Every reporting screen I inherit has the same function buried in a controller: a filter() method forty lines deep in nested if statements, each one bolting another where onto a query. It works until someone asks for a new filter, and then you're threading a change through a wall of conditionals nobody wants to touch. Laravel's Pipeline facade is the tool I reach for here — it passes one object through an ordered list of small, single-responsibility stages, so each filter becomes its own class you can read, reorder, and test on its own.
The nested-if problem#
Here's the shape of the code I mean. A product index that accepts a few optional filters from the request:
public function index(Request $request)
{
$query = Product::query();
if ($request->filled('status')) {
$query->where('status', $request->input('status'));
}
if ($request->filled('search')) {
$query->where('name', 'like', '%'.$request->input('search').'%');
}
if ($request->filled('min_price')) {
$query->where('price', '>=', $request->integer('min_price'));
}
// ...three more of these, and growing
return $query->paginate();
}
Nothing is wrong here. It's just that every new filter adds another branch to the same method, the controller keeps swelling, and you can't test the search rule without spinning up the whole request. This is exactly the situation the pipeline pattern is built for.
Laravel's Pipeline facade in one example#
The Pipeline facade pipes an input through a series of callables, giving each one a chance to inspect or change it before handing it to the next. The API is three chained calls — send(), through(), and thenReturn():
use Illuminate\Support\Facades\Pipeline;
$clean = Pipeline::send($rawComment)
->through([
NormaliseWhitespace::class,
StripTrackingParams::class,
])
->thenReturn();
Each stage is a small class with a handle() method that takes the passable plus a $next closure. Do your work, then hand the result to $next:
<?php
declare(strict_types=1);
namespace App\Pipelines\Comments;
use Closure;
final readonly class NormaliseWhitespace
{
public function handle(string $value, Closure $next): string
{
// Collapse runs of whitespace, then pass it down the line.
return $next(trim(preg_replace('/\s+/', ' ', $value)));
}
}
If that shape looks familiar, it should — it's the same signature Laravel middleware uses, because middleware is a pipeline internally.
Building a Laravel query-filter pipeline#
Now the useful part: replacing that nested-if controller with a query-filter pipeline. The passable is an Eloquent Builder, and each stage conditionally constrains it. Here's the filter-by-status stage:
<?php
declare(strict_types=1);
namespace App\Pipelines\Products;
use Closure;
use Illuminate\Database\Eloquent\Builder;
final readonly class FilterByStatus
{
public function handle(Builder $query, Closure $next): Builder
{
// Only touch the query when the request actually carries a status.
return $next(
$query->when(
request()->filled('status'),
fn (Builder $query) => $query->where('status', request('status')),
)
);
}
}
The search stage is its own class, and knows nothing about status:
<?php
declare(strict_types=1);
namespace App\Pipelines\Products;
use Closure;
use Illuminate\Database\Eloquent\Builder;
final readonly class SearchByName
{
public function handle(Builder $query, Closure $next): Builder
{
return $next(
$query->when(
request()->filled('search'),
fn (Builder $query) => $query->where('name', 'like', '%'.request('search').'%'),
)
);
}
}
The controller collapses to one readable pass. Send the query in, run it through the stages, get the built query back, and paginate it:
use App\Models\Product;
use App\Pipelines\Products\FilterByStatus;
use App\Pipelines\Products\SearchByName;
use Illuminate\Support\Facades\Pipeline;
$products = Pipeline::send(Product::query())
->through([
FilterByStatus::class,
SearchByName::class,
])
->thenReturn()
->paginate(20);
Adding a price filter is now a new class dropped into the through() array — no existing stage changes. Because each stage constrains the same builder, it pairs well with catching accidental N+1s early using Eloquent strict mode. And when LIKE '%term%' stops being good enough, that's the seam where you swap the search stage for typo-tolerant search with Laravel Scout and Typesense without rewriting the filter code around it.
Testing a pipe in isolation#
This is the payoff. To test FilterByStatus, you don't build the whole pipeline — you call the stage directly and pass an identity closure as $next so you can inspect what came out:
use App\Models\Product;
use App\Pipelines\Products\FilterByStatus;
use Illuminate\Database\Eloquent\Builder;
it('constrains the query when the request carries a status', function () {
request()->merge(['status' => 'archived']);
$query = (new FilterByStatus())->handle(
Product::query(),
fn (Builder $query) => $query, // stand-in for the next stage
);
expect($query->toSql())->toContain('status')
->and($query->getBindings())->toContain('archived');
});
it('leaves the query untouched when no status is present', function () {
$query = (new FilterByStatus())->handle(
Product::query(),
fn (Builder $query) => $query,
);
expect($query->toSql())->not->toContain('status');
});
Two focused tests, no HTTP round-trip, no database rows — toSql() just compiles the query against your test connection's grammar. If you want to lock the structure of the pipeline down too, Pest architecture tests are a good fit for asserting every class under App\Pipelines exposes a handle method.
The Pipeline facade vs middleware vs a service class#
These three overlap, so here's how I choose between them.
Middleware and the Pipeline facade are the same machine. Laravel builds the middleware stack by sending the Request through a pipeline internally — each middleware is a pipe with a handle($request, $next) signature. The same idea shows up for queued work when you start wrapping jobs in their own middleware for rate limiting and backoff. If you already understand middleware, you already understand pipelines.
A service class is the right call when you have one cohesive operation — "store this comment" — even if it does several things inside. Reach for a pipeline when you have an ordered list of independent transformations you want to add, remove, reorder, or test one at a time. The tell is a method that keeps growing if branches that don't depend on each other.
One thing a pipeline is not: parallel. Stages run strictly in sequence, each waiting for the last. If your stages are independent and slow — several outbound HTTP calls, say — a pipeline still runs them one after another, and you'd want to run independent work in parallel with the Concurrency facade instead.
Gotchas and edge cases#
A few things that bite people, all verified against Laravel 13:
The default method is handle. If your stage doesn't have one, the pipeline falls back to invoking the class (so an __invoke method works too). Want a different name like execute? Chain ->via('execute') before thenReturn().
Forgetting to return $next(...) silently breaks the chain. If a stage does its work but doesn't call $next, everything after it is skipped and you get back an unexpected value. Every stage must end by handing control on.
then() and thenReturn() are not interchangeable. thenReturn() just returns the transformed passable. then($callback) runs a final callback with the result — use it when you need to do something extra at the end, like Laravel does when it dispatches the built request to the router.
Watch container coupling. Each string pipe is resolved out of the container, so constructor dependencies get injected for free — but reading request() inside a stage ties it to the current request. That's fine for a query filter; for anything you want to reuse off a web request, pass the state in through send() instead.
Conditional stages have first-class support. Instead of an if around the whole thing, use ->pipe(SomeStage::class) to push a stage on, or ->when($condition, fn ($pipeline) => $pipeline->pipe(SomeStage::class)) to add one only when a condition holds.
Wrapping up#
Start with the worst offender: find the fattest filter() or handle() method you own, pull each independent branch into its own stage class, and wire them together with Pipeline::send()->through()->thenReturn(). You'll get classes you can name, reorder, and unit-test in isolation, and a controller that reads like a table of contents. From here, the natural next step is sequencing work that spans multiple jobs — chaining vs batching queued jobs applies the same "ordered steps" thinking to the queue.
FAQ#
What is the Pipeline facade in Laravel?
Illuminate\Support\Facades\Pipeline is a helper for passing a single input through an ordered series of classes or closures, giving each one a chance to inspect or modify it before calling the next. You describe the flow with send(), through(), and thenReturn(). It's the same mechanism Laravel uses internally to run requests through middleware.
How does Laravel's Pipeline work?
You hand it a payload with send(), list your stages with through(), and finish with thenReturn() (or then()). Each stage is a class with a handle($passable, Closure $next) method: it does its work, then calls $next($passable) to pass control to the next stage. When the last stage returns, you get the fully transformed value back.
When should I use a pipeline instead of a service class?
Use a service class for one cohesive operation, even a multi-step one. Reach for a pipeline when you have an ordered list of independent transformations you want to add, remove, reorder, or test individually — search filters, content sanitising, an onboarding sequence. The signal is a method accumulating if branches that don't depend on each other.
How do I build a query filter with the Laravel pipeline?
Send an Eloquent Builder as the passable and make each stage a class that conditionally constrains it, returning $next($query). Guard each constraint with $query->when(request()->filled('field'), ...) so a stage only touches the query when that filter is present. Call ->thenReturn() to get the built query, then paginate() or get() it.
Is the Pipeline facade the same as middleware?
Effectively, yes — middleware is a pipeline. Laravel builds the middleware stack by sending the Request object through a pipeline, and each middleware class has the exact handle($passable, $next) signature a pipe uses. The Pipeline facade just exposes that same pattern for your own code, so you can apply it to comments, queries, or any object rather than only HTTP requests.