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

Laravel Pipeline Pattern: Building Custom Pipelines Beyond Middleware

4 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 general-purpose, composable tool you can use anywhere in your domain — order processing, import validation, document transformation, multi-stage approval flows.

This article shows how to design typed, testable pipelines that carry real domain meaning.


The Core API

use Illuminate\Pipeline\Pipeline;

$result = app(Pipeline::class)
    ->send($payload)
    ->through([
        StageOne::class,
        StageTwo::class,
    ])
    ->thenReturn();

thenReturn() returns the final $payload after all pipes run. Each pipe receives ($payload, Closure $next) and must call $next($payload) to continue — identical to middleware.


Typed Payloads: Stop Passing Arrays

The single biggest improvement you can make is replacing anonymous arrays with a typed DTO.

final class OrderImportContext
{
    public function __construct(
        public readonly array  $rawRow,
        public ?Product        $product   = null,
        public ?Customer       $customer  = null,
        public array           $errors    = [],
    ) {}

    public function withError(string $message): self
    {
        $clone = clone $this;
        $clone->errors[] = $message;
        return $clone;
    }
}

Each pipe receives a fully typed object. IDE completion works. Pipes are self-documenting.


Writing a Pipe

A pipe is any class with a handle method (or __invoke if you prefer).

final class ResolveProduct
{
    public function handle(OrderImportContext $ctx, Closure $next): OrderImportContext
    {
        $product = Product::query()
            ->where('sku', $ctx->rawRow['sku'] ?? '')
            ->first();

        if ($product === null) {
            return $next($ctx->withError('Unknown SKU: ' . ($ctx->rawRow['sku'] ?? 'n/a')));
        }

        $ctx = clone $ctx;
        $ctx->product = $product;

        return $next($ctx);
    }
}

Notice: even on error we call $next(). This lets downstream pipes accumulate all errors in one pass rather than short-circuiting on the first failure.


Short-Circuiting When You Need It

Sometimes you do want to stop early — for example, a hard authentication failure.

final class AssertActiveCustomer
{
    public function handle(OrderImportContext $ctx, Closure $next): OrderImportContext
    {
        if ($ctx->customer?->isSuspended()) {
            // Return without calling $next — pipeline stops here.
            return $ctx->withError('Customer account is suspended.');
        }

        return $next($ctx);
    }
}

The pipeline returns whatever value the pipe returns, so the caller always gets an OrderImportContext.


Registering Pipelines as Named Services

Bind your pipeline configuration in a service provider so it's injectable and swappable:

$this->app->bind(OrderImportPipeline::class, function ($app) {
    return new class($app) {
        public function __construct(private \Illuminate\Contracts\Container\Container $app) {}

        public function run(OrderImportContext $ctx): OrderImportContext
        {
            return (new Pipeline($this->app))
                ->send($ctx)
                ->through([
                    ResolveProduct::class,
                    ResolveCustomer::class,
                    AssertActiveCustomer::class,
                    ValidateQuantity::class,
                    PersistOrder::class,
                ])
                ->thenReturn();
        }
    };
});

Inject OrderImportPipeline wherever you need it. Swap the pipe list in tests.


Testing Pipelines with Pest

it('accumulates errors for unknown sku and suspended customer', function () {
    $ctx = new OrderImportContext(
        rawRow: ['sku' => 'GHOST-99', 'qty' => 1],
        customer: Customer::factory()->suspended()->make(),
    );

    $result = app(OrderImportPipeline::class)->run($ctx);

    expect($result->errors)
        ->toContain('Unknown SKU: GHOST-99')
        ->toContain('Customer account is suspended.');
});

Because each pipe is a plain class, you can also unit-test pipes in isolation:

it('short-circuits on suspended customer', function () {
    $ctx = new OrderImportContext(
        rawRow: [],
        customer: Customer::factory()->suspended()->make(),
    );

    $pipe   = new AssertActiveCustomer();
    $result = $pipe->handle($ctx, fn ($c) => $c);

    expect($result->errors)->toContain('Customer account is suspended.');
});

Key Takeaways

  • Use typed DTOs as the pipeline payload — never raw arrays.
  • Accumulate vs. short-circuit is a deliberate design choice per pipeline.
  • Bind named pipelines in service providers for DI and testability.
  • Each pipe is a plain class — unit-testable without bootstrapping the full pipeline.
  • The Pipeline facade is available globally, but prefer app(Pipeline::class) for container-resolved pipes.
  • Pipelines compose naturally with Laravel's queue system: serialize the DTO, dispatch a job, run the pipeline inside the job.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I use closures instead of classes as pipes?
Yes. The Pipeline accepts any callable, including closures. However, named classes are strongly preferred in production code because they are individually testable, injectable via the container, and visible in stack traces.
Q02 How does the Laravel Pipeline differ from a simple foreach loop over handlers?
The Pipeline uses a nested closure chain (similar to a Russian-doll middleware stack), which means each pipe controls whether and when to call the next stage. A foreach loop cannot short-circuit or wrap the downstream execution — for example, a pipe cannot wrap $next() in a database transaction and roll back on failure.
Q03 Is there a performance cost to using Pipeline for high-throughput code paths?
The overhead is negligible for typical domain workflows. The closure chain adds a small amount of stack depth, but this is rarely measurable against real I/O. For extremely tight loops (e.g., processing millions of rows in memory), a direct method call chain will be marginally faster, but the Pipeline is appropriate for the vast majority of Laravel use cases.

Continue reading

More Articles

View all