Laravel Pipeline Pattern — Chain Complex Operations Cleanly

Laravel Pipeline Pattern — Chain Complex Operations Cleanly

Reading Time: 4 minutes

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
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 PipelineUse a Service instead
3+ ordered steps on the same object1–2 steps, no shared state
Steps need to be reordered/toggledFixed sequence, no variation
Each step should be testable aloneSteps are tightly coupled anyway
You want to add steps without touching existing codeScope 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

Resources


← API Resources | Soft Deleted →

Laravel Pipeline Pattern — Chain Complex Operations Cleanly

Oh hi there 👋
It’s nice to meet you.

Sign up to receive awesome content in your inbox.

We don’t spam! Read our privacy policy for more info.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

2 Comments