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.