Skip to content

The Pipeline Pattern in Laravel: Building Custom Pipelines Beyond Middleware

Laravel's Pipeline class is far more powerful than middleware alone. Learn how to compose reusable, testable processing stages for domain workflows using the same primitive the framework uses internally.

Published · Updated · 3 min read

The Pipeline Pattern in Laravel: Building Custom Pipelines Beyond Middleware

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.

Frequently asked questions

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.

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.

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.

Mohamed Said

About the author

Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

About GitHub LinkedIn WhatsApp Email Address My CV

Have a technical challenge?

Tell me what you’re building. I reply within two working days.

Discuss your project