Series: Every Laravel Project Should Have These Building Blocks
Part 34 of 35 | Level: Beginner–Intermediate Prerequisites: None
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 <?php declare(strict_types=1); namespace App\DTOs; use App\Models\Coupon; use App\Models\Order; use App\Models\User; final class OrderPipelineData { public ?Order $order = null; public ?Coupon $appliedCoupon = null; // set by ApplyCoupon 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 <?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 <?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 <?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 <?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 <?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