DEV Community

Cover image for Laravel's Pipeline: The Class Behind Middleware (and Your Own Pipes)
Gabriel Anhaia
Gabriel Anhaia

Posted on

Laravel's Pipeline: The Class Behind Middleware (and Your Own Pipes)


Every HTTP request your Laravel app serves runs through a class most people never open. Illuminate\Pipeline\Pipeline is what pushes a request through auth, throttle, CSRF, and the rest of your middleware stack, one layer at a time, then hands the response back out the same way. It's the engine behind $middleware. It's also public API, and you can point it at anything.

That last part is where it earns its keep. The place it pays off is the use case that started as three lines and grew into a handle() method with nine if blocks nobody wants to touch.

The if-chain that always grows

Order checkout is the classic example. You start with tax. Then someone adds a coupon. Then fraud screening. Then loyalty points. Six months later the method reads like this:

public function checkout(Order $order): Order
{
    $order = $this->applyTax($order);

    if ($order->couponCode) {
        $order = $this->applyCoupon($order);
    }

    if ($this->fraud->isSuspicious($order)) {
        $order->flagForReview();
    }

    if ($order->customer->isLoyaltyMember()) {
        $order = $this->applyPoints($order);
    }

    // ...four more of these
    return $order;
}
Enter fullscreen mode Exit fullscreen mode

Each step reads $order, changes it, passes it on. The if gates decide whether a step runs. Adding a step means editing this method, re-reading the whole thing, and hoping you got the order right. The method knows about tax, coupons, fraud, and points all at once. That's four reasons for it to change.

The shape underneath is a sequence of transformations over one value. That's exactly what a pipeline is.

Pipeline in 60 seconds

Pipeline takes a payload, sends it through a list of stages, and returns the result. Each stage gets the payload and a $next closure. It does its work, then calls $next($payload) to hand control to the following stage.

use Illuminate\Pipeline\Pipeline;

$result = app(Pipeline::class)
    ->send($order)
    ->through([
        ApplyTax::class,
        ApplyCoupon::class,
        ScreenForFraud::class,
        ApplyLoyaltyPoints::class,
    ])
    ->then(fn (Order $order) => $order);
Enter fullscreen mode Exit fullscreen mode

send() sets the payload. through() lists the stages in run order. then() receives whatever comes out the far end and returns the final value. There's also thenReturn(), which is ->then(fn ($x) => $x) when you just want the payload back.

Each stage is a small class with a handle method:

namespace App\Checkout\Pipes;

use App\Models\Order;
use Closure;

class ApplyCoupon
{
    public function handle(Order $order, Closure $next): Order
    {
        if ($order->couponCode !== null) {
            $order->applyDiscount(
                $this->discounts->resolve($order->couponCode)
            );
        }

        return $next($order);
    }

    public function __construct(
        private DiscountResolver $discounts,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Two things fall out of this. The if that used to live in the parent method now lives inside the pipe it belongs to, next to the code it guards. And because pipes are resolved through the container, ApplyCoupon gets its DiscountResolver injected. No service locator, no passing dependencies down through the checkout method.

Why $next instead of a plain loop

You could write a foreach that calls each stage and reassigns $order. So why the closure?

Because $next runs whatever is after the current pipe, which means a pipe can do work on the way in and on the way out. Wrap the call:

class TimeCheckout
{
    public function handle(Order $order, Closure $next): Order
    {
        $start = hrtime(true);

        $order = $next($order);   // run the rest of the pipeline

        $ms = (hrtime(true) - $start) / 1e6;
        Log::info('checkout pipeline', ['ms' => $ms]);

        return $order;
    }
}
Enter fullscreen mode Exit fullscreen mode

Put TimeCheckout first in the list and it times every stage after it. That's the same onion model Laravel middleware uses: outer layers see the request going in and the response coming out. A flat loop can't do that. A pipe can short-circuit too. Return early without calling $next and the rest of the pipeline never runs:

public function handle(Order $order, Closure $next): Order
{
    if ($this->fraud->isSuspicious($order)) {
        $order->flagForReview();
        return $order;   // stop here, skip remaining pipes
    }

    return $next($order);
}
Enter fullscreen mode Exit fullscreen mode

That's the fraud gate, expressed as a stop instead of an if wrapped around everything below it.

Pipes with arguments

Stages sometimes need config. Pipeline reuses the middleware string syntax: Class:arg1,arg2. The extra arguments arrive after $next.

->through([
    'App\Checkout\Pipes\ApplyTax:US,CA',
    ApplyCoupon::class,
])
Enter fullscreen mode Exit fullscreen mode
public function handle(
    Order $order,
    Closure $next,
    string ...$regions,
): Order {
    if (in_array($order->region, $regions, true)) {
        $order->addTax($this->rates->for($order->region));
    }

    return $next($order);
}
Enter fullscreen mode Exit fullscreen mode

If you dislike the string parsing, pass an already-built object into through() instead of a class name. Pipeline accepts instances, so you can construct a pipe with constructor args and hand it over directly. Instances skip container resolution, which is handy when a pipe needs runtime values the container can't know about.

Where it beats the if-chain, and where it doesn't

Reach for a pipeline when the steps share one payload, run in sequence, and the list keeps growing. Each pipe becomes independently testable: instantiate it, pass a fake $next that returns its argument, assert on what came out.

public function test_coupon_pipe_applies_discount(): void
{
    $order = Order::factory()->make(['couponCode' => 'SAVE10']);

    $result = (new ApplyCoupon($this->resolver))
        ->handle($order, fn (Order $o) => $o);

    $this->assertSame(10.0, $result->discount);
}
Enter fullscreen mode Exit fullscreen mode

No pipeline, no container, no HTTP. Just the one stage.

It's the wrong tool when the steps don't share a payload, or when the control flow branches instead of running straight through. A pipeline is a line, not a tree. If step three needs to pick between two different step-fours based on a result, an if (or a small state machine) is clearer than bending Pipeline around a branch. Don't turn a two-line method into four pipe classes either. The pattern pays for itself once the list of steps is long enough that people are afraid to add to it.

Keeping the framework class out of your domain

There's a catch worth naming. Illuminate\Pipeline\Pipeline is a Laravel class. Wire it directly into your checkout use case and your domain now imports the framework. For a lot of apps that's fine. If you're keeping a hard line between domain and framework, the fix is a one-method interface your domain owns:

namespace App\Checkout;

interface StageRunner
{
    /** @param array<class-string> $stages */
    public function run(object $payload, array $stages): object;
}
Enter fullscreen mode Exit fullscreen mode

The adapter lives in the infrastructure layer and delegates to Laravel:

namespace App\Infrastructure;

use App\Checkout\StageRunner;
use Illuminate\Pipeline\Pipeline;

class LaravelStageRunner implements StageRunner
{
    public function __construct(private Pipeline $pipeline) {}

    public function run(object $payload, array $stages): object
    {
        return $this->pipeline
            ->send($payload)
            ->through($stages)
            ->thenReturn();
    }
}
Enter fullscreen mode Exit fullscreen mode

Your use case depends on StageRunner, not on Pipeline. The day you move that logic off Laravel, you write one new adapter and the pipes stay exactly as they are. The pipes themselves are plain classes with a handle method. Nothing in them imports the framework.

That's the whole point of the pattern: the composition is generic, the steps are yours, and the framework is a detail you can swap.

Takeaways

  • Pipeline is the same engine Laravel runs your middleware through, and it's public API you can point at any sequential transform.
  • send()->through()->then() replaces a growing chain of if blocks when every step shares one payload and runs in order.
  • $next gives you the middleware onion: work on the way in, work on the way out, and early return to short-circuit.
  • Each pipe is a container-resolved class you can unit test in isolation with a fake $next.
  • Hide the Laravel class behind a small interface if your domain shouldn't know the framework exists.

A checkout that ran through nine if blocks becomes a named list of steps, each in its own file, each testable alone. Adding fraud screening stops being surgery on a 60-line method and becomes writing one class and adding one line to a list.

Pushing the composition engine to the edge and keeping the steps as plain domain classes is the exact move Decoupled PHP keeps coming back to: the framework runs the plumbing, your use cases own the logic, and the seam between them is a one-method interface. Get that seam right and the pattern outlives whichever queue, router, or pipeline class you started with.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)