- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
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;
}
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);
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,
) {}
}
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;
}
}
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);
}
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,
])
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);
}
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);
}
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;
}
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();
}
}
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
-
Pipelineis 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 ofifblocks when every step shares one payload and runs in order. -
$nextgives 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.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)