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

The Pipeline Pattern in Laravel: Custom Pipelines Beyond Middleware

3 min read Mohamed Said Mohamed Said

The Pipeline Pattern in Laravel: Custom Pipelines Beyond Middleware

Most Laravel developers know Illuminate\Pipeline\Pipeline only as the machinery that runs HTTP middleware. That's a shame — it's one of the cleanest abstractions in the framework, and it maps perfectly onto domain workflows: order processing, document ingestion, multi-step imports, and anything else that passes a single object through a sequence of transformations.

What the Pipeline Actually Does

At its core, Pipeline::send($passable)->through($pipes)->thenReturn() builds a nested closure stack and calls it. Each pipe receives the passable and a $next callable. That's it. No magic, no hidden state.

use Illuminate\Pipeline\Pipeline;

$result = app(Pipeline::class)
    ->send($passable)
    ->through([
        PipeA::class,
        PipeB::class,
    ])
    ->thenReturn();

The default method invoked on each class is handle($passable, Closure $next). You can override this with ->via('process') if your pipes already have a different contract.

Typed Passables: Use a DTO

Passing a plain array through a pipeline is asking for bugs. Use a mutable DTO so every pipe has a typed contract.

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

    public function addError(string $message): void
    {
        $this->errors[] = $message;
    }

    public function hasErrors(): bool
    {
        return count($this->errors) > 0;
    }
}

Each pipe reads from and writes to this context. Nothing leaks outside the pipeline.

Writing a Pipe Class

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

    public function handle(OrderImportContext $context, Closure $next): OrderImportContext
    {
        $sku = $context->rawRow['sku'] ?? null;

        if (! $sku) {
            $context->addError('Missing SKU');
            return $next($context); // continue; downstream pipes may still run
        }

        $context->product = $this->products->findBySku($sku);

        if (! $context->product) {
            $context->addError("Unknown SKU: {$sku}");
        }

        return $next($context);
    }
}

Because pipes are resolved via the service container, constructor injection works out of the box.

Short-Circuiting the Pipeline

Sometimes you want to halt on the first error. Simply don't call $next:

public function handle(OrderImportContext $context, Closure $next): OrderImportContext
{
    if ($context->hasErrors()) {
        return $context; // bail early, skip remaining pipes
    }

    // ... do work

    return $next($context);
}

This is the same mechanism Laravel's own CheckForMaintenanceMode middleware uses.

Assembling the Pipeline as a Service

Don't scatter app(Pipeline::class) calls across your codebase. Wrap it:

final class OrderImportPipeline
{
    private array $pipes = [
        ResolveProduct::class,
        ResolveCustomer::class,
        ValidateQuantity::class,
        PersistOrder::class,
    ];

    public function __construct(
        private readonly Pipeline $pipeline,
    ) {}

    public function run(array $rawRow): OrderImportContext
    {
        return $this->pipeline
            ->send(new OrderImportContext($rawRow))
            ->through($this->pipes)
            ->thenReturn();
    }
}

Bind it in a service provider and inject it wherever needed.

Testing with Pest

Because each pipe is a plain class, you can test them in isolation:

it('adds an error when SKU is missing', function () {
    $pipe = new ResolveProduct(
        products: Mockery::mock(ProductRepository::class),
    );

    $context = new OrderImportContext(rawRow: []);
    $result = $pipe->handle($context, fn ($ctx) => $ctx);

    expect($result->errors)->toContain('Missing SKU');
});

And the full pipeline as an integration test:

it('persists a valid order row end-to-end', function () {
    $pipeline = app(OrderImportPipeline::class);
    $context = $pipeline->run(['sku' => 'WIDGET-1', 'qty' => 2, 'customer_id' => 1]);

    expect($context->hasErrors())->toBeFalse()
        ->and(Order::count())->toBe(1);
});

Takeaways

  • Pipeline is a first-class Laravel primitive — use it for any sequential, composable workflow.
  • A typed DTO passable eliminates silent type errors and makes each pipe self-documenting.
  • Pipes are container-resolved, so full DI is available without any extra wiring.
  • Short-circuit by returning early without calling $next; continue by always calling it.
  • Wrapping the pipeline in a dedicated service class keeps assembly logic in one place and makes the pipeline trivially swappable in tests.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I pass a pipeline instance through the service container and swap pipes per context?
Yes. Bind your pipeline wrapper in a service provider and use contextual binding or a factory method to inject different pipe sets for different contexts — for example, a stricter validation pipe set for API imports versus a lenient one for admin bulk uploads.
Q02 Is there a performance cost to using Pipeline over a plain foreach loop?
The overhead is negligible for typical domain workflows. Pipeline builds a closure stack once per invocation, which adds microseconds. For tight loops processing thousands of rows per second, a plain loop is faster, but for most business logic the readability and testability gains outweigh any micro-benchmark difference.
Q03 How do I handle exceptions thrown inside a pipe?
Exceptions bubble up normally through the closure stack. Wrap your `thenReturn()` call in a try/catch at the call site, or add a dedicated exception-catching pipe at the start of the pipeline that wraps `$next($context)` in a try/catch and records the error on the context object.

Continue reading

More Articles

View all