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

Laravel Pipeline Pattern: Building Custom Pipelines Beyond Middleware

4 min read Mohamed Said Mohamed Said

The Pipeline Pattern in Laravel: Beyond Middleware

Most Laravel developers know Pipeline as the engine behind HTTP middleware. Fewer reach for it when modelling domain workflows — and that's a missed opportunity. The Illuminate\Pipeline\Pipeline class is a general-purpose, composable tool that can replace tangled chains of service calls, nested conditionals, and procedural scripts.

What the Pipeline Facade Actually Does

At its core, a pipeline passes a payload through an ordered list of pipes, each of which can transform the payload or short-circuit the chain.

use Illuminate\Support\Facades\Pipeline;

$result = Pipeline::send($order)
    ->through([
        ValidateInventory::class,
        ApplyDiscounts::class,
        CalculateTax::class,
        ReserveStock::class,
    ])
    ->thenReturn();

Each pipe receives ($payload, Closure $next) and must call $next($payload) to continue — identical to middleware, but with no HTTP coupling whatsoever.

Typed Pipe Classes

Anonymous closures work for quick scripts, but named classes are the right choice for production pipelines. Make the contract explicit with an interface:

interface OrderPipe
{
    public function handle(OrderContext $context, Closure $next): OrderContext;
}

Then implement each step as a focused, injectable class:

final class ApplyDiscounts implements OrderPipe
{
    public function __construct(
        private readonly DiscountRepository $discounts
    ) {}

    public function handle(OrderContext $context, Closure $next): OrderContext
    {
        $applicable = $this->discounts->forCustomer($context->customer);

        return $next(
            $context->withDiscount($applicable->totalAmount())
        );
    }
}

Because Laravel resolves pipe classes through the service container, constructor injection works automatically — no factory boilerplate needed.

Carrying State with a Context DTO

Passing a mutable array through a pipeline is fragile. Instead, use an immutable value object (or a simple DTO with with* wither methods) as the payload:

final class OrderContext
{
    public function __construct(
        public readonly Order $order,
        public readonly Customer $customer,
        public readonly Money $discount = new Money(0),
        public readonly Money $tax = new Money(0),
    ) {}

    public function withDiscount(Money $discount): self
    {
        return new self($this->order, $this->customer, $discount, $this->tax);
    }

    public function withTax(Money $tax): self
    {
        return new self($this->order, $this->customer, $this->discount, $tax);
    }
}

Each pipe returns a new context rather than mutating shared state, making the data flow trivially traceable.

Short-Circuiting and Early Returns

Sometimes a pipe should halt the chain — for example, when inventory is insufficient:

final class ValidateInventory implements OrderPipe
{
    public function handle(OrderContext $context, Closure $next): OrderContext
    {
        foreach ($context->order->lines as $line) {
            if (! $line->product->hasStock($line->quantity)) {
                // Return without calling $next — pipeline stops here.
                return $context->withError(
                    "Insufficient stock for {$line->product->sku}"
                );
            }
        }

        return $next($context);
    }
}

The caller inspects $result->hasError() after thenReturn(). No exceptions required for expected business failures.

Building a Reusable Pipeline Service

For pipelines used in multiple places, wrap them in a dedicated service:

final class OrderProcessingPipeline
{
    private array $pipes = [
        ValidateInventory::class,
        ApplyDiscounts::class,
        CalculateTax::class,
        ReserveStock::class,
    ];

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

    public function process(OrderContext $context): OrderContext
    {
        return $this->pipeline
            ->send($context)
            ->through($this->pipes)
            ->thenReturn();
    }
}

Bind it in a service provider and inject it wherever needed. Swapping or reordering pipes becomes a one-line change.

Testing Individual Pipes

Because each pipe is a plain class, unit testing is straightforward with Pest:

it('applies the highest available discount', function () {
    $discounts = Mockery::mock(DiscountRepository::class);
    $discounts->allows('forCustomer')->andReturn(
        new DiscountCollection([new Discount(Money::of(20, 'GBP'))])
    );

    $pipe = new ApplyDiscounts($discounts);
    $context = OrderContext::fake();

    $result = $pipe->handle($context, fn ($ctx) => $ctx);

    expect($result->discount->amount())->toBe(20);
});

No HTTP kernel, no database — just the pipe and its collaborators.

Takeaways

  • Use Pipeline::send()->through()->thenReturn() for any multi-step domain workflow, not just HTTP.
  • Typed pipe interfaces and immutable context DTOs eliminate ambiguity about what each step receives and returns.
  • Container resolution means constructor injection works in every pipe class for free.
  • Short-circuit by returning the payload without calling $next — no exceptions needed for expected failures.
  • Wrapping pipelines in a dedicated service class makes reuse, testing, and pipe reordering trivial.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I use the Laravel Pipeline outside of HTTP middleware?
Yes. `Illuminate\Pipeline\Pipeline` is a standalone class with no HTTP coupling. You can inject it into any service and use it for domain workflows, data transformation, import processing, or any multi-step operation.
Q02 How do I stop a pipeline early without throwing an exception?
Simply return the payload from a pipe without calling `$next($payload)`. The pipeline stops at that pipe and `thenReturn()` returns the current payload. Model expected failures as data on the context object rather than exceptions.
Q03 Are pipe classes resolved from the service container?
Yes. When you pass class name strings to `through()`, Laravel resolves each one via `app()->make()`, so constructor dependencies are injected automatically just like controllers or jobs.

Continue reading

More Articles

View all