Pipeline Pattern in Laravel: Clean Code for Complex Logic
Sometimes controllers or services become "fat" with procedural logic that executes steps in a specific order.
For example, placing an order involves:
- Validate stock
- Calculate discounts
- Charge payment
- Send notification
- Empty cart
Putting this all in one method leads to spaghetti code. The Pipeline Pattern allows you to break this process into individual classes (Pipes) through which data passes.
Laravel's Pipeline Utility
Laravel uses pipelines internally for Middleware. You can use the same utility for your own logic.
use Illuminate\Support\Facades\Pipeline;
public function store(Request $request)
{
$order = Pipeline::send($request->all())
->through([
ValidateInventory::class,
ApplyDiscounts::class,
ProcessPayment::class,
SendOrderEmail::class,
])
->then(fn ($content) => Order::create($content));
return response()->json($order);
}
Creating a Pipe
Each pipe class needs a handle method. It receives the content (passable) and a Closure $next.
1. Validate Inventory
class ValidateInventory
{
public function handle($content, Closure $next)
{
if (! Inventory::hasStock($content['product_id'])) {
throw new OutOfStockException();
}
return $next($content);
}
}
2. Apply Discounts
class ApplyDiscounts
{
public function handle($content, Closure $next)
{
if (isset($content['coupon_code'])) {
$content['price'] = $content['price'] * 0.9; // 10% off
}
return $next($content);
}
}
3. Process Payment
class ProcessPayment
{
public function handle($content, Closure $next)
{
// Call Stripe/PayPal
PaymentGateway::charge($content['price']);
return $next($content);
}
}
Dependency Injection
Pipes are resolved via the Service Container, so you can inject dependencies in the constructor.
class SendOrderEmail
{
public function __construct(
protected Mailer $mailer
) {}
public function handle($content, Closure $next)
{
$this->mailer->to($content['email'])->send(new OrderConfirmed());
return $next($content);
}
}
Using a Custom Method Name
If you don't want to use handle, you can specify a method name via via().
Pipeline::send($order)
->via('process')
->through($pipes)
->then(function ($order) {
// ...
});
Advanced Data Objects
Passing an array $content can be risky (no type safety). Instead, pass an Object or DTO.
class OrderContext
{
public User $user;
public Collection $items;
public float $total = 0;
public ?string $error = null;
}
// Usage
$context = new OrderContext($user, $items);
Pipeline::send($context)
->through([...])
->then(function ($context) {
// All pipes modified this $context object
$context->save();
});
Breaking the Pipeline
If a pipe doesn't call $next($content), the pipeline stops. This is useful for validation failures or conditional logic.
class CheckFraud
{
public function handle($content, Closure $next)
{
if ($this->isFraudulent($content)) {
// Stop execution, return error
return response()->json(['error' => 'Fraud detected'], 400);
}
return $next($content);
}
}
Note: If you return a response early, the final then() callback will NOT execute (unless you structured it to handle that return value).
Reusable Pipelines
You can encapsulate the pipeline configuration in a dedicated class.
class OrderProcessingPipeline
{
protected $pipes = [
ValidateInventory::class,
ApplyDiscounts::class,
ProcessPayment::class,
];
public function run(Order $order)
{
return Pipeline::send($order)
->through($this->pipes)
->thenReturn(); // Returns the passed object
}
}
When to use?
✅ Use when:
- You have a sequential process with more than 3 steps.
- The steps are independent or loosely coupled.
- You need conditional execution of steps.
- You want to unit test each step in isolation.
❌ Avoid when:
- The logic is simple (1-2 steps).
- The steps are tightly coupled (step 3 relies heavily on step 1 internals).
- You need transactional database rollback across all steps (though you can wrap the pipeline in a
DB::transaction).
Summary
The Pipeline pattern allows you to write:
- Testable code: Each pipe is a small, validatable unit.
- Readable code: The main controller method reads like a table of contents.
- Flexible code: Rearrange, add, or remove steps without touching the core logic.
It's one of Laravel's most underutilized features for refactoring legacy codebases.