Laravel Pipeline Pattern Beyond Middleware | Mohamed Said        [  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png)   Mohamed Said Laravel Backend Engineer  ](https://msaied.com) [ Home ](https://msaied.com) [ Projects ](https://msaied.com/projects) [ Articles  ](https://msaied.com/articles) [ Certificates ](https://msaied.com/certificates) [ Contact ](https://msaied.com#contact-section) 

       [  ](https://github.com/EG-Mohamed)       

 [ Home ](https://msaied.com) [ Projects ](https://msaied.com/projects) [ Articles ](https://msaied.com/articles) [ Certificates ](https://msaied.com/certificates) [ Contact ](https://msaied.com#contact-section) 

  [ home ](https://msaied.com)    [ articles ](https://msaied.com/articles)    Laravel Pipeline Pattern: Building Custom Pipelines Beyond Middleware        On this page       1. [  The Pipeline Pattern Beyond Middleware ](#the-pipeline-pattern-beyond-middleware)
2. [  Why Reach for a Pipeline? ](#why-reach-for-a-pipeline)
3. [  Defining a Typed Payload ](#defining-a-typed-payload)
4. [  Writing Stages ](#writing-stages)
5. [  Assembling the Pipeline ](#assembling-the-pipeline)
6. [  Short-Circuiting a Stage ](#short-circuiting-a-stage)
7. [  Testing Stages in Isolation with Pest ](#testing-stages-in-isolation-with-pest)
8. [  Registering Stages via the Container ](#registering-stages-via-the-container)
9. [  Takeaways ](#takeaways)

  ![Laravel Pipeline Pattern: Building Custom Pipelines Beyond Middleware](https://cdn.msaied.com/499/6113dcde1518951ad514082a3699232a.png)

  #laravel   #design-patterns   #architecture   #testing  

 Laravel Pipeline Pattern: Building Custom Pipelines Beyond Middleware 
=======================================================================

     2 Aug 2026      3 min read    ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said  

       Table of contents

  9 sections  

1. [  01   The Pipeline Pattern Beyond Middleware  ](#the-pipeline-pattern-beyond-middleware)
2. [  02   Why Reach for a Pipeline?  ](#why-reach-for-a-pipeline)
3. [  03   Defining a Typed Payload  ](#defining-a-typed-payload)
4. [  04   Writing Stages  ](#writing-stages)
5. [  05   Assembling the Pipeline  ](#assembling-the-pipeline)
6. [  06   Short-Circuiting a Stage  ](#short-circuiting-a-stage)
7. [  07   Testing Stages in Isolation with Pest  ](#testing-stages-in-isolation-with-pest)
8. [  08   Registering Stages via the Container  ](#registering-stages-via-the-container)
9. [  09   Takeaways  ](#takeaways)

       The Pipeline Pattern Beyond Middleware
--------------------------------------

Most Laravel developers know `Pipeline` from HTTP middleware, but the same `Illuminate\Pipeline\Pipeline` class is available anywhere in your application. Used deliberately, it replaces tangled service methods with a clean, composable chain of single-responsibility stages.

### Why Reach for a Pipeline?

Consider an order-submission workflow: validate inventory, apply discount rules, charge payment, dispatch fulfilment, send confirmation. Stuffing all of that into an `OrderService::submit()` method creates a god-object. A pipeline turns each concern into its own class, testable in isolation.

### Defining a Typed Payload

Start with a readonly DTO so every stage shares a common contract:

```php
readonly class OrderContext
{
    public function __construct(
        public Order $order,
        public ?Discount $discount = null,
        public bool $paymentCaptured = false,
    ) {}

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

    public function markPaymentCaptured(): self
    {
        return new self($this->order, $this->discount, true);
    }
}

```

Immutable DTOs prevent stages from silently mutating shared state — a common bug in mutable pipeline payloads.

### Writing Stages

Each stage receives the payload and a `$next` closure:

```php
final class ApplyDiscountStage
{
    public function __construct(
        private readonly DiscountResolver $resolver,
    ) {}

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

        return $next($context->withDiscount($discount));
    }
}

```

```php
final class CapturePaymentStage
{
    public function __construct(
        private readonly PaymentGateway $gateway,
    ) {}

    public function handle(OrderContext $context, Closure $next): OrderContext
    {
        $this->gateway->capture($context->order, $context->discount);

        return $next($context->markPaymentCaptured());
    }
}

```

Note the explicit return type on `handle`. Laravel's pipeline calls `handle` by convention, but you can customise the method name via `->via('process')` if you prefer.

### Assembling the Pipeline

```php
final class SubmitOrderAction
{
    public function __construct(
        private readonly Pipeline $pipeline,
    ) {}

    public function execute(Order $order): OrderContext
    {
        return $this->pipeline
            ->send(new OrderContext($order))
            ->through([
                ValidateInventoryStage::class,
                ApplyDiscountStage::class,
                CapturePaymentStage::class,
                DispatchFulfilmentStage::class,
                SendConfirmationStage::class,
            ])
            ->thenReturn();
    }
}

```

`thenReturn()` returns the final payload. Use `->then(fn ($ctx) => ...)` when you need a different return value at the end.

### Short-Circuiting a Stage

Sometimes a stage should halt the chain — for example, if inventory is unavailable:

```php
final class ValidateInventoryStage
{
    public function handle(OrderContext $context, Closure $next): OrderContext
    {
        if (! $context->order->hasStock()) {
            throw new InsufficientStockException($context->order);
        }

        return $next($context);
    }
}

```

Throw a domain exception rather than returning early without calling `$next`. This keeps the pipeline contract honest and lets callers handle failures uniformly.

### Testing Stages in Isolation with Pest

```php
it('applies a discount when one is available', function () {
    $resolver = Mockery::mock(DiscountResolver::class);
    $resolver->expects('forOrder')->andReturn(new Discount(10));

    $stage = new ApplyDiscountStage($resolver);
    $order = Order::factory()->make();
    $context = new OrderContext($order);

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

    expect($result->discount)->toBeInstanceOf(Discount::class)
        ->and($result->discount->percentage)->toBe(10);
});

```

Because each stage is a plain class with constructor injection, you never need to boot the full application to test it.

### Registering Stages via the Container

Laravel resolves stage class names through the service container, so constructor dependencies are injected automatically. If a stage needs contextual binding, register it in a service provider:

```php
$this->app->when(CapturePaymentStage::class)
    ->needs(PaymentGateway::class)
    ->give(StripeGateway::class);

```

### Takeaways

- Use readonly DTOs as pipeline payloads to prevent silent mutation between stages.
- Each stage is a single-responsibility class — test it without booting the framework.
- Throw domain exceptions to short-circuit; never silently skip `$next`.
- Constructor injection works automatically because Laravel resolves stages via the container.
- `->via('process')` lets you rename the handler method for semantic clarity.
- Pipelines compose naturally with actions, making complex workflows readable and auditable.

 Found this useful?

          [  ](https://twitter.com/intent/tweet?url=https%3A%2F%2Fmsaied.com%2Farticles%2Flaravel-pipeline-pattern-building-custom-pipelines-beyond-middleware-2&text=Laravel+Pipeline+Pattern%3A+Building+Custom+Pipelines+Beyond+Middleware) [  ](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fmsaied.com%2Farticles%2Flaravel-pipeline-pattern-building-custom-pipelines-beyond-middleware-2) 

 Frequently Asked Questions 
----------------------------

  3 questions  

     Q01  Can I reuse the same Pipeline instance across multiple requests in Octane?        No. Bind `Pipeline` as a transient (non-singleton) or resolve it fresh each time via `app(Pipeline::class)`. A shared instance retains the previous payload and stages between requests, causing subtle bugs under Octane's persistent worker model. 

      Q02  What is the difference between `thenReturn()` and `then()`?        `thenReturn()` is syntactic sugar for `-&gt;then(fn ($payload) =&gt; $payload)` — it simply returns the final payload unchanged. Use `-&gt;then(Closure $destination)` when you need to transform or persist the result after all stages have run. 

      Q03  Should pipeline stages be final classes?        Marking them `final` is a good default. Stages represent a single, concrete behaviour; allowing inheritance invites accidental overrides that break the pipeline contract. If you need variation, compose a new stage rather than extending an existing one. 

  Continue reading

 More Articles 
---------------

 [ View all    ](https://msaied.com/articles) 

 [ ![PhpStorm 2026.2 Released: Laravel Tool Window, PHP 8.5 Pipe Operator, and AI Agents](https://cdn.msaied.com/505/151a0bba66cc27064e090e69e55d7c92.png) PhpStorm JetBrains PHP 8.5 

### PhpStorm 2026.2 Released: Laravel Tool Window, PHP 8.5 Pipe Operator, and AI Agents

PhpStorm 2026.2 ships a dedicated Laravel tool window with Artisan, error logs, and Laravel Cloud tabs, plus P...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 3 Aug 2026     3 min read  

  Read    

 ](https://msaied.com/articles/phpstorm-20262-released-laravel-tool-window-php-85-pipe-operator-and-ai-agents) [ ![Laravel Doctor: Diagnose Your Laravel App With One Artisan Command](https://cdn.msaied.com/504/d72224689abc7b396bce187535008272.png) Laravel Artisan Health Checks 

### Laravel Doctor: Diagnose Your Laravel App With One Artisan Command

Laravel Doctor is a first-party package announced at Laracon US 2026 that adds an `artisan doctor` command to...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 3 Aug 2026     4 min read  

  Read    

 ](https://msaied.com/articles/laravel-doctor-diagnose-your-laravel-app-with-one-artisan-command) [ ![Livewire v4.3.5 Released: Fix for SFC Detection with PHP Attribute Array Arguments](https://cdn.msaied.com/503/9678ed8dbf5d7a6f4f19ca7694cf241b.png) Livewire Laravel PHP 

### Livewire v4.3.5 Released: Fix for SFC Detection with PHP Attribute Array Arguments

Livewire v4.3.5 ships a targeted bug fix for Single File Component (SFC) detection when PHP attributes contain...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 3 Aug 2026     2 min read  

  Read    

 ](https://msaied.com/articles/livewire-v435-released-fix-for-sfc-detection-with-php-attribute-array-arguments) 

   [  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png)   Mohamed Said Laravel Backend Engineer  ](https://msaied.com)Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

Explore

- [Home](https://msaied.com)
- [Projects](https://msaied.com/projects)
- [Articles](https://msaied.com/articles)
- [Certificates](https://msaied.com/certificates)
- [Contact](https://msaied.com#contact-section)

Connect

- [   hello@msaied.com ](mailto:hello@msaied.com)
- [   +20 109 461 9204 ](tel:+201094619204)

© 2026 Mohamed Said. All rights reserved.

 [  ](https://github.com/EG-Mohamed) [  ](https://www.linkedin.com/in/msaiedm/) [  ](https://wa.me/201094619204) [  ](mailto:hello@msaied.com) [  ](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)
