DEV Community

Cover image for Laravel Pipeline Pattern: Chain Complex Operations Cleanly
Shakil Alam
Shakil Alam

Posted on Originally published at blog.shakiltech.com

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


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();
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode
// 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);
    }
}
Enter fullscreen mode Exit fullscreen mode
// 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;
    }
}
Enter fullscreen mode Exit fullscreen mode
// 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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()));
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Folder Structure

app/
├── Pipelines/
│   └── Order/
│       ├── ValidateStock.php
│       ├── ApplyCoupon.php
│       ├── CreateOrderRecord.php
│       └── SendConfirmation.php
├── DTOs/
│   └── OrderPipelineData.php
└── Actions/
    └── Order/
        └── ProcessOrderAction.php
Enter fullscreen mode Exit fullscreen mode

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

Resources


← API Resources | Soft Deleted →

Top comments (0)