Skip to content

The Pipeline Pattern in Laravel: Building Custom Pipelines Beyond Middleware

Laravel's Pipeline class powers middleware, but it's equally powerful for domain workflows. Learn how to build typed, testable pipelines for complex business logic without coupling to HTTP.

Published · Updated · 3 min read

The Pipeline Pattern in Laravel: Building Custom Pipelines Beyond Middleware

The Pipeline Pattern in Laravel: Beyond Middleware

Most Laravel developers know Pipeline as the engine behind HTTP middleware. Fewer realise it's a first-class citizen available anywhere in your application — and that it's one of the cleanest ways to model sequential, composable domain workflows.

This article shows how to build typed, testable pipelines for real business logic: order processing, document transformation, multi-step validation, and more.


What the Pipeline Class Actually Does

Illuminate\Pipeline\Pipeline passes a payload through an ordered list of pipes. Each pipe receives the payload and a $next closure. It can mutate the payload, short-circuit the chain, or simply delegate.

use Illuminate\Pipeline\Pipeline;

$result = app(Pipeline::class)
    ->send($payload)
    ->through([
        StageOne::class,
        StageTwo::class,
        StageThree::class,
    ])
    ->thenReturn();

The default method called on each pipe is handle. You can override this:

->via('process')

Typing Your Payload with a DTO

Untyped arrays as payloads are a maintenance trap. Use a readonly DTO:

final readonly class OrderContext
{
    public function __construct(
        public Order $order,
        public ?Discount $discount = null,
        public bool $inventoryReserved = false,
        public bool $notificationSent = false,
    ) {}

    public function withDiscount(Discount $discount): self
    {
        return new self(
            order: $this->order,
            discount: $discount,
            inventoryReserved: $this->inventoryReserved,
            notificationSent: $this->notificationSent,
        );
    }
}

Each stage returns a new OrderContext rather than mutating state — immutability makes stages trivially testable in isolation.


Writing a Typed Stage

final class ApplyDiscountStage
{
    public function __construct(
        private readonly DiscountRepository $discounts,
    ) {}

    public function handle(OrderContext $context, Closure $next): OrderContext
    {
        $discount = $this->discounts->findForOrder($context->order);

        $context = $discount
            ? $context->withDiscount($discount)
            : $context;

        return $next($context);
    }
}

The container resolves constructor dependencies automatically, so ApplyDiscountStage is fully injectable without any extra wiring.


Conditional Pipes and Runtime Composition

Pipelines shine when stage lists are dynamic:

$pipes = [
    ValidateInventoryStage::class,
    ApplyDiscountStage::class,
];

if ($context->order->requiresExportCompliance()) {
    $pipes[] = ExportComplianceCheckStage::class;
}

$pipes[] = ReserveInventoryStage::class;
$pipes[] = SendConfirmationStage::class;

$result = app(Pipeline::class)
    ->send($context)
    ->through($pipes)
    ->thenReturn();

This is far cleaner than a chain of if blocks or a bloated service method.


Handling Failures Cleanly

Short-circuit by throwing a domain exception inside a stage:

final class ValidateInventoryStage
{
    public function handle(OrderContext $context, Closure $next): OrderContext
    {
        if (! $this->inventory->isAvailable($context->order)) {
            throw new InsufficientInventoryException($context->order->id);
        }

        return $next($context);
    }
}

Wrap the pipeline call in a try/catch at the application layer. The pipeline itself stays free of error-handling noise.


Testing a Stage in Isolation

Because each stage is a plain class with a single handle method, Pest tests are minimal:

it('applies a discount when one exists', function () {
    $order = Order::factory()->make();
    $discount = new Discount(percentage: 10);

    $repo = Mockery::mock(DiscountRepository::class);
    $repo->shouldReceive('findForOrder')->once()->andReturn($discount);

    $stage = new ApplyDiscountStage($repo);
    $context = new OrderContext(order: $order);

    $result = $stage->handle($context, fn ($ctx) => $ctx);

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

No HTTP, no database, no Kernel bootstrapping.


Takeaways

  • Illuminate\Pipeline\Pipeline is not HTTP-specific — use it anywhere sequential, composable logic is needed.
  • Readonly DTOs as payloads give you immutability and IDE autocompletion throughout the chain.
  • Stages are container-resolved classes: inject repositories, services, and config freely.
  • Dynamic pipe arrays let you compose workflows at runtime based on domain rules.
  • Each stage is independently unit-testable with zero framework overhead.

Frequently asked questions

Can I reuse the same Pipeline instance across multiple requests in Octane?

No. Resolve a fresh Pipeline via `app(Pipeline::class)` or `new Pipeline(app())` per invocation. Storing a Pipeline instance as a singleton risks payload leakage between requests under Octane's persistent worker model.

How is this different from using a chain of Action classes?

Action chains require explicit orchestration code that calls each action in sequence. A Pipeline centralises that orchestration, supports dynamic stage lists, and provides a consistent short-circuit mechanism via exceptions or by not calling `$next`.

Can pipes be closures instead of classes?

Yes. The Pipeline accepts any callable, including closures. Classes are preferred for named, reusable, injectable stages; closures are fine for one-off transformations in tests or quick prototypes.

Mohamed Said

About the author

Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

About GitHub LinkedIn WhatsApp Email Address My CV

Have a technical challenge?

Tell me what you’re building. I reply within two working days.

Discuss your project