Understanding Laravel's Pipeline Pattern
If you've ever wondered how Laravel's HTTP middleware system works under the hood, the answer is the Pipeline class. It's one of the more elegant pieces of the framework, and it's not just for HTTP — you can use it anywhere you need to pass a value through a series of transformations or checks.
What a Pipeline Does
A pipeline takes a passable (some value), runs it through a series of pipes (handlers), and returns the result. Each pipe can transform the value, short-circuit the chain, or pass it along unchanged. Conceptually:
$passable → pipe1 → pipe2 → pipe3 → $result
That's exactly how middleware works: the request passes through each middleware layer before reaching the controller, then the response passes back through them in reverse.
The Basic API
Laravel exposes Illuminate\Pipeline\Pipeline as a standalone class you can use anywhere:
use Illuminate\Pipeline\Pipeline;
$result = app(Pipeline::class)
->send($passable)
->through($pipes)
->thenReturn();
Or with a closure at the end instead of thenReturn():
$result = app(Pipeline::class)
->send($passable)
->through($pipes)
->then(fn ($value) => "Final: {$value}");
Building a Real Example: Order Processing
Imagine an order placement flow with several validation and enrichment steps. Without a pipeline, you end up with a method that calls five other methods in sequence and passes a growing $order object around. With a pipeline, each step becomes its own class with a single responsibility.
First, define the passable — in this case, an OrderData DTO:
class OrderData
{
public function __construct(
public array $items,
public User $user,
public ?float $discount = null,
public ?string $couponCode = null,
) {}
}
Then write each pipe as a class with a handle() method:
class ValidateInventory
{
public function handle(OrderData $order, Closure $next): OrderData
{
foreach ($order->items as $item) {
if ($item['quantity'] > $item['stock']) {
throw new InsufficientStockException($item['product_id']);
}
}
return $next($order);
}
}
class ApplyCoupon
{
public function handle(OrderData $order, Closure $next): OrderData
{
if ($order->couponCode) {
$coupon = Coupon::where('code', $order->couponCode)->firstOrFail();
$order->discount = $coupon->discount_percentage;
}
return $next($order);
}
}
class CalculateTotals
{
public function handle(OrderData $order, Closure $next): OrderData
{
$subtotal = collect($order->items)->sum(
fn ($item) => $item['price'] * $item['quantity']
);
$order->subtotal = $subtotal;
$order->total = $subtotal * (1 - ($order->discount ?? 0));
return $next($order);
}
}
Now run them through a pipeline:
class PlaceOrderAction
{
public function __construct(private Pipeline $pipeline) {}
public function execute(OrderData $data): Order
{
$processed = $this->pipeline
->send($data)
->through([
ValidateInventory::class,
ApplyCoupon::class,
CalculateTotals::class,
])
->thenReturn();
return Order::create([
'user_id' => $processed->user->id,
'subtotal' => $processed->subtotal,
'total' => $processed->total,
'items' => $processed->items,
]);
}
}
Each pipe is independently testable. The order of operations is explicit and readable. Adding a new step means adding a new class and inserting it into the array — no existing code changes.
Short-Circuiting the Pipeline
A pipe can stop execution early by not calling $next:
class CheckBlacklistedUser
{
public function handle(OrderData $order, Closure $next): mixed
{
if ($order->user->isBlacklisted()) {
throw new UserBlacklistedException();
}
// Call $next to continue
return $next($order);
}
}
Or it can return a value directly without calling $next, which ends the pipeline at that point. This is exactly how authentication middleware short-circuits with a redirect response.
Why Not Just Chain Method Calls?
You could achieve the same result with:
$data = (new ValidateInventory)->process($data);
$data = (new ApplyCoupon)->process($data);
$data = (new CalculateTotals)->process($data);
The difference is flexibility. A pipeline lets you:
- Swap, add, or remove stages dynamically at runtime
- Drive the pipe list from config or database
- Wrap all stages in a try/catch at the pipeline level
- Test each stage in isolation without knowing about the others
Where Else Laravel Uses Pipelines
Beyond HTTP middleware, Laravel uses the Pipeline class internally for:
- Route middleware — same mechanism as HTTP, just scoped to a route group
- Console middleware — wraps artisan commands
- Job middleware — introduced in Laravel 6, lets queue jobs have their own middleware
- Model observers — conceptually pipeline-like, though implemented differently
Once you recognize the pattern, you'll see places to apply it throughout your own applications: import pipelines, data transformation flows, multi-step form processing, API request normalisation. Anywhere you have a linear sequence of operations on a single object, a pipeline gives you a clean, extensible structure.