0

How does Laravel's pipeline work internally?

Advanced5 min read·eng-10-006
interview

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:

text
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:

php
app(Pipeline::class)
    ->send($payload)
    ->through([StageOne::class, StageTwo::class, StageThree::class])
    ->thenReturn();         // or ->then(fn($payload) => response($payload))

Each stage:

php
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
<?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