Medium Laravel

Laravel Pipelines: Turn a 340-Line Checkout Controller into Small, Testable Steps

Use the Laravel Pipeline facade to split checkout flows and query filters into pipe classes you can reorder, reuse and unit test, with the pitfalls to avoid.

02 Nov, 2026 8 min Updated 23 Oct, 2026 10 Code blocks
Diagram
graph LR A[CheckoutData] --> B[Ensure items in stock] B --> C[Calculate subtotal] C --> D[Apply coupon] D --> E[Calculate tax] E --> F[Create order] F --> G[Reserve inventory] G --> H[Charge payment] H --> I[Order returned]

Every Laravel project has one: the CheckoutController@store method that started as 30 lines and is now 340. It checks stock, recalculates prices, validates a coupon, adds VAT, creates the order, reserves inventory, calls the payment gateway, and somewhere in the middle there is an if ($request->has('gift_wrap')) that someone added on a Friday. Nobody wants to touch it, every change breaks something unrelated, and the only test is a 200-line feature test that mocks half the app.

The work itself is fine. It is a sequence of steps, each one taking the same piece of data, doing one thing to it, and passing it on. Laravel already has a tool built for exactly that shape, and you use it on every request without noticing: the pipeline that runs your middleware. It is available to your own code through the Pipeline facade.

How the Pipeline Works

A pipeline takes a "passable" (any value: a DTO, a query builder, a string) and sends it through a list of pipes. Each pipe receives the passable and a $next closure. It can modify the passable, call $next($passable) to continue, do work after $next returns, or stop the chain by returning or throwing without calling $next. That is exactly the middleware contract.

Pipeline::send($passable)   the value travelling through
    ->through([...pipes])   classes, class strings or closures
    ->via('handle')         method to call on class pipes (default: handle)
    ->then($destination)    final closure, receives the processed value
    ->thenReturn()          shortcut: return the processed value as is

Internally Laravel builds an onion: it wraps the destination closure in the last pipe, wraps that in the previous pipe, and so on with array_reduce, then calls the outermost one. Class-string pipes are resolved from the container at call time, so their constructors can type-hint any dependency.

Designing the Passenger Object

Before writing pipes, decide what travels through them. Passing the raw Request or a loose array works for a demo but makes every pipe guess at keys. A small mutable DTO, the "passenger", gives each step a typed place to read inputs and write results:

namespace App\Checkout;

use App\Models\Cart;
use App\Models\Coupon;
use App\Models\Order;
use App\Models\User;

final class CheckoutData
{
    public int $subtotal = 0;
    public int $discount = 0;
    public int $tax = 0;
    public ?Coupon $coupon = null;
    public ?Order $order = null;
    public ?string $paymentReference = null;

    public function __construct(
        public readonly User $user,
        public readonly Cart $cart,
        public readonly ?string $couponCode = null,
    ) {}

    public function total(): int
    {
        return max(0, $this->subtotal - $this->discount) + $this->tax;
    }
}

Inputs are readonly because no pipe should change who is checking out or which cart is being bought. Outputs are plain public properties that pipes fill in order. Money is stored as integer minor units (for VND that is simply the amount) so there is no float rounding.

Pipes as Classes

Each step becomes a class with one handle method. Here is the coupon step:

namespace App\Checkout\Pipes;

use App\Checkout\CheckoutData;
use App\Checkout\CheckoutFailed;
use App\Models\Coupon;
use Closure;

final class ApplyCoupon
{
    public function handle(CheckoutData $data, Closure $next): mixed
    {
        if ($data->couponCode === null) {
            return $next($data);
        }

        $coupon = Coupon::query()
            ->where('code', $data->couponCode)
            ->where('expires_at', '>', now())
            ->first();

        if ($coupon === null) {
            throw CheckoutFailed::because('Coupon is invalid or expired.');
        }

        $data->coupon = $coupon;
        $data->discount = min($data->subtotal, $coupon->amountOff($data->subtotal));

        return $next($data);
    }
}

Read it top to bottom and you know everything the step does. It does not know about tax, payment or the HTTP request. It has one reason to change. The early return $next($data) for "no coupon" is the pattern you will use most often: a pipe that does not apply simply passes the data along.

Throwing is how a pipe aborts the whole flow. Because the next pipes are only called through $next, an exception in ApplyCoupon means CreateOrder and ChargePayment never run.

Building the Checkout Pipeline

The action that ties it together is now short enough to fit on one screen, and the order of steps reads like the business process:

namespace App\Checkout;

use App\Checkout\Pipes\ApplyCoupon;
use App\Checkout\Pipes\CalculateSubtotal;
use App\Checkout\Pipes\CalculateTax;
use App\Checkout\Pipes\ChargePayment;
use App\Checkout\Pipes\CreateOrder;
use App\Checkout\Pipes\EnsureItemsInStock;
use App\Checkout\Pipes\ReserveInventory;
use App\Models\Order;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Pipeline;

final class PlaceOrder
{
    /** @var list<class-string> */
    private array $pipes = [
        EnsureItemsInStock::class,
        CalculateSubtotal::class,
        ApplyCoupon::class,
        CalculateTax::class,
        CreateOrder::class,
        ReserveInventory::class,
        ChargePayment::class,
    ];

    public function __invoke(CheckoutData $data): Order
    {
        return DB::transaction(fn () => Pipeline::send($data)
            ->through($this->pipes)
            ->then(fn (CheckoutData $data): Order => $data->order));
    }
}
graph LR A[CheckoutData] --> B[Ensure items in stock] B --> C[Calculate subtotal] C --> D[Apply coupon] D --> E[Calculate tax] E --> F[Create order] F --> G[Reserve inventory] G --> H[Charge payment] H --> I[Order returned]

Wrapping the pipeline in DB::transaction means any exception rolls back the order row and the inventory reservation together. Keep in mind that the payment call is a side effect outside the database: if ChargePayment succeeds and the commit fails afterwards, you have charged a customer for an order that does not exist. Put the charge last, keep it idempotent with a reference you store on the order, and consider the transactional outbox pattern for anything that must happen exactly once after commit.

Because a pipe controls when $next runs, you can also write steps that wrap the rest of the chain, just like middleware measures response time:

namespace App\Checkout\Pipes;

use App\Checkout\CheckoutData;
use Closure;
use Illuminate\Support\Facades\Log;

final class LogCheckoutDuration
{
    public function handle(CheckoutData $data, Closure $next): mixed
    {
        $startedAt = hrtime(true);

        $result = $next($data);

        Log::info('checkout.completed', [
            'user_id' => $data->user->id,
            'total' => $data->total(),
            'ms' => (hrtime(true) - $startedAt) / 1e6,
        ]);

        return $result;
    }
}

Put it first in the list and it measures everything after it. Adding cross-cutting behaviour like this no longer means editing the business steps.

Pipelines for Query Filters

The second classic use is filtering a listing endpoint. The passable is an Eloquent Builder, and each filter adds its own where clause only when its query string parameter is present:

namespace App\Http\Controllers;

use App\Models\Product;
use App\QueryFilters\Category;
use App\QueryFilters\InStock;
use App\QueryFilters\PriceRange;
use App\QueryFilters\Search;
use App\QueryFilters\Sort;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Support\Facades\Pipeline;

final class ProductIndexController
{
    public function __invoke(): CursorPaginator
    {
        return Pipeline::send(Product::query()->where('is_active', true))
            ->through([
                Search::class,
                Category::class,
                PriceRange::class,
                InStock::class,
                Sort::class,
            ])
            ->thenReturn()
            ->cursorPaginate(24)
            ->withQueryString();
    }
}
namespace App\QueryFilters;

use Closure;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;

final class PriceRange
{
    public function __construct(private Request $request) {}

    public function handle(Builder $query, Closure $next): mixed
    {
        $min = $this->request->integer('min_price');
        $max = $this->request->integer('max_price');

        if ($min > 0) {
            $query->where('price', '>=', $min);
        }

        if ($max > 0) {
            $query->where('price', '<=', $max);
        }

        return $next($query);
    }
}

Each filter is resolved from the container, so Request is injected through the constructor. Adding a new filter is one class and one line in the array, with no changes to the controller logic. thenReturn() hands back the builder, and you paginate as usual.

Two more features are worth knowing. A class string can carry parameters after a colon, which arrive as extra string arguments to handle (exactly like throttle:60,1 in middleware). And closures can be mixed with classes for tiny one-off steps. via() changes the method name if your pipes use something other than handle:

use App\Checkout\Pipes\EnsureMinimumTotal;
use Illuminate\Support\Facades\Pipeline;

$result = Pipeline::send($data)
    ->through([
        EnsureMinimumTotal::class . ':50000,VND',
        fn ($data, $next) => $next($data),
    ])
    ->via('process')
    ->thenReturn();

Testing Pipes and Pipelines

Because each pipe is a class with one method and an explicit $next, unit tests do not need the HTTP layer. Call handle directly and pass a closure as $next that records whether it was called:

use App\Checkout\CheckoutData;
use App\Checkout\CheckoutFailed;
use App\Checkout\Pipes\ApplyCoupon;
use App\Models\Cart;
use App\Models\Coupon;
use App\Models\User;

function checkoutData(?string $code, int $subtotal): CheckoutData
{
    $data = new CheckoutData(User::factory()->make(), Cart::factory()->make(), $code);
    $data->subtotal = $subtotal;

    return $data;
}

it('applies a valid coupon and passes the data on', function () {
    Coupon::factory()->create(['code' => 'TET2027', 'amount' => 50_000, 'expires_at' => now()->addDay()]);
    $called = false;

    $result = (new ApplyCoupon())->handle(checkoutData('TET2027', 300_000), function ($data) use (&$called) {
        $called = true;

        return $data;
    });

    expect($called)->toBeTrue()
        ->and($result->discount)->toBe(50_000)
        ->and($result->total())->toBe(250_000);
});

it('stops the pipeline when the coupon is expired', function () {
    Coupon::factory()->create(['code' => 'OLD', 'expires_at' => now()->subDay()]);

    (new ApplyCoupon())->handle(checkoutData('OLD', 300_000), fn () => throw new LogicException('next must not run'));
})->throws(CheckoutFailed::class);

it('skips silently when no coupon code is given', function () {
    $result = (new ApplyCoupon())->handle(checkoutData(null, 120_000), fn ($data) => $data);

    expect($result->discount)->toBe(0);
});

Three cases cover most pipes: it transforms the data and calls $next, it aborts without calling $next, and it passes through untouched when it does not apply. Make the "abort" test's $next throw, so a pipe that forgets to stop is caught loudly.

Then one or two tests at the pipeline level check the order and the contract between steps. Because class pipes are resolved from the container, you can swap one for a mock:

use App\Checkout\Pipes\ChargePayment;
use App\Checkout\PlaceOrder;

it('never charges the card when an earlier pipe fails', function () {
    $this->mock(ChargePayment::class)->shouldNotReceive('handle');

    $data = checkoutData('DOES-NOT-EXIST', 300_000);

    expect(fn () => app(PlaceOrder::class)($data))->toThrow(App\Checkout\CheckoutFailed::class);
    $this->assertDatabaseCount('orders', 0);
});

That test protects the most expensive mistake in a checkout flow: charging money when the order should have been rejected.

Common Pitfalls

  • Forgetting to return $next($data). Calling $next($data); without return drops the result, and then receives null. Static analysis catches this if handle declares a return type.
  • Hidden ordering dependencies. CalculateTax needs subtotal and discount, so it must come after ApplyCoupon. Document these in the passenger class, or assert preconditions at the top of the pipe and throw early.
  • Passing the Request around. If pipes read from the request directly, they cannot be reused from a queued job or an Artisan command. Copy the inputs into the passenger once at the boundary.
  • Using a pipeline for two steps. Pipeline::send()->through([A::class, B::class]) for a two-line function is indirection without benefit. A plain method call is clearer.
  • Swallowing exceptions in a wrapping pipe. A logging or timing pipe that catches Throwable and returns something else turns a failed payment into a "successful" checkout. Rethrow.
  • Side effects that cannot be rolled back. Database work inside the transaction is safe; emails, webhooks and payments are not. Dispatch those after commit (ShouldQueueAfterCommit, DB::afterCommit(), or an outbox).

When to Use a Pipeline and When Not To

A pipeline fits well when:

  • a process is a linear sequence of steps over the same data: checkout, user onboarding, import row processing, document publishing;
  • steps are optional or configurable: build the pipe list from feature flags, tenant settings or the order type;
  • you want filters on a listing where each parameter maps to one query clause;
  • you need around behaviour (timing, logging, locking) without touching each step.

Reach for something else when:

  • the flow branches heavily ("if B2B then this five-step path, else that one"): a state machine or separate actions per path are easier to follow;
  • steps need to run in parallel or survive restarts: use queued jobs with Bus::chain() or Bus::batch(), which add retries and persistence that an in-memory pipeline does not have;
  • there are only one or two steps: just call them.

Back to that 340-line controller: after the refactor it validates the request, builds a CheckoutData, calls PlaceOrder, and returns a resource. Each business rule lives in a class you can test in isolation, and the next "gift wrap" request is a new pipe and one line in the list.

Interactive challenge

Try the challenge

Practice what you just learned. Write your solution, reveal hints if you get stuck.

Instructions

Implement a minimal pipeline like Laravel's. Create a class Pipeline with: send(passable) which stores the value and returns the pipeline; through(pipes) which stores an array of pipe functions and returns the pipeline; then(destination) which runs the passable through the pipes in order and returns the result; and thenReturn() (Python: then_return()) which works like then() with a destination that returns the value unchanged. Each pipe is a function called as pipe(value, next). Calling next(newValue) runs the rest of the chain (the remaining pipes, then the destination) and returns its result, so a pipe can run code before and after next. A pipe that returns without calling next stops the chain: later pipes and the destination never run and its return value becomes the result. With no pipes, then() just calls destination(passable).

Starter code

javascript
class Pipeline {
  constructor() {
    this.passable = undefined;
    this.pipes = [];
  }

  send(passable) {
    // TODO: store the passable and return this
    throw new Error('TODO');
  }

  through(pipes) {
    // TODO: store the list of pipes and return this
    throw new Error('TODO');
  }

  then(destination) {
    // TODO: run the passable through every pipe in order.
    // Each pipe is called as pipe(passable, next); next(value) continues the chain.
    // After the last pipe, call destination(value) and return what it returns.
    throw new Error('TODO');
  }

  thenReturn() {
    // TODO: same as then() with a destination that returns the value unchanged
    throw new Error('TODO');
  }
}
python
from typing import Any, Callable


class Pipeline:
    def __init__(self) -> None:
        self.passable: Any = None
        self.pipes: list[Callable[[Any, Callable[[Any], Any]], Any]] = []

    def send(self, passable: Any) -> "Pipeline":
        # TODO: store the passable and return self
        raise NotImplementedError

    def through(self, pipes: list) -> "Pipeline":
        # TODO: store the list of pipes and return self
        raise NotImplementedError

    def then(self, destination: Callable[[Any], Any]) -> Any:
        # TODO: run the passable through every pipe in order.
        # Each pipe is called as pipe(passable, next); next(value) continues the chain.
        # After the last pipe, call destination(value) and return what it returns.
        raise NotImplementedError

    def then_return(self) -> Any:
        # TODO: same as then() with a destination that returns the value unchanged
        raise NotImplementedError

Your solution

Hints

All hints revealed
Hint 1

Build the chain from the end: start with the destination and wrap it with the last pipe, then the one before, and so on.

Hint 2

JavaScript reduceRight and Python reduce over reversed(pipes) are a natural fit for building the onion.

Hint 3

Each wrapper is a function of one argument: value => pipe(value, nextWrapper).

Solution · javascript
class Pipeline {
  constructor() {
    this.passable = undefined;
    this.pipes = [];
  }

  send(passable) {
    this.passable = passable;
    return this;
  }

  through(pipes) {
    this.pipes = [...pipes];
    return this;
  }

  then(destination) {
    const run = this.pipes.reduceRight(
      (next, pipe) => (value) => pipe(value, next),
      (value) => destination(value),
    );
    return run(this.passable);
  }

  thenReturn() {
    return this.then((value) => value);
  }
}
Solution · python
from functools import reduce
from typing import Any, Callable


class Pipeline:
    def __init__(self) -> None:
        self.passable: Any = None
        self.pipes: list[Callable[[Any, Callable[[Any], Any]], Any]] = []

    def send(self, passable: Any) -> "Pipeline":
        self.passable = passable
        return self

    def through(self, pipes: list) -> "Pipeline":
        self.pipes = list(pipes)
        return self

    def then(self, destination: Callable[[Any], Any]) -> Any:
        def wrap(next_fn, pipe):
            return lambda value: pipe(value, next_fn)

        run = reduce(wrap, reversed(self.pipes), destination)
        return run(self.passable)

    def then_return(self) -> Any:
        return self.then(lambda value: value)

More from Laravel