DEV Community

Cover image for Laravel Pipeline Class: Multi-Step Flows Explained
devTalk
devTalk

Posted on Originally published at dev-talk.com

Laravel Pipeline Class: Multi-Step Flows Explained

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
        ));
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

With the input ['name' => ' Ada Lovelace ', 'email' => ' ADA@Example.COM ', 'phone' => '+94 (77) 123-4567', 'country' => null] the result was:

name: "Ada Lovelace"
email: "ada@example.com"
phone: "94771234567"
country: "GB"
Enter fullscreen mode Exit fullscreen mode

Notes on what I verified:

  • Pipes written as class names are created by the service container, so their constructors can ask for services.
  • The :GB suffix passes extra arguments to handle(), the same trick middleware parameters use. Several values are separated by commas.
  • Without that suffix, the step used its own default (LK in my test class).
  • In my test I used new Pipeline($container) directly because I was not running a full app. In Laravel, the Pipeline facade 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();
Enter fullscreen mode Exit fullscreen mode

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 than handle.
  • pipe() adds steps to an existing list, and when($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, and finally() 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 (0)