illuminate/pipeline
Illuminate Pipeline, part of the Laravel framework, sends a value through a sequence of customizable stages, each receiving and modifying the result of the previous one. Ideal for building middleware-style processing chains, filters, and validation workflows in PHP applications.
In a Laravel app, you already have it. illuminate/pipeline ships with the framework, is aliased as pipeline in the container (Illuminate\Pipeline\Pipeline::class / Illuminate\Contracts\Pipeline\Pipeline::class), and needs zero configuration. In a standalone project, composer require illuminate/pipeline plus illuminate/container (needed to resolve string/class pipes) is the practical minimum.
Where to look first: the source is ~200 lines across two files — Illuminate\Pipeline\Pipeline and Illuminate\Contracts\Pipeline\Pipeline. Read through(), via(), then() and carry() once; almost every behaviour you'll ever debug lives in carry(). Then look at Illuminate\Routing\Pipeline, which is the framework's own subclass and shows the intended extension points.
Smallest useful chain:
use Illuminate\Pipeline\Pipeline;
$payload = app(Pipeline::class)
->send($request->all())
->through([
App\Pipes\TrimStrings::class,
App\Pipes\ValidateRequiredFields::class,
App\Pipes\CastTypes::class,
])
->thenReturn();
A pipe is any class with a handle($passable, Closure $next) method:
namespace App\Pipes;
use Closure;
class CastTypes
{
public function handle(array $payload, Closure $next): array
{
$payload['age'] = (int) ($payload['age'] ?? 0);
return $next($payload); // never omit this
}
}
First use case: an API request payload normaliser — trim → validate → cast → persist — so the controller stays a two-liner and each step is independently unit-testable. Second use case: an order/onboarding workflow where the steps are business concepts rather than HTTP concerns.
One pipe per class, one responsibility. Give each pipe a typed handle() with Closure $next and a return type. Keep them stateless; anything they need (repositories, mailers, config) comes through the constructor via the container, which resolves class-string pipes for you.
Wrap each workflow in a named pipeline service. Don't scatter send()->through()->then() across controllers. Build a small class:
class FulfilOrder
{
public function __construct(private Pipeline $pipeline) {}
public function run(Order $order): Order
{
return $this->pipeline
->send($order)
->through(config('pipelines.fulfilment'))
->thenReturn();
}
}
Now the pipe list is config-driven — which is how you get feature flagging and environment-specific behaviour for free (filter the array before through()).
Short-circuiting is a first-class pattern. A pipe that returns without calling $next ends the chain and its return value becomes the pipeline result. Use it for guards and idempotency checks; throw ValidationException::withMessages() if you want a 422 rather than a silent null.
then() for a final aggregation/transform, thenReturn() when the payload is already the answer. The destination receives only the payload (no $next), and it must be a Closure — ->then(CreateOrder::class) is a TypeError.
->then(fn (array $data) => Order::create($data))
via('process') when you want a different method name. It applies to the entire chain, not per pipe, so a pipeline mixing handle and process isn't possible — wrap the odd one out.
Parameterised string pipes for configurable steps:
->through(['App\Pipes\Throttle:10,1', ...$extraPipes])
public function handle($passable, Closure $next, $max = 10, $decay = 1) { ... }
This is the cheapest way to make a generic pipe (paginate, retry, throttle) reusable without subclassing. Note explode(',') — no commas inside a parameter.
Reuse pipes as route middleware. A tiny middleware that runs the same pipe list over the Request keeps HTTP concerns and domain concerns sharing one implementation. Use Illuminate\Routing\Pipeline if you want exceptions rendered into responses automatically; the base Illuminate\Pipeline\Pipeline just rethrows.
Reach for Bus::chain() or job middleware for async work. The pipeline is synchronous and not resumable; if step 3 fails, steps 1–2 already happened. Keep pipelines for in-request orchestration and wrap the call site in DB::transaction() when the steps have side effects.
Test at two levels. Unit-test each pipe with a spy $next (fn ($p) => $p) and assert both the transformed payload and whether $next was called (that's how you prove a short-circuit). Then one feature test per named pipeline class with fake collaborators, not per pipe permutation.
return $next($passable) yields null downstream. This is the number one bug. Some pipes then "pass", validation silently skips, and you get a mystery 500 three steps later. Return type declarations (: array) catch it at the pipe boundary.through() replaces the pipe array, it doesn't append. Calling it twice while building a chain dynamically silently drops the first list. Collect into one array, then call once.via() is chain-wide, and if the pipe class lacks that method, carry() falls back to invoking the object. So a class with neither handle nor __invoke fails with "not callable" — a confusing error that has nothing to do with the actual mistake.then() accepts Closure only. Conversely, a closure pipe is fine in through() but cannot be serialised — never put closures in a pipeline that gets queued or cached.handleException() rethrows (base class). Wrap the whole then()/thenReturn() call, add an error-boundary pipe that logs and returns a fallback, or use Illuminate\Routing\Pipeline for HTTP. Also note exceptions lose step context: catch inside a try/catch in the pipe and rethrow with the payload snapshot attached.clone before retries/dry-runs — and remember clone on a model doesn't deep-copy loaded relations.Pipeline instance across executions. send() and through() mutate internal state and then() does not reset it. Always start a fresh chain (resolve Pipeline fresh from the container) rather than stashing one in a singleton property.pipeline container alias to a decorated subclass; inject your own subclass type-hint instead. The framework constructs its own instances for routing, and overriding the alias produces surprising, hard-to-trace differences between route middleware and your code.isAuthenticated, null checks, feature flags) first and DB/external calls last; you want the chain to end before the expensive part, not after it.$next can be called more than once. Inside a pipe you can loop, retry, or fan out by invoking $next per item — powerful for batch pipes (foreach ($items as $item) { $results[] = $next($item); }), but it means downstream pipes must be safe to re-run.Log::withContext pipe that logs the payload class/shape at each index, or a single DebugPayload pipe inserted conditionally via app()->isLocal(). dump() in a closure pipe is fine but it's easy to leave in and it's invisible in code review. For timing, put hrtime() marks in a Telemetry pipe pair (start/end) and log the delta in the destination closure.@template/PHPDoc on the pipeline wrapper class and keep payloads as arrays or dedicated DTOs rather than mixed; it catches the missing-return class of bug at analysis time instead of in production.How can I help you explore Laravel packages today?