Every Laravel request passes through middleware. You've probably written custom middleware, registered it in bootstrap/app.php, and moved on. But have you ever looked at how middleware works under the hood? The engine driving that entire system is a class called Pipeline, and it's available for you to use in your own application code.
The Pipeline pattern lets you pass an object through a series of steps, where each step can inspect, modify, or short-circuit the process. It's the same concept as Unix pipes: take an input, transform it, pass it along. This pattern is hiding in plain sight inside Laravel, and once you start using it, you'll find it everywhere.
How Laravel Uses Pipelines Internally
When a request hits your Laravel application, the Router sends it through the middleware stack using Illuminate\Pipeline\Pipeline. Here's a simplified version of what happens inside the HTTP kernel:
// Simplified from Illuminate\Foundation\Http\Kernel
(new Pipeline($this->app))
->send($request)
->through($this->middleware)
->then(fn ($request) => $this->router->dispatch($request));
The send() method sets the object traveling through the pipeline. The through() method defines the steps. The then() method provides the final destination and kicks everything off. Each middleware receives the request and a $next closure that passes control to the next step.
This same pattern is powerful for your own domain logic. Let me show you how.
The Basics: Your First Pipeline
Let's start with a common scenario: processing user input before creating a record. You might need to normalize data, validate business rules, enrich it with external data, and check permissions. Without pipelines, this often becomes a bloated controller method or a service class with a long procedural method.
With pipelines, each step is its own class with a single responsibility:
use Illuminate\Support\Facades\Pipeline;
$result = Pipeline::send($orderData)
->through([
NormalizeAddresses::class,
ApplyDiscountRules::class,
CalculateShipping::class,
CalculateTax::class,
ValidateInventory::class,
])
->thenReturn();
Each pipe class implements a handle() method (you can customize the method name, but handle is the default):
<?php
namespace App\Pipes\Orders;
use App\DataTransferObjects\OrderData;
use Closure;
class CalculateShipping
{
public function handle(OrderData $order, Closure $next): mixed
{
$shippingRate = $this->calculateRate(
$order->address,
$order->totalWeight()
);
$order->shippingCost = $shippingRate;
return $next($order);
}
private function calculateRate(Address $address, float $weight): int
{
// Shipping logic here
}
}
The key contract is simple: receive the data, do your work, call $next($data) to continue, or throw an exception (or return early) to stop the pipeline.
Using a Data Transfer Object
Pipelines work best when the "passable" object is a well-defined DTO rather than a raw array. This gives you type safety and makes each pipe self-documenting:
<?php
namespace App\DataTransferObjects;
use App\Models\User;
class RegistrationData
{
public bool $isApproved = false;
public ?string $rejectionReason = null;
public array $notifications = [];
public ?string $assignedTeam = null;
public function __construct(
public string $name,
public string $email,
public string $company,
public string $plan,
public ?User $user = null,
) {}
public function reject(string $reason): void
{
$this->isApproved = false;
$this->rejectionReason = $reason;
}
public function approve(): void
{
$this->isApproved = true;
$this->rejectionReason = null;
}
}
Now each pipe can interact with a strongly typed object and set meaningful flags that downstream pipes can read.
Real-World Example: Approval Workflow
Let's build something real. Imagine a SaaS application where new user registrations go through a multi-step approval process. Some steps are automated checks; others flag the registration for manual review.
<?php
namespace App\Services;
use App\DataTransferObjects\RegistrationData;
use App\Pipes\Registration\AssignToTeam;
use App\Pipes\Registration\CheckBlockedDomains;
use App\Pipes\Registration\CheckDuplicateAccounts;
use App\Pipes\Registration\CreateUser;
use App\Pipes\Registration\EnrichCompanyData;
use App\Pipes\Registration\SendWelcomeNotification;
use App\Pipes\Registration\ValidatePlanEligibility;
use Illuminate\Support\Facades\Pipeline;
class RegistrationService
{
/**
* @return RegistrationData
*/
public function register(RegistrationData $data): RegistrationData
{
return Pipeline::send($data)
->through([
CheckBlockedDomains::class,
CheckDuplicateAccounts::class,
EnrichCompanyData::class,
ValidatePlanEligibility::class,
AssignToTeam::class,
CreateUser::class,
SendWelcomeNotification::class,
])
->thenReturn();
}
}
Each pipe handles a single concern. Let's look at a few of them:
<?php
namespace App\Pipes\Registration;
use App\DataTransferObjects\RegistrationData;
use Closure;
class CheckBlockedDomains
{
/** @var array<string> */
private array $blockedDomains = [
'tempmail.com',
'throwaway.email',
'mailinator.com',
];
public function handle(RegistrationData $data, Closure $next): mixed
{
$domain = str($data->email)->after('@')->toString();
if (in_array($domain, $this->blockedDomains)) {
$data->reject("Email domain '{$domain}' is not allowed.");
// Stop the pipeline — don't continue to the next step
return $data;
}
return $next($data);
}
}
Notice how the blocked domain check stops the pipeline by returning early instead of calling $next(). This is the short-circuit capability. Downstream pipes never run.
<?php
namespace App\Pipes\Registration;
use App\DataTransferObjects\RegistrationData;
use App\Services\ClearbitService;
use Closure;
class EnrichCompanyData
{
public function __construct(
private ClearbitService $clearbit,
) {}
public function handle(RegistrationData $data, Closure $next): mixed
{
try {
$companyInfo = $this->clearbit->lookupCompany($data->company);
if ($companyInfo) {
$data->companySize = $companyInfo->employeeCount;
$data->industry = $companyInfo->industry;
}
} catch (\Throwable) {
// Enrichment is optional — don't block registration if it fails
}
return $next($data);
}
}
The enrichment pipe demonstrates another important pattern: a pipe that enhances the data when possible but doesn't block the pipeline on failure. The try/catch ensures an external service outage doesn't prevent a user from registering.
<?php
namespace App\Pipes\Registration;
use App\DataTransferObjects\RegistrationData;
use App\Models\User;
use Closure;
class CreateUser
{
public function handle(RegistrationData $data, Closure $next): mixed
{
if (! $data->isApproved) {
// Previous pipe rejected the registration; skip user creation
return $next($data);
}
$data->user = User::create([
'name' => $data->name,
'email' => $data->email,
'company' => $data->company,
'team_id' => $data->assignedTeam,
]);
return $next($data);
}
}
This pipe checks a flag set by an earlier pipe. Each step can react to what happened before it, which makes the pipeline flexible without adding conditionals to the service class.
Content Transformation Chains
Pipelines are especially clean for content processing. Imagine a CMS or blog platform where user-submitted content needs to pass through several transformations before it's stored or displayed:
<?php
namespace App\Services;
use App\DataTransferObjects\ContentData;
use App\Pipes\Content\ConvertMarkdownToHtml;
use App\Pipes\Content\ExtractTableOfContents;
use App\Pipes\Content\GenerateExcerpt;
use App\Pipes\Content\HighlightCodeBlocks;
use App\Pipes\Content\LazyLoadImages;
use App\Pipes\Content\SanitizeHtml;
use Illuminate\Support\Facades\Pipeline;
class ContentProcessor
{
public function process(ContentData $content): ContentData
{
return Pipeline::send($content)
->through([
SanitizeHtml::class,
ConvertMarkdownToHtml::class,
HighlightCodeBlocks::class,
LazyLoadImages::class,
ExtractTableOfContents::class,
GenerateExcerpt::class,
])
->thenReturn();
}
}
Each transformation is isolated and testable. If you need to add a new step, like automatic link checking or image optimization, you add one class and one line in the pipeline definition. No existing code changes.
Here's what a content pipe looks like:
<?php
namespace App\Pipes\Content;
use App\DataTransferObjects\ContentData;
use Closure;
class LazyLoadImages
{
public function handle(ContentData $content, Closure $next): mixed
{
$content->html = preg_replace(
'/<img(?!.*loading=)/',
'<img loading="lazy"',
$content->html
);
return $next($content);
}
}
Dynamic Pipelines: Building the Steps at Runtime
The pipeline steps don't have to be hardcoded. You can build them dynamically based on configuration, user roles, or feature flags:
<?php
namespace App\Services;
use Illuminate\Support\Facades\Pipeline;
class OrderProcessor
{
public function process(OrderData $order): OrderData
{
$pipes = collect([
ValidateItems::class,
CalculateSubtotal::class,
]);
if ($order->hasDiscountCode()) {
$pipes->push(ApplyDiscountCode::class);
}
if ($order->requiresShipping()) {
$pipes->push(CalculateShipping::class);
}
if (config('features.loyalty_points')) {
$pipes->push(ApplyLoyaltyPoints::class);
}
$pipes->push(
CalculateTax::class,
FinalizeTotal::class,
);
return Pipeline::send($order)
->through($pipes->all())
->thenReturn();
}
}
This is much cleaner than nesting conditionals inside a monolithic processing method. Each pipe still does one thing, and the composition logic reads like a checklist.
Using Closures as Pipes
Not every step warrants its own class. For simple transformations, you can use closures directly in the pipeline:
use Illuminate\Support\Facades\Pipeline;
$result = Pipeline::send($input)
->through([
fn ($data, Closure $next) => $next(strtolower($data)),
fn ($data, Closure $next) => $next(trim($data)),
SomeComplexTransformation::class,
fn ($data, Closure $next) => $next(str_replace(' ', '-', $data)),
])
->thenReturn();
You can mix closures and classes freely. Use closures for trivial one-liners and classes for anything with dependencies or logic worth testing.
Customizing the Pipe Method Name
By default, the Pipeline calls the handle() method on each pipe class. You can change this with the via() method:
Pipeline::send($data)
->through($pipes)
->via('transform')
->thenReturn();
This is useful when your pipe classes serve multiple purposes, or when you want the method name to better describe the operation. For content processing, transform reads better than handle. For validation pipelines, validate might make more sense.
Testing Pipelines
One of the biggest advantages of the pipeline pattern is testability. Each pipe is a plain PHP class that you can unit test in isolation:
<?php
namespace Tests\Unit\Pipes\Registration;
use App\DataTransferObjects\RegistrationData;
use App\Pipes\Registration\CheckBlockedDomains;
use PHPUnit\Framework\TestCase;
class CheckBlockedDomainsTest extends TestCase
{
public function test_blocked_domain_rejects_registration(): void
{
$data = new RegistrationData(
name: 'Test User',
email: 'user@tempmail.com',
company: 'Acme',
plan: 'pro',
);
$pipe = new CheckBlockedDomains;
$nextCalled = false;
$result = $pipe->handle($data, function ($data) use (&$nextCalled) {
$nextCalled = true;
return $data;
});
$this->assertFalse($nextCalled);
$this->assertFalse($result->isApproved);
$this->assertStringContainsString('tempmail.com', $result->rejectionReason);
}
public function test_valid_domain_passes_through(): void
{
$data = new RegistrationData(
name: 'Test User',
email: 'user@legitimate-company.com',
company: 'Acme',
plan: 'pro',
);
$pipe = new CheckBlockedDomains;
$nextCalled = false;
$pipe->handle($data, function ($data) use (&$nextCalled) {
$nextCalled = true;
return $data;
});
$this->assertTrue($nextCalled);
}
}
You test each pipe by calling its method directly, passing a closure as the $next parameter. You can assert whether the closure was called (the pipeline continued), whether the data was modified correctly, and whether the pipeline was short-circuited.
For integration tests, test the entire pipeline through the service class that orchestrates it:
public function test_registration_blocks_disposable_emails(): void
{
$data = new RegistrationData(
name: 'Test',
email: 'test@tempmail.com',
company: 'Acme',
plan: 'starter',
);
$result = app(RegistrationService::class)->register($data);
$this->assertFalse($result->isApproved);
$this->assertNull($result->user);
}
When to Use Pipelines (and When Not To)
Pipelines shine when you have a sequence of operations on a single piece of data where each step is independent and testable, steps might need to be added or reordered, and the logic is complex enough that a single method would become unwieldy.
They're a good fit for request processing and validation, data import and transformation, content processing, approval and moderation workflows, and multi-step calculations like pricing and tax.
Pipelines are not the right tool when your steps have complex dependencies on each other (use a saga or state machine instead), when you need parallel execution (use job batching), or when the process is simple enough that a few lines in a service method would suffice. Don't introduce a pipeline for two steps that will never grow.
A Pattern Worth Knowing
The Pipeline pattern is one of Laravel's best-kept secrets. It's not hidden or undocumented — it's in the framework source code, it has a clean facade, and it solves a real problem. I ignored it for years because I didn't know it existed, and I kept writing bloated service methods instead.
Next time you find yourself writing a long method that does seven things in sequence, or a service class with a process() method that keeps growing, consider breaking it into pipes. You'll get code that is easier to read, easier to test, and easier to extend. And that's the whole point of using a framework in the first place.