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

Laravel Pipeline Pattern: Building Custom Pipelines Beyond Middleware

3 min read Mohamed Said Mohamed Said

The Pipeline Pattern Beyond Middleware

Most Laravel developers know Pipeline from HTTP middleware, but the same Illuminate\Pipeline\Pipeline class is available anywhere in your application. Used deliberately, it replaces tangled service methods with a clean, composable chain of single-responsibility stages.

Why Reach for a Pipeline?

Consider an order-submission workflow: validate inventory, apply discount rules, charge payment, dispatch fulfilment, send confirmation. Stuffing all of that into an OrderService::submit() method creates a god-object. A pipeline turns each concern into its own class, testable in isolation.

Defining a Typed Payload

Start with a readonly DTO so every stage shares a common contract:

readonly class OrderContext
{
    public function __construct(
        public Order $order,
        public ?Discount $discount = null,
        public bool $paymentCaptured = false,
    ) {}

    public function withDiscount(Discount $discount): self
    {
        return new self($this->order, $discount, $this->paymentCaptured);
    }

    public function markPaymentCaptured(): self
    {
        return new self($this->order, $this->discount, true);
    }
}

Immutable DTOs prevent stages from silently mutating shared state — a common bug in mutable pipeline payloads.

Writing Stages

Each stage receives the payload and a $next closure:

final class ApplyDiscountStage
{
    public function __construct(
        private readonly DiscountResolver $resolver,
    ) {}

    public function handle(OrderContext $context, Closure $next): OrderContext
    {
        $discount = $this->resolver->forOrder($context->order);

        return $next($context->withDiscount($discount));
    }
}
final class CapturePaymentStage
{
    public function __construct(
        private readonly PaymentGateway $gateway,
    ) {}

    public function handle(OrderContext $context, Closure $next): OrderContext
    {
        $this->gateway->capture($context->order, $context->discount);

        return $next($context->markPaymentCaptured());
    }
}

Note the explicit return type on handle. Laravel's pipeline calls handle by convention, but you can customise the method name via ->via('process') if you prefer.

Assembling the Pipeline

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

    public function execute(Order $order): OrderContext
    {
        return $this->pipeline
            ->send(new OrderContext($order))
            ->through([
                ValidateInventoryStage::class,
                ApplyDiscountStage::class,
                CapturePaymentStage::class,
                DispatchFulfilmentStage::class,
                SendConfirmationStage::class,
            ])
            ->thenReturn();
    }
}

thenReturn() returns the final payload. Use ->then(fn ($ctx) => ...) when you need a different return value at the end.

Short-Circuiting a Stage

Sometimes a stage should halt the chain — for example, if inventory is unavailable:

final class ValidateInventoryStage
{
    public function handle(OrderContext $context, Closure $next): OrderContext
    {
        if (! $context->order->hasStock()) {
            throw new InsufficientStockException($context->order);
        }

        return $next($context);
    }
}

Throw a domain exception rather than returning early without calling $next. This keeps the pipeline contract honest and lets callers handle failures uniformly.

Testing Stages in Isolation with Pest

it('applies a discount when one is available', function () {
    $resolver = Mockery::mock(DiscountResolver::class);
    $resolver->expects('forOrder')->andReturn(new Discount(10));

    $stage = new ApplyDiscountStage($resolver);
    $order = Order::factory()->make();
    $context = new OrderContext($order);

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

    expect($result->discount)->toBeInstanceOf(Discount::class)
        ->and($result->discount->percentage)->toBe(10);
});

Because each stage is a plain class with constructor injection, you never need to boot the full application to test it.

Registering Stages via the Container

Laravel resolves stage class names through the service container, so constructor dependencies are injected automatically. If a stage needs contextual binding, register it in a service provider:

$this->app->when(CapturePaymentStage::class)
    ->needs(PaymentGateway::class)
    ->give(StripeGateway::class);

Takeaways

  • Use readonly DTOs as pipeline payloads to prevent silent mutation between stages.
  • Each stage is a single-responsibility class — test it without booting the framework.
  • Throw domain exceptions to short-circuit; never silently skip $next.
  • Constructor injection works automatically because Laravel resolves stages via the container.
  • ->via('process') lets you rename the handler method for semantic clarity.
  • Pipelines compose naturally with actions, making complex workflows readable and auditable.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I reuse the same Pipeline instance across multiple requests in Octane?
No. Bind `Pipeline` as a transient (non-singleton) or resolve it fresh each time via `app(Pipeline::class)`. A shared instance retains the previous payload and stages between requests, causing subtle bugs under Octane's persistent worker model.
Q02 What is the difference between `thenReturn()` and `then()`?
`thenReturn()` is syntactic sugar for `->then(fn ($payload) => $payload)` — it simply returns the final payload unchanged. Use `->then(Closure $destination)` when you need to transform or persist the result after all stages have run.
Q03 Should pipeline stages be final classes?
Marking them `final` is a good default. Stages represent a single, concrete behaviour; allowing inheritance invites accidental overrides that break the pipeline contract. If you need variation, compose a new stage rather than extending an existing one.

Continue reading

More Articles

View all