The Pipeline Pattern in Laravel: Beyond HTTP Middleware
Most Laravel developers know Pipeline only as the engine behind HTTP middleware. But Illuminate\Pipeline\Pipeline is a general-purpose, composable tool you can use anywhere in your domain — order processing, import validation, document transformation, multi-stage approval flows.
This article shows how to design typed, testable pipelines that carry real domain meaning.
The Core API
use Illuminate\Pipeline\Pipeline;
$result = app(Pipeline::class)
->send($payload)
->through([
StageOne::class,
StageTwo::class,
])
->thenReturn();
thenReturn() returns the final $payload after all pipes run. Each pipe receives ($payload, Closure $next) and must call $next($payload) to continue — identical to middleware.
Typed Payloads: Stop Passing Arrays
The single biggest improvement you can make is replacing anonymous arrays with a typed DTO.
final class OrderImportContext
{
public function __construct(
public readonly array $rawRow,
public ?Product $product = null,
public ?Customer $customer = null,
public array $errors = [],
) {}
public function withError(string $message): self
{
$clone = clone $this;
$clone->errors[] = $message;
return $clone;
}
}
Each pipe receives a fully typed object. IDE completion works. Pipes are self-documenting.
Writing a Pipe
A pipe is any class with a handle method (or __invoke if you prefer).
final class ResolveProduct
{
public function handle(OrderImportContext $ctx, Closure $next): OrderImportContext
{
$product = Product::query()
->where('sku', $ctx->rawRow['sku'] ?? '')
->first();
if ($product === null) {
return $next($ctx->withError('Unknown SKU: ' . ($ctx->rawRow['sku'] ?? 'n/a')));
}
$ctx = clone $ctx;
$ctx->product = $product;
return $next($ctx);
}
}
Notice: even on error we call $next(). This lets downstream pipes accumulate all errors in one pass rather than short-circuiting on the first failure.
Short-Circuiting When You Need It
Sometimes you do want to stop early — for example, a hard authentication failure.
final class AssertActiveCustomer
{
public function handle(OrderImportContext $ctx, Closure $next): OrderImportContext
{
if ($ctx->customer?->isSuspended()) {
// Return without calling $next — pipeline stops here.
return $ctx->withError('Customer account is suspended.');
}
return $next($ctx);
}
}
The pipeline returns whatever value the pipe returns, so the caller always gets an OrderImportContext.
Registering Pipelines as Named Services
Bind your pipeline configuration in a service provider so it's injectable and swappable:
$this->app->bind(OrderImportPipeline::class, function ($app) {
return new class($app) {
public function __construct(private \Illuminate\Contracts\Container\Container $app) {}
public function run(OrderImportContext $ctx): OrderImportContext
{
return (new Pipeline($this->app))
->send($ctx)
->through([
ResolveProduct::class,
ResolveCustomer::class,
AssertActiveCustomer::class,
ValidateQuantity::class,
PersistOrder::class,
])
->thenReturn();
}
};
});
Inject OrderImportPipeline wherever you need it. Swap the pipe list in tests.
Testing Pipelines with Pest
it('accumulates errors for unknown sku and suspended customer', function () {
$ctx = new OrderImportContext(
rawRow: ['sku' => 'GHOST-99', 'qty' => 1],
customer: Customer::factory()->suspended()->make(),
);
$result = app(OrderImportPipeline::class)->run($ctx);
expect($result->errors)
->toContain('Unknown SKU: GHOST-99')
->toContain('Customer account is suspended.');
});
Because each pipe is a plain class, you can also unit-test pipes in isolation:
it('short-circuits on suspended customer', function () {
$ctx = new OrderImportContext(
rawRow: [],
customer: Customer::factory()->suspended()->make(),
);
$pipe = new AssertActiveCustomer();
$result = $pipe->handle($ctx, fn ($c) => $c);
expect($result->errors)->toContain('Customer account is suspended.');
});
Key Takeaways
- Use typed DTOs as the pipeline payload — never raw arrays.
- Accumulate vs. short-circuit is a deliberate design choice per pipeline.
- Bind named pipelines in service providers for DI and testability.
- Each pipe is a plain class — unit-testable without bootstrapping the full pipeline.
- The
Pipelinefacade is available globally, but preferapp(Pipeline::class)for container-resolved pipes. - Pipelines compose naturally with Laravel's queue system: serialize the DTO, dispatch a job, run the pipeline inside the job.