Pipeline Pattern in Laravel: Clean Code for Complex Logic

· 3 min read

Sometimes controllers or services become "fat" with procedural logic that executes steps in a specific order.

For example, placing an order involves:

  1. Validate stock
  2. Calculate discounts
  3. Charge payment
  4. Send notification
  5. 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.

Comments