The Pipeline Pattern in Laravel: Beyond Middleware
Most Laravel developers know Pipeline as the invisible machinery that runs HTTP middleware. Few reach for it when solving domain problems — and that's a missed opportunity. The Illuminate\Pipeline\Pipeline class is a first-class, general-purpose tool for building ordered, composable processing chains over any payload.
What the Pipeline Class Actually Does
At its core, Pipeline wraps a passable value through an ordered list of stages (pipes), each of which can transform the value or short-circuit the chain. The contract is simple:
use Illuminate\Pipeline\Pipeline;
$result = app(Pipeline::class)
->send($order)
->through([
ValidateOrderInventory::class,
ApplyLoyaltyDiscount::class,
CalculateTax::class,
ReserveStock::class,
])
->thenReturn();
Each pipe receives ($passable, $next) — identical to middleware — but the passable is your domain object, not an HTTP request.
Defining Pipe Classes
Implement the implicit pipe contract with a handle method:
final class ApplyLoyaltyDiscount
{
public function handle(Order $order, Closure $next): Order
{
if ($order->customer->hasLoyaltyTier()) {
$order->applyDiscount(
$order->customer->loyaltyDiscountRate()
);
}
return $next($order);
}
}
The pipe returns $next($order) to continue or returns early to short-circuit. No base class required — just a callable shape.
Using a Custom Method Name
If you want a more expressive interface, define your own method and tell the pipeline:
interface OrderPipe
{
public function process(Order $order, Closure $next): Order;
}
$result = app(Pipeline::class)
->send($order)
->through($pipes)
->via('process')
->thenReturn();
This lets you enforce the interface in your pipe classes and get IDE autocompletion on the passable type.
Short-Circuiting with Exceptions vs. Early Returns
Two clean patterns exist for halting a pipeline:
Exception-based — throw a domain exception inside a pipe; catch it at the call site:
public function handle(Order $order, Closure $next): Order
{
if ($order->total()->isZero()) {
throw new InvalidOrderException('Order total cannot be zero.');
}
return $next($order);
}
Result-object-based — wrap the passable in a result DTO so pipes can inspect and propagate failure without exceptions:
public function handle(OrderResult $result, Closure $next): OrderResult
{
if ($result->failed()) {
return $result; // skip remaining pipes
}
// ... mutate and continue
return $next($result);
}
The result-object approach is friendlier to testing and avoids exception-as-control-flow.
Resolving Pipes from the Container
String class names are resolved via the service container, so constructor injection works automatically:
final class CalculateTax
{
public function __construct(
private readonly TaxRateRepository $rates
) {}
public function handle(Order $order, Closure $next): Order
{
$order->setTax($this->rates->rateFor($order->shippingRegion()));
return $next($order);
}
}
No service locator boilerplate — the container handles it.
Testing Individual Pipes in Isolation
Because each pipe is a plain class, unit testing is trivial:
it('applies loyalty discount when customer has a tier', function () {
$customer = Customer::factory()->withLoyaltyTier('gold')->make();
$order = Order::factory()->for($customer)->make(['subtotal' => 100_00]);
$pipe = new ApplyLoyaltyDiscount();
$result = $pipe->handle($order, fn ($o) => $o);
expect($result->discount())->toBe(10_00);
});
The $next closure is just an identity function in tests. No HTTP context, no mocking the pipeline itself.
When to Reach for a Pipeline
- Multi-step import/export processing (CSV → validate → transform → persist)
- Order fulfilment workflows with optional stages
- Notification enrichment chains (resolve recipient → render template → choose channel)
- API response decoration across multiple transformers
Takeaways
Pipelineis a general-purpose tool — not HTTP-only.- Pipes are plain classes resolved by the container; constructor injection is free.
- Use
->via('method')to enforce a typed interface across all pipes. - Result objects are cleaner than exceptions for expected failure paths.
- Each pipe is independently unit-testable with a simple identity
$next. - Pipelines make multi-step domain logic explicit, ordered, and easy to extend.