Every request to your Laravel app travels through a stack of middleware: each one does some work, then hands the request to the next. The class that makes this happen is not private to the framework. It is Illuminate\Pipeline\Pipeline, and you can send anything through it: an order during checkout, a row from a CSV import, a text that needs cleaning. This article explains how it works in plain terms, with two examples I ran against Laravel 13.35.0 and a real database, including one where it saves you from a half-finished order.
The idea in one minute
Think of an assembly line. You put an object on the belt, and a list of stations each do one job on it. A station is a class with a handle() method. It receives the object and a $next closure. When it finishes its job, it passes the object on by calling $next:
use Closure;
class TrimStrings
{
public function handle(array $row, Closure $next)
{
return $next(array_map(
fn ($value) => is_string($value) ? trim($value) : $value,
$row
));
}
}
The framework backs up the claim in the tip: the HTTP kernel and the router both build their middleware stack from a Pipeline class (Illuminate\Routing\Pipeline, which extends the base one). Your own pipelines use the same machinery.
Example 1: clean imported data
Three small steps, each doing one thing, plus a fourth that takes a parameter:
use Illuminate\Support\Facades\Pipeline;
$clean = Pipeline::send($row)
->through([
TrimStrings::class,
LowercaseEmail::class,
NormalizePhone::class,
DefaultCountry::class . ':GB',
])
->thenReturn();
With the input ['name' => ' Ada Lovelace ', 'email' => ' [email protected] ', 'phone' => '+94 (77) 123-4567', 'country' => null] the result was:
name: "Ada Lovelace"
email: "[email protected]"
phone: "94771234567"
country: "GB"
Notes on what I verified:
- Pipes written as class names are created by the service container, so their constructors can ask for services.
- The
:GBsuffix passes extra arguments tohandle(), the same trick middleware parameters use. Several values are separated by commas. - Without that suffix, the step used its own default (
LKin my test class). - In my test I used
new Pipeline($container)directly because I was not running a full app. In Laravel, thePipelinefacade does the same thing and creates a fresh instance on each call (the facade is marked as not cached in the source), so one pipeline's steps never leak into the next.
Example 2: a checkout flow that rolls back
This is where it gets useful. A checkout has steps that must succeed together: calculate the total, reserve stock, create the order, charge the card. I wrote each as a step, and made the last one throw to simulate a declined card.
$steps = [
CalculateTotal::class,
ReserveStock::class,
CreateOrder::class,
ChargeCard::class,
];
Pipeline::send(new Checkout('BOOK', 2, 1500))
->through($steps)
->withinTransaction()
->thenReturn();
Results with 3 books in stock:
| Run | Stock afterwards | Orders afterwards |
|---|---|---|
| Card declined, no transaction | 1 | 1 (an order for a failed payment) |
Card declined, with withinTransaction()
|
3 | 0 |
Card approved, with withinTransaction()
|
1 | 1 (order total 3000 cents) |
Without the transaction, the failed payment left stock reduced and an order in the table. With withinTransaction(), the database work was undone. That method is in the 13.35.0 source: it wraps the whole run in a database transaction, and it can take a connection name. A caution that I did not test but follows from how transactions work: a transaction only rolls back your database. If a step calls an outside service (a payment provider, an email), that call cannot be undone. Put those steps last, and keep them out of the transaction when you can.
Rules that keep a pipeline working
Always return $next(...)
A step that calls $next($x) without return breaks the chain's result. I tried it, and the pipeline returned NULL. No error, just an empty result, which is a very annoying bug to find.
A step can stop the line
If a step returns without calling $next, later steps never run. In my test the second step did not execute, and the pipeline returned the first step's value. This is how a middleware rejects a request, and it works for validation-style steps too.
Code after $next runs in reverse
Each step can do work before and after calling $next. With steps A and B, the order I recorded was: before A, before B, destination, after B, after A. Use the "after" half for things like timing or cleanup.
Other ways to build a pipeline
-
Closures work as steps:
fn ($n, $next) => $next($n + 1). Two closures turned 5 into 60 in my test. -
via('process')calls a different method name thanhandle. -
pipe()adds steps to an existing list, andwhen($condition, fn ($p) => $p->pipe(...))adds one only if a condition is true. -
then(fn ($x) => ...)runs a final callback instead of just returning the result, andfinally()runs code afterwards even when a step throws. My test logged "finally ran" followed by the exception being caught.
When not to use it
A pipeline is a good fit when you have several independent steps in a fixed order, steps you want to reuse or test separately, or flows where you add and remove steps over time. If you have two steps that will never change, a plain method with two calls is clearer. Passing an object through steps also means the steps can change it: they should all agree on who modifies what. For arrays and strings, return the changed copy, as in the examples.
References
Originally published on DEV Talk.
Top comments (1)
The fact that it can return
NULLwithout throwing an error makes this an easy bug to overlook. A tiny missingreturncan break the whole pipeline.