How does Laravel's pipeline work internally?
Concept
Laravel's Pipeline is a class that passes a payload through a series of stages, where each stage can modify the payload or short-circuit the chain.
Internally, Illuminate\Pipeline\Pipeline uses array_reduce() over the stages array to build a nested closure chain. The payload travels through each stage calling $next($request) to pass to the next stage.
How it builds the chain:
array_reduce(
array_reverse($pipes),
fn($carry, $pipe) => fn($passable) => $pipe->handle($passable, $carry),
fn($passable) => $destination($passable) // the final destination
)This creates a closure that, when called, calls the LAST pipe's handle() with $carry pointing to the next closure. Working inward from the outside.
Usage pattern:
app(Pipeline::class)
->send($payload)
->through([StageOne::class, StageTwo::class, StageThree::class])
->thenReturn(); // or ->then(fn($payload) => response($payload))Each stage:
class StageOne
{
public function handle($payload, \Closure $next): mixed
{
// Before — runs first
$payload = transform($payload);
$result = $next($payload); // pass to next stage
// After — runs on the way back
return $result;
}
}Short-circuiting: A stage can return WITHOUT calling $next(). This stops the pipeline — no subsequent stages run.
Laravel middleware IS a pipeline: The Illuminate\Routing\Router passes the request through the middleware stack using a Pipeline. Your middleware's handle($request, $next) method is the stage interface.
Direct use: The Pipeline class is public API — use it for your own multi-step data transformations.
Code Example
<?php
// Stage classes for user registration pipeline
class ValidateUserData
{
public function handle(array $data, \Closure $next): array
{
if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Valid email required');
}
return $next($data);
}
}
class NormalizeUserData
{
public function handle(array $data, \Closure $next): array
{
$data['email'] = strtolower(trim($data['email']));
$data['name'] = trim($data['name']);
return $next($data);
}
}
class HashUserPassword
{
public function handle(array $data, \Closure $next): array
{
if (isset($data['password'])) {
$data['password'] = password_hash($data['password'], PASSWORD_ARGON2ID);
}
return $next($data);
}
}
class EnrichWithDefaults
{
public function handle(array $data, \Closure $next): array
{
$data['role'] ??= 'user';
$data['created_at'] ??= now()->toDateTimeString();
return $next($data);
}
}
// Run the pipeline
$processedData = app(\Illuminate\Pipeline\Pipeline::class)
->send($request->all())
->through([
ValidateUserData::class,
NormalizeUserData::class,
HashUserPassword::class,
EnrichWithDefaults::class,
])
->thenReturn(); // returns the final $data after all stages
$user = User::create($processedData);
// Closure stages — no class needed for simple transformations
$result = app(\Illuminate\Pipeline\Pipeline::class)
->send($rawInput)
->through([
fn($data, $next) => $next(array_map('trim', $data)),
fn($data, $next) => $next(array_filter($data)),
])
->thenReturn();
// Short-circuit example — early return, skip remaining stages
class CheckForBannedEmail
{
public function handle(array $data, \Closure $next): mixed
{
if (in_array($data['email'], config('auth.banned_emails'))) {
// Don't call $next() — pipeline stops here
throw new \DomainException('Email address is banned');
}
return $next($data);
}
}
// How middleware uses the same pattern (simplified):
// $pipeline = new Pipeline($this->container);
// return $pipeline
// ->send($request)
// ->through($middleware) // ['auth', 'throttle', ...] — resolved from aliases
// ->then(fn($req) => $router->dispatch($req)); // final destination = controller