Laravel Pipeline Pattern — Chain Complex Operations Cleanly
Series: Every Laravel Project Should Have These Building Blocks
Part 34 of 35 | Level: Beginner–Intermediate Prerequisites: None
Table of Contents
The Problem
You have a multi-step operation. Maybe it’s processing an order: validate stock → apply discount → charge payment → send confirmation → notify warehouse. You could nest these calls:
// ❌ Nesting quickly becomes hard to follow
public function handle(CreateOrderRequest $request): JsonResponse
{
$order = $this->stockService->validate($request->validated());
$order = $this->discountService->apply($order, $request->coupon);
$order = $this->paymentService->charge($order);
$this->notificationService->sendConfirmation($order);
$this->warehouseService->notify($order);
return OrderResource::make($order)->response();
}
Or you could make a massive “God” service that does all of it. Neither is clean. Both are hard to test in isolation, reorder, or extend.
Laravel ships a Pipeline facade that solves exactly this.
What Is a Pipeline?
Laravel’s Pipeline passes an object through a series of pipes in sequence. Each pipe receives the object, does one thing, then passes it to the next pipe. If a pipe fails, the whole chain stops.
$result = Pipeline::send($payload)
->through([
PipeOne::class,
PipeTwo::class,
PipeThree::class,
])
->thenReturn();
It’s the same pattern Laravel uses internally for middleware — $next($request) is just a pipe.
Building a Pipeline Step-by-Step
1. Define the payload (a DTO)
Each pipe works on the same object. Make it typed:
// app/DTOs/OrderPipelineData.php
declare(strict_types=1);
namespace App\DTOs;
use App\Models\Order;
use App\Models\User;
final class OrderPipelineData
{
public ?Order $order = null;
public function __construct(
public readonly User $user,
public readonly array $items,
public readonly ?string $couponCode,
) {}
}
2. Write individual pipes
Each pipe is a single-responsibility class with a handle method:
// app/Pipelines/Order/ValidateStock.php
declare(strict_types=1);
namespace App\Pipelines\Order;
use App\DTOs\OrderPipelineData;
use App\Exceptions\OutOfStockException;
use Closure;
final class ValidateStock
{
public function handle(OrderPipelineData $data, Closure $next): OrderPipelineData
{
foreach ($data->items as $item) {
if ($item['quantity'] > $item['stock']) {
throw new OutOfStockException("'{$item['name']}' is out of stock.");
}
}
return $next($data);
}
}
// app/Pipelines/Order/ApplyCoupon.php
declare(strict_types=1);
namespace App\Pipelines\Order;
use App\DTOs\OrderPipelineData;
use App\Models\Coupon;
use Closure;
final class ApplyCoupon
{
public function handle(OrderPipelineData $data, Closure $next): OrderPipelineData
{
if ($data->couponCode) {
$coupon = Coupon::where('code', $data->couponCode)->firstOrFail();
$coupon->assertValid($data->user);
// Coupon details stored on DTO for downstream pipes
$data->appliedCoupon = $coupon;
}
return $next($data);
}
}
// app/Pipelines/Order/CreateOrderRecord.php
declare(strict_types=1);
namespace App\Pipelines\Order;
use App\DTOs\OrderPipelineData;
use App\Models\Order;
use Closure;
use Illuminate\Support\Facades\DB;
final class CreateOrderRecord
{
public function handle(OrderPipelineData $data, Closure $next): OrderPipelineData
{
$data->order = DB::transaction(function () use ($data): Order {
$order = Order::create([
'user_id' => $data->user->id,
'coupon_id' => $data->appliedCoupon?->id,
'total' => $this->calculateTotal($data),
'status' => 'pending',
]);
foreach ($data->items as $item) {
$order->items()->create($item);
}
return $order;
});
return $next($data);
}
private function calculateTotal(OrderPipelineData $data): int
{
$subtotal = collect($data->items)->sum(fn ($i) => $i['price'] * $i['quantity']);
$discount = $data->appliedCoupon?->calculateDiscount($subtotal) ?? 0;
return $subtotal - $discount;
}
}
// app/Pipelines/Order/SendConfirmation.php
declare(strict_types=1);
namespace App\Pipelines\Order;
use App\DTOs\OrderPipelineData;
use App\Notifications\OrderConfirmed;
use Closure;
final class SendConfirmation
{
public function handle(OrderPipelineData $data, Closure $next): OrderPipelineData
{
$data->user->notify(new OrderConfirmed($data->order));
return $next($data);
}
}
3. Wire it together in an Action
// app/Actions/Order/ProcessOrderAction.php
declare(strict_types=1);
namespace App\Actions\Order;
use App\DTOs\OrderPipelineData;
use App\Pipelines\Order\ApplyCoupon;
use App\Pipelines\Order\CreateOrderRecord;
use App\Pipelines\Order\SendConfirmation;
use App\Pipelines\Order\ValidateStock;
use Illuminate\Pipeline\Pipeline;
final class ProcessOrderAction
{
public function __construct(private readonly Pipeline $pipeline) {}
public function execute(OrderPipelineData $data): OrderPipelineData
{
return $this->pipeline
->send($data)
->through([
ValidateStock::class,
ApplyCoupon::class,
CreateOrderRecord::class,
SendConfirmation::class,
])
->thenReturn();
}
}
4. Call it from the controller
// app/Http/Controllers/OrdersController.php
public function store(StoreOrderRequest $request, ProcessOrderAction $action): JsonResponse
{
$data = new OrderPipelineData(
user: $request->user(),
items: $request->validated('items'),
couponCode: $request->validated('coupon_code'),
);
$result = $action->execute($data);
return OrderResource::make($result->order)
->response()
->setStatusCode(201);
}
Clean, thin, and every step is independently testable.
Real-World: Conditional Pipes
You can conditionally include pipes at runtime:
$pipes = [
ValidateStock::class,
ApplyCoupon::class,
CreateOrderRecord::class,
];
if ($request->user()->wantsEmailConfirmation()) {
$pipes[] = SendConfirmation::class;
}
if (config('features.warehouse_integration')) {
$pipes[] = NotifyWarehouse::class;
}
return $this->pipeline->send($data)->through($pipes)->thenReturn();
Real-World: Handling Failures
Each pipe can throw an exception — the chain stops immediately. Wrap in a try/catch in the action:
public function execute(OrderPipelineData $data): OrderPipelineData
{
try {
return $this->pipeline
->send($data)
->through([...])
->thenReturn();
} catch (OutOfStockException $e) {
throw ValidationException::withMessages(['items' => $e->getMessage()]);
} catch (PaymentFailedException $e) {
Log::error('Payment failed', ['order' => $data->order?->id, 'error' => $e->getMessage()]);
throw $e;
}
}
Real-World: Data Transformation Pipeline
Pipelines aren’t just for business logic. They’re great for transforming incoming data before it hits your database:
// Sanitise → normalise → enrich → store
Pipeline::send($rawImportData)
->through([
StripHtmlTags::class,
NormalisePhoneNumbers::class,
LookupCountryCode::class,
DeduplicateByEmail::class,
])
->then(fn ($clean) => ImportRecord::insert($clean->toArray()));
When to Use a Pipeline
| Use Pipeline | Use a Service instead |
|---|---|
| 3+ ordered steps on the same object | 1–2 steps, no shared state |
| Steps need to be reordered/toggled | Fixed sequence, no variation |
| Each step should be testable alone | Steps are tightly coupled anyway |
| You want to add steps without touching existing code | Scope is narrow and stable |
Testing Pipes in Isolation
The best part: each pipe can be unit-tested without running the full chain.
// tests/Unit/Pipelines/ValidateStockTest.php
public function test_throws_when_item_out_of_stock(): void
{
$data = new OrderPipelineData(
user: User::factory()->make(),
items: [['name' => 'T-Shirt', 'quantity' => 10, 'stock' => 5, 'price' => 1000]],
couponCode: null,
);
$this->expectException(OutOfStockException::class);
(new ValidateStock())->handle($data, fn ($d) => $d);
}
public function test_passes_through_when_stock_available(): void
{
$data = new OrderPipelineData(
user: User::factory()->make(),
items: [['name' => 'T-Shirt', 'quantity' => 3, 'stock' => 10, 'price' => 1000]],
couponCode: null,
);
$result = (new ValidateStock())->handle($data, fn ($d) => $d);
$this->assertSame($data, $result);
}
Folder Structure
app/
├── Pipelines/
│ └── Order/
│ ├── ValidateStock.php
│ ├── ApplyCoupon.php
│ ├── CreateOrderRecord.php
│ └── SendConfirmation.php
├── DTOs/
│ └── OrderPipelineData.php
└── Actions/
└── Order/
└── ProcessOrderAction.php
FAQs
When should I use Laravel Pipeline instead of a Service?
Use a Pipeline for 3+ ordered steps on the same object, when steps need reordering or toggling, or when each step should be testable alone. Use a Service for 1–2 tightly coupled steps.
Is Laravel Pipeline the same as middleware?
Same pattern. HTTP middleware is Laravel’s own use of Pipeline — each pipe receives the payload, does one thing, and calls $next().
How do I handle failures inside a pipeline? Throw a domain exception from any pipe; the chain stops immediately. Catch it in the Action that runs the pipeline and translate it to a validation error or log entry.
Key Takeaways
- Pipeline replaces nested service calls with a clean, ordered chain of single-purpose classes
- Each pipe has one responsibility — easy to test, easy to reorder
- The payload DTO carries state between pipes so nothing is hidden in service properties
- Conditional pipes let you vary the chain at runtime without branching inside pipes
- Laravel uses this pattern internally for HTTP middleware — you already know how it works
2 Comments