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 first-class, general-purpose primitive you can reach for whenever you need to pass a payload through an ordered sequence of transformations — order processing, CSV imports, AI prompt enrichment, multi-step validation, and more.
Why Pipelines Over Plain Method Chains?
A plain method chain couples every step to the caller. Pipelines decouple the what (the payload) from the how (the stages), letting you:
- Swap, reorder, or skip stages at runtime
- Test each stage in complete isolation
- Resolve stages from the service container (constructor injection works automatically)
The Core API
use Illuminate\Pipeline\Pipeline;
$result = app(Pipeline::class)
->send($payload)
->through([
NormaliseInput::class,
ValidateBusinessRules::class,
PersistOrder::class,
DispatchConfirmationEmail::class,
])
->thenReturn();
thenReturn() returns the (possibly mutated) payload after all stages. Use then(fn ($p) => ...) when you need a final transformation step.
Writing a Stage
A stage is any class with a handle method — or any callable. The signature mirrors middleware:
final class NormaliseInput
{
public function handle(OrderData $data, Closure $next): OrderData
{
$data = $data->withNormalisedEmail(
mb_strtolower(trim($data->email))
);
return $next($data);
}
}
Because stages are resolved via the container, you can inject repositories, config, or services freely:
final class ValidateBusinessRules
{
public function __construct(
private readonly ProductRepository $products,
) {}
public function handle(OrderData $data, Closure $next): OrderData
{
foreach ($data->lines as $line) {
throw_unless(
$this->products->isAvailable($line->sku, $line->qty),
InsufficientStockException::class,
"SKU {$line->sku} is out of stock."
);
}
return $next($data);
}
}
Dynamic Stage Selection
Stages don't have to be a static array. Build them at runtime based on context:
$stages = collect([
NormaliseInput::class,
ValidateBusinessRules::class,
])
->when($order->requiresExportCompliance(), fn ($c) =>
$c->push(ExportComplianceCheck::class)
)
->push(PersistOrder::class)
->all();
app(Pipeline::class)->send($order)->through($stages)->thenReturn();
A Typed Pipeline Wrapper
For domain code, a thin wrapper improves discoverability and enforces the payload type:
final class OrderProcessingPipeline
{
public function __construct(private readonly Pipeline $pipeline) {}
/** @param class-string[] $extraStages */
public function process(OrderData $data, array $extraStages = []): OrderData
{
return $this->pipeline
->send($data)
->through([
NormaliseInput::class,
ValidateBusinessRules::class,
...$extraStages,
PersistOrder::class,
])
->thenReturn();
}
}
Bind it as a singleton and inject it wherever needed. Callers never touch the raw Pipeline class.
Testing Stages in Isolation
Because each stage is a plain class, unit testing is trivial:
it('normalises the email to lowercase', function () {
$stage = new NormaliseInput();
$data = OrderData::fake(['email' => ' USER@Example.COM ']);
$result = $stage->handle($data, fn ($d) => $d);
expect($result->email)->toBe('user@example.com');
});
No HTTP context, no database, no framework bootstrap required.
When Not to Use a Pipeline
Pipelines shine when stages are interchangeable and the payload flows linearly. Avoid them when:
- Stages need to communicate laterally (use a saga or process manager instead)
- The flow is highly conditional with many branches (a state machine is clearer)
- You only have one or two steps (a simple service method is less indirection)
Takeaways
Illuminate\Pipeline\Pipelineis a general-purpose tool, not just for HTTP middleware.- Stages are container-resolved classes — constructor injection works out of the box.
- Wrap the raw pipeline in a typed domain class to enforce payload contracts.
- Each stage is independently unit-testable with a simple closure as the
$nextstub. - Build stage lists dynamically with
collect()->when()for context-sensitive flows.