The Pipeline Pattern in Laravel: Building Custom Pipelines Beyond Middleware
#laravel #design-patterns #pipeline #clean-architecture

The Pipeline Pattern in Laravel: Building Custom Pipelines Beyond Middleware

3 min read Mohamed Said Mohamed Said

The Pipeline Pattern in Laravel: Beyond HTTP Middleware

Most Laravel developers know Pipeline only as the engine behind HTTP middleware. But Illuminate\Pipeline\Pipeline is a first-class, general-purpose primitive you can reach for whenever you need to pass a payload through an ordered sequence of transformations — order processing, CSV imports, AI prompt enrichment, multi-step validation, and more.

Why Pipelines Over Plain Method Chains?

A plain method chain couples every step to the caller. Pipelines decouple the what (the payload) from the how (the stages), letting you:

  • Swap, reorder, or skip stages at runtime
  • Test each stage in complete isolation
  • Resolve stages from the service container (constructor injection works automatically)

The Core API

use Illuminate\Pipeline\Pipeline;

$result = app(Pipeline::class)
    ->send($payload)
    ->through([
        NormaliseInput::class,
        ValidateBusinessRules::class,
        PersistOrder::class,
        DispatchConfirmationEmail::class,
    ])
    ->thenReturn();

thenReturn() returns the (possibly mutated) payload after all stages. Use then(fn ($p) => ...) when you need a final transformation step.

Writing a Stage

A stage is any class with a handle method — or any callable. The signature mirrors middleware:

final class NormaliseInput
{
    public function handle(OrderData $data, Closure $next): OrderData
    {
        $data = $data->withNormalisedEmail(
            mb_strtolower(trim($data->email))
        );

        return $next($data);
    }
}

Because stages are resolved via the container, you can inject repositories, config, or services freely:

final class ValidateBusinessRules
{
    public function __construct(
        private readonly ProductRepository $products,
    ) {}

    public function handle(OrderData $data, Closure $next): OrderData
    {
        foreach ($data->lines as $line) {
            throw_unless(
                $this->products->isAvailable($line->sku, $line->qty),
                InsufficientStockException::class,
                "SKU {$line->sku} is out of stock."
            );
        }

        return $next($data);
    }
}

Dynamic Stage Selection

Stages don't have to be a static array. Build them at runtime based on context:

$stages = collect([
    NormaliseInput::class,
    ValidateBusinessRules::class,
])
->when($order->requiresExportCompliance(), fn ($c) =>
    $c->push(ExportComplianceCheck::class)
)
->push(PersistOrder::class)
->all();

app(Pipeline::class)->send($order)->through($stages)->thenReturn();

A Typed Pipeline Wrapper

For domain code, a thin wrapper improves discoverability and enforces the payload type:

final class OrderProcessingPipeline
{
    public function __construct(private readonly Pipeline $pipeline) {}

    /** @param class-string[] $extraStages */
    public function process(OrderData $data, array $extraStages = []): OrderData
    {
        return $this->pipeline
            ->send($data)
            ->through([
                NormaliseInput::class,
                ValidateBusinessRules::class,
                ...$extraStages,
                PersistOrder::class,
            ])
            ->thenReturn();
    }
}

Bind it as a singleton and inject it wherever needed. Callers never touch the raw Pipeline class.

Testing Stages in Isolation

Because each stage is a plain class, unit testing is trivial:

it('normalises the email to lowercase', function () {
    $stage = new NormaliseInput();
    $data  = OrderData::fake(['email' => '  USER@Example.COM  ']);

    $result = $stage->handle($data, fn ($d) => $d);

    expect($result->email)->toBe('user@example.com');
});

No HTTP context, no database, no framework bootstrap required.

When Not to Use a Pipeline

Pipelines shine when stages are interchangeable and the payload flows linearly. Avoid them when:

  • Stages need to communicate laterally (use a saga or process manager instead)
  • The flow is highly conditional with many branches (a state machine is clearer)
  • You only have one or two steps (a simple service method is less indirection)

Takeaways

  • Illuminate\Pipeline\Pipeline is a general-purpose tool, not just for HTTP middleware.
  • Stages are container-resolved classes — constructor injection works out of the box.
  • Wrap the raw pipeline in a typed domain class to enforce payload contracts.
  • Each stage is independently unit-testable with a simple closure as the $next stub.
  • Build stage lists dynamically with collect()->when() for context-sensitive flows.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I use closures as pipeline stages instead of classes?
Yes. The `through()` method accepts any iterable of callables, including closures. Classes are preferred in production code because they are container-resolved, named, and independently testable, but closures are handy for quick ad-hoc stages or in tests.
Q02 How do I stop the pipeline early, for example on a validation failure?
Simply throw an exception inside the stage and do not call `$next`. Wrap the `thenReturn()` call in a try/catch at the call site. This keeps each stage's responsibility clear and avoids boolean flags polluting the payload.
Q03 Is there a performance cost to using the Pipeline class compared to direct method calls?
The overhead is negligible for typical domain workflows — a handful of container resolutions and closure calls. Only consider alternatives if you are running thousands of pipeline executions per request in a tight loop, which would be unusual.

Continue reading

More Articles

View all