The Pipeline Pattern in Laravel: Building Custom Pipelines Beyond Middleware
#laravel #design-patterns #pipeline #clean-architecture

The Pipeline Pattern in Laravel: Building Custom Pipelines Beyond Middleware

3 min read Mohamed Said Mohamed Said

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

  • Pipeline is 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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I mix closure pipes and class-based pipes in the same pipeline?
Yes. Laravel's Pipeline accepts any combination of class name strings, object instances, and closures in the `through()` array. Closures receive the same `($passable, $next)` signature as class-based pipes.
Q02 How do I conditionally add a pipe based on runtime state?
Build the pipes array before passing it to `through()`. Use a standard `if` statement or `array_filter` to include or exclude specific pipe classes based on your domain conditions, then pass the final array to the pipeline.
Q03 Is there a performance cost to using Pipeline for domain logic instead of plain method calls?
The overhead is negligible for typical domain workflows — it amounts to a few closure wraps and container resolutions. Only in tight loops processing thousands of items per request would you consider inlining the logic, and even then profiling should guide that decision.

Continue reading

More Articles

View all