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.