The Pipeline Pattern in Laravel: Custom Pipelines Beyond Middleware
Most Laravel developers know Illuminate\Pipeline\Pipeline only as the machinery that runs HTTP middleware. That's a shame — it's one of the cleanest abstractions in the framework, and it maps perfectly onto domain workflows: order processing, document ingestion, multi-step imports, and anything else that passes a single object through a sequence of transformations.
What the Pipeline Actually Does
At its core, Pipeline::send($passable)->through($pipes)->thenReturn() builds a nested closure stack and calls it. Each pipe receives the passable and a $next callable. That's it. No magic, no hidden state.
use Illuminate\Pipeline\Pipeline;
$result = app(Pipeline::class)
->send($passable)
->through([
PipeA::class,
PipeB::class,
])
->thenReturn();
The default method invoked on each class is handle($passable, Closure $next). You can override this with ->via('process') if your pipes already have a different contract.
Typed Passables: Use a DTO
Passing a plain array through a pipeline is asking for bugs. Use a mutable DTO so every pipe has a typed contract.
final class OrderImportContext
{
public function __construct(
public readonly array $rawRow,
public ?Product $product = null,
public ?Customer $customer = null,
public array $errors = [],
) {}
public function addError(string $message): void
{
$this->errors[] = $message;
}
public function hasErrors(): bool
{
return count($this->errors) > 0;
}
}
Each pipe reads from and writes to this context. Nothing leaks outside the pipeline.
Writing a Pipe Class
final class ResolveProduct
{
public function __construct(
private readonly ProductRepository $products,
) {}
public function handle(OrderImportContext $context, Closure $next): OrderImportContext
{
$sku = $context->rawRow['sku'] ?? null;
if (! $sku) {
$context->addError('Missing SKU');
return $next($context); // continue; downstream pipes may still run
}
$context->product = $this->products->findBySku($sku);
if (! $context->product) {
$context->addError("Unknown SKU: {$sku}");
}
return $next($context);
}
}
Because pipes are resolved via the service container, constructor injection works out of the box.
Short-Circuiting the Pipeline
Sometimes you want to halt on the first error. Simply don't call $next:
public function handle(OrderImportContext $context, Closure $next): OrderImportContext
{
if ($context->hasErrors()) {
return $context; // bail early, skip remaining pipes
}
// ... do work
return $next($context);
}
This is the same mechanism Laravel's own CheckForMaintenanceMode middleware uses.
Assembling the Pipeline as a Service
Don't scatter app(Pipeline::class) calls across your codebase. Wrap it:
final class OrderImportPipeline
{
private array $pipes = [
ResolveProduct::class,
ResolveCustomer::class,
ValidateQuantity::class,
PersistOrder::class,
];
public function __construct(
private readonly Pipeline $pipeline,
) {}
public function run(array $rawRow): OrderImportContext
{
return $this->pipeline
->send(new OrderImportContext($rawRow))
->through($this->pipes)
->thenReturn();
}
}
Bind it in a service provider and inject it wherever needed.
Testing with Pest
Because each pipe is a plain class, you can test them in isolation:
it('adds an error when SKU is missing', function () {
$pipe = new ResolveProduct(
products: Mockery::mock(ProductRepository::class),
);
$context = new OrderImportContext(rawRow: []);
$result = $pipe->handle($context, fn ($ctx) => $ctx);
expect($result->errors)->toContain('Missing SKU');
});
And the full pipeline as an integration test:
it('persists a valid order row end-to-end', function () {
$pipeline = app(OrderImportPipeline::class);
$context = $pipeline->run(['sku' => 'WIDGET-1', 'qty' => 2, 'customer_id' => 1]);
expect($context->hasErrors())->toBeFalse()
->and(Order::count())->toBe(1);
});
Takeaways
Pipelineis a first-class Laravel primitive — use it for any sequential, composable workflow.- A typed DTO passable eliminates silent type errors and makes each pipe self-documenting.
- Pipes are container-resolved, so full DI is available without any extra wiring.
- Short-circuit by returning early without calling
$next; continue by always calling it. - Wrapping the pipeline in a dedicated service class keeps assembly logic in one place and makes the pipeline trivially swappable in tests.