The Pipeline Pattern in Laravel: Beyond Middleware
Most Laravel developers know Pipeline as the engine behind HTTP middleware. Fewer realise it's a first-class citizen available anywhere in your application — and that it's one of the cleanest ways to model sequential, composable domain workflows.
This article shows how to build typed, testable pipelines for real business logic: order processing, document transformation, multi-step validation, and more.
What the Pipeline Class Actually Does
Illuminate\Pipeline\Pipeline passes a payload through an ordered list of pipes. Each pipe receives the payload and a $next closure. It can mutate the payload, short-circuit the chain, or simply delegate.
use Illuminate\Pipeline\Pipeline;
$result = app(Pipeline::class)
->send($payload)
->through([
StageOne::class,
StageTwo::class,
StageThree::class,
])
->thenReturn();
The default method called on each pipe is handle. You can override this:
->via('process')
Typing Your Payload with a DTO
Untyped arrays as payloads are a maintenance trap. Use a readonly DTO:
final readonly class OrderContext
{
public function __construct(
public Order $order,
public ?Discount $discount = null,
public bool $inventoryReserved = false,
public bool $notificationSent = false,
) {}
public function withDiscount(Discount $discount): self
{
return new self(
order: $this->order,
discount: $discount,
inventoryReserved: $this->inventoryReserved,
notificationSent: $this->notificationSent,
);
}
}
Each stage returns a new OrderContext rather than mutating state — immutability makes stages trivially testable in isolation.
Writing a Typed Stage
final class ApplyDiscountStage
{
public function __construct(
private readonly DiscountRepository $discounts,
) {}
public function handle(OrderContext $context, Closure $next): OrderContext
{
$discount = $this->discounts->findForOrder($context->order);
$context = $discount
? $context->withDiscount($discount)
: $context;
return $next($context);
}
}
The container resolves constructor dependencies automatically, so ApplyDiscountStage is fully injectable without any extra wiring.
Conditional Pipes and Runtime Composition
Pipelines shine when stage lists are dynamic:
$pipes = [
ValidateInventoryStage::class,
ApplyDiscountStage::class,
];
if ($context->order->requiresExportCompliance()) {
$pipes[] = ExportComplianceCheckStage::class;
}
$pipes[] = ReserveInventoryStage::class;
$pipes[] = SendConfirmationStage::class;
$result = app(Pipeline::class)
->send($context)
->through($pipes)
->thenReturn();
This is far cleaner than a chain of if blocks or a bloated service method.
Handling Failures Cleanly
Short-circuit by throwing a domain exception inside a stage:
final class ValidateInventoryStage
{
public function handle(OrderContext $context, Closure $next): OrderContext
{
if (! $this->inventory->isAvailable($context->order)) {
throw new InsufficientInventoryException($context->order->id);
}
return $next($context);
}
}
Wrap the pipeline call in a try/catch at the application layer. The pipeline itself stays free of error-handling noise.
Testing a Stage in Isolation
Because each stage is a plain class with a single handle method, Pest tests are minimal:
it('applies a discount when one exists', function () {
$order = Order::factory()->make();
$discount = new Discount(percentage: 10);
$repo = Mockery::mock(DiscountRepository::class);
$repo->shouldReceive('findForOrder')->once()->andReturn($discount);
$stage = new ApplyDiscountStage($repo);
$context = new OrderContext(order: $order);
$result = $stage->handle($context, fn ($ctx) => $ctx);
expect($result->discount)->toBe($discount);
});
No HTTP, no database, no Kernel bootstrapping.
Takeaways
Illuminate\Pipeline\Pipelineis not HTTP-specific — use it anywhere sequential, composable logic is needed.- Readonly DTOs as payloads give you immutability and IDE autocompletion throughout the chain.
- Stages are container-resolved classes: inject repositories, services, and config freely.
- Dynamic pipe arrays let you compose workflows at runtime based on domain rules.
- Each stage is independently unit-testable with zero framework overhead.