Pipelines
The pattern behind middleware — pass an object through a series of stages, each free to transform it.
The Pipeline is the mechanism that powers HTTP middleware, and you can use it directly for any "pass this through a sequence of steps" problem. Each stage receives the object and a $next closure; it does its work, then calls $next($object) to hand off to the following stage — exactly like middleware.
Why it beats a pile of if-statements
Filtering a search query, sanitising input, building a report through composable steps — a pipeline lets each concern live in its own tiny, testable class, and you compose them by listing them in order.
use Illuminate\Support\Facades\Pipeline;
$result = Pipeline::send($query)
->through([FilterByStatus::class, SortByDate::class])
->thenReturn();The stage shape
Each stage is a class with a handle($passable, Closure $next) method. Return $next($passable) to continue, or short-circuit by returning early.
Example
<?php
// --- A single, focused pipe ---
namespace App\QueryFilters;
use Closure;
use Illuminate\Database\Eloquent\Builder;
class FilterByStatus
{
public function handle(Builder $query, Closure $next): Builder
{
if ($status = request('status')) {
$query->where('status', $status);
}
return $next($query); // hand off to the next pipe
}
}
// --- Composing them ---
namespace App\Http\Controllers;
use App\Models\Order;
use App\QueryFilters\{FilterByStatus, FilterByDateRange, SortResults};
use Illuminate\Support\Facades\Pipeline;
class OrderSearchController
{
public function __invoke()
{
$orders = Pipeline::send(Order::query())
->through([
FilterByStatus::class,
FilterByDateRange::class,
SortResults::class,
])
->thenReturn() // returns the transformed Builder
->paginate(20);
return $orders;
}
}When to use it
- A content moderation system passes a user's post through a Pipeline of stages — spam check, profanity filter, link validation — before saving it, with each stage being a separate testable class.
- An image upload handler runs an uploaded file through a Pipeline of processors (resize, watermark, optimize, store) keeping each transformation independent and reorderable.
- A data import service sends each CSV row through a Pipeline that trims whitespace, validates fields, maps to a DTO, and upserts the record, making it easy to add or remove stages.
More examples
Basic Pipeline with send/through/thenReturn
Pipeline::send() sets the payload, through() defines the ordered stages, and thenReturn() runs the pipeline and returns the final transformed value.
use Illuminate\Pipeline\Pipeline;
$post = app(Pipeline::class)
->send($request->all())
->through([
\App\Pipes\TrimInput::class,
\App\Pipes\FilterProfanity::class,
\App\Pipes\CheckSpamScore::class,
])
->thenReturn();
// $post is the value returned by the last pipeA pipe class with handle()
Each pipe class receives the payload and a $next closure; calling $next() continues the pipeline, while returning early halts it — just like HTTP middleware.
namespace App\Pipes;
use Closure;
class FilterProfanity
{
public function handle(array $payload, Closure $next): array
{
$blocked = config('moderation.blocked_words', []);
foreach ($blocked as $word) {
$payload['body'] = str_ireplace($word, '***', $payload['body']);
}
return $next($payload); // pass to the next pipe
}
}Pipeline with a then() callback
then() receives the transformed value after all pipes have run and performs a final action — here an upsert — returning its own result.
use Illuminate\Pipeline\Pipeline;
$result = app(Pipeline::class)
->send(new ImportRow($csvRow))
->through([
TrimWhitespace::class,
ValidateRequiredFields::class,
MapToProduct::class,
])
->then(function (ImportRow $row) {
Product::upsert($row->data, ['sku'], ['name', 'price', 'stock']);
return 'imported';
});
Discussion