Laravel’s Pipeline Class Is the Feature You Keep Reinventing

Written by

in

If you’ve ever written a service method that looks like a staircase of if statements, each one nudging the same array along before handing it to the next, you’ve written a pipeline. You just did it by hand.

Laravel ships with a proper one. It’s been in the framework for years, it’s what the middleware stack is built on, and hardly anyone reaches for it directly. Which is a shame, because it solves a very specific, very common problem: you have a thing, you want to run it through a series of steps, and each step should be its own small, testable class.

The problem it solves

Say you’re building a product search. Users can filter by category, price range, in-stock only, and sort order. The first version usually looks something like this:

public function index(Request $request)
{
    $query = Product::query();

    if ($request->filled('category')) {
        $query->where('category_id', $request->category);
    }

    if ($request->filled('min_price')) {
        $query->where('price', '>=', $request->min_price);
    }

    if ($request->filled('max_price')) {
        $query->where('price', '<=', $request->max_price);
    }

    if ($request->boolean('in_stock')) {
        $query->where('stock', '>', 0);
    }

    if ($request->filled('sort')) {
        $query->orderBy($request->sort, $request->get('direction', 'asc'));
    }

    return $query->paginate();
}

It works. Then marketing asks for a “new arrivals” filter. Then someone wants to reuse the price logic on the admin side. Then you need to test the sort behaviour in isolation and realise you can’t without spinning up a full request. The method grows, and every change risks breaking a filter that has nothing to do with the one you’re touching.

Enter the pipeline

Here’s the same thing rewritten with Illuminate\Pipeline\Pipeline:

use Illuminate\Support\Facades\Pipeline;

public function index()
{
    $products = Pipeline::send(Product::query())
        ->through([
            Filters\Category::class,
            Filters\PriceRange::class,
            Filters\InStock::class,
            Filters\Sort::class,
        ])
        ->thenReturn()
        ->paginate();

    return $products;
}

Each filter is a small class with a single handle method. It receives the query builder and a $next closure, does its work (or doesn’t), and passes the builder along:

namespace App\Filters;

use Closure;
use Illuminate\Database\Eloquent\Builder;

class PriceRange
{
    public function handle(Builder $query, Closure $next)
    {
        $min = request('min_price');
        $max = request('max_price');

        if ($min !== null) {
            $query->where('price', '>=', $min);
        }

        if ($max !== null) {
            $query->where('price', '<=', $max);
        }

        return $next($query);
    }
}

That should look familiar. It’s exactly the shape of a middleware class, and that’s not a coincidence: Pipeline is the engine underneath Laravel’s HTTP kernel. When your request passes through auth, throttle and friends, it’s going through this same mechanism.

Why this is better than the if-chain

A few things fall out of this structure that you don’t get for free with the original version.

Each step is testable on its own. You can instantiate PriceRange, hand it a query builder and a closure, and assert on the resulting SQL. No HTTP request, no controller, no database round trip if you don’t want one.

Steps are reusable. The admin product list can use the same PriceRange filter. So can an API endpoint. So can an artisan command that exports products.

Adding a step is one line. New arrivals? Write Filters\NewArrivals, add it to the array. You never open the controller method that contains the other filters.

Order is explicit. The array is the execution order. Anyone reading the controller can see what happens and in what sequence without scrolling through a wall of conditionals.

Passing extra data into the pipes

One thing that trips people up: the filters above reach for request() directly, which is fine in a controller but makes them harder to reuse elsewhere. A cleaner approach is to send a small value object through the pipeline instead of the bare query builder.

class SearchContext
{
    public function __construct(
        public Builder $query,
        public array $filters,
    ) {}
}
$context = Pipeline::send(new SearchContext(Product::query(), $request->all()))
    ->through($this->filters)
    ->thenReturn();

return $context->query->paginate();

Now each filter reads from $context->filters rather than the global request, and the whole pipeline can run anywhere, including inside a queued job that has no request at all.

Using closures instead of classes

You don’t have to write a class for every step. For quick, one-off transformations, closures work just as well:

$slug = Pipeline::send($title)
    ->through([
        fn ($s, $next) => $next(trim($s)),
        fn ($s, $next) => $next(strtolower($s)),
        fn ($s, $next) => $next(preg_replace('/[^a-z0-9]+/', '-', $s)),
        fn ($s, $next) => $next(trim($s, '-')),
    ])
    ->thenReturn();

Mixing the two is fine too. I tend to start with closures while I’m figuring out the shape of the thing, then promote steps to classes once they grow or need tests.

Running code after the pipeline

thenReturn() just hands back whatever came out the end. If you want to do something with the result inside the chain, then() takes a closure:

Pipeline::send($order)
    ->through([
        ValidateStock::class,
        ApplyDiscounts::class,
        CalculateTax::class,
        ReserveInventory::class,
    ])
    ->then(function (Order $order) {
        $order->save();
        OrderPlaced::dispatch($order);
        return $order;
    });

This reads almost like a spec of what placing an order means. Which, honestly, is the whole point.

Where I’d actually use this

Pipelines shine when you have a sequence of steps that are conceptually independent but need to run in order. Query filtering is the classic example. Others I’ve used it for:

  • Import processing, where each row goes through parse → validate → normalise → persist
  • Checkout flows with multiple pricing rules that stack
  • Content rendering, running markdown through a chain of transformers
  • Onboarding steps that can be reordered or toggled per tenant

Where it’s overkill: two steps that will never change, or logic that’s genuinely tangled and can’t be split cleanly. Don’t force it.

Wrapping up

The Pipeline class isn’t flashy. There’s no artisan command, no config file, no package to install. It’s just a small piece of the framework that formalises a pattern you’re probably already writing by hand, and in doing so makes your code easier to test, easier to extend, and a lot easier to read six months from now.

Next time you catch yourself writing a fourth if in a row that all poke at the same variable, give it a try.