Laravel Pipelines The hidden Class We've been sleeping on.
7 minutes
I'll be honest with you - we've been using Laravel for over a decade, and it's only the last year or so that we've properly discovered the Pipeline Class. I know, I know, it's been sitting there in the documentation this whole time, but somehow we just never gave it the attention it deserved.
We've all been there staring down the start of a code refactor looking at a bunch of messy logic thats in a codebase you've inherited. The kind of logic thats been thrown into a single controller method which ends up being several hundred lines long, nested if statements the works.
Typically we would start to look at moving what we can into service classes or look at what can be made into action based classes to start to bring down that line length, but after a quick discussion in the slack tech questions channel about any other decent approaches one of the team mentioned pipelines and how powerful they could be for this refactor.
The Problem We Were Trying to Solve
For this post we've swapped out the actual client code for something a bit less trivial but still something that is basically a great example of code that expands over time:
Validate the order data
Apply any active discount codes
Calculate shipping costs
Add applicable taxes
Check inventory levels
Apply any customer loyalty points
Send notifications to relevant parties
Our example pre pipeline code looks something like this:
1public function processOrder(Request $request) 2{ 3 $order = Order::find($request->order_id); 4 5 // Validate order 6 if (!$this->validateOrder($order)) { 7 return response()->json(['error' => 'Invalid order'], 422); 8 } 9 10 // Apply discounts11 if ($order->discount_code) {12 $order = $this->applyDiscounts($order);13 }14 15 // Calculate shipping16 $order->shipping_cost = $this->calculateShipping($order);17 18 // Add taxes19 $order->tax = $this->calculateTax($order);20 21 // Check inventory22 if (!$this->checkInventory($order)) {23 return response()->json(['error' => 'Insufficient inventory'], 422);24 }25 26 // Apply loyalty points27 if ($order->customer->has_loyalty_card) {28 $order = $this->applyLoyaltyPoints($order);29 }30 31 // Send notifications32 $this->sendNotifications($order);33 34 $order->save();35 36 return response()->json($order);37}
It works, but bloody hell, look at that controller! It knows way too much about the business logic, it's hard to test individual pieces, and adding new steps means modifying this already complex method.
Enter Laravel Pipeline
This is where Laravel's Pipeline comes in. It's essentially a way to pass an object through a series of classes, where each class can modify the object before passing it to the next one. If you've ever used middleware, you already understand the concept - Pipeline is actually what powers Laravel's middleware system under the hood.
Here's what that same order processing looks like using Pipeline:
1use Illuminate\Support\Facades\Pipeline; 2 3public function processOrder(Request $request) 4{ 5 $order = Order::find($request->order_id); 6 7 return Pipeline::send($order) 8 ->through([ 9 ValidateOrder::class,10 ApplyDiscountCodes::class,11 CalculateShipping::class,12 CalculateTaxes::class,13 CheckInventory::class,14 ApplyLoyaltyPoints::class,15 SendNotifications::class,16 ])17 ->thenReturn();18}
Look at that! The controller is now just orchestrating the flow, not implementing the business logic. Each step is its own class, making it incredibly easy to test, modify, or reorder.
Building Pipeline Classes
Each class in the pipeline follows a simple pattern. They receive the data being passed through, do their thing, and pass it to the next class. Here's what our ApplyDiscountCodes
class looks like
1<?php 2 3namespace App\Pipelines\OrderProcessing; 4 5use App\Models\Order; 6use App\Services\DiscountService; 7use Closure; 8 9final class ApplyDiscountCodes10{11 public function __construct(12 private DiscountService $discountService13 ) {}14 15 public function handle(Order $order, Closure $next)16 {17 if ($order->discount_code) {18 $discount = $this->discountService->validate($order->discount_code);19 20 if ($discount && $discount->isValidForOrder($order)) {21 $order->discount_amount = $discount->calculateDiscount($order);22 $order->subtotal -= $order->discount_amount;23 $order->applied_discounts()->attach($discount);24 }25 }26 27 return $next($order);28 }29}
The real beauty here is that each pipeline class has a single responsibility. The ApplyDiscountCodes
class doesn't know or care about shipping calculations or inventory checks - it just applies discounts and moves on.
This also has another major benefit for us it makes it easier to test!!
Looking for help with a PHP Project?
Talk to us today about our Team Augmentation service to see how we can help you with your current PHP Project.
Testing Pipelines with Pest
Now, here's where pipelines really shine - testing. Each pipe can be tested in complete isolation. Here's how we test our discount pipeline using Pest:
1<?php 2 3use App\Models\Order; 4use App\Models\Discount; 5use App\Pipelines\OrderProcessing\ApplyDiscountCodes; 6use App\Services\DiscountService; 7 8beforeEach(function () { 9 $this->order = Order::factory()->create([10 'discount_code' => 'SUMMER20',11 'subtotal' => 100.00,12 ]);13 14 $this->discount = Discount::factory()->create([15 'code' => 'SUMMER20',16 'type' => 'percentage',17 'value' => 20,18 ]);19});20 21it('applies valid discount codes to orders', function () {22 $discountService = Mockery::mock(DiscountService::class);23 $discountService->shouldReceive('validate')24 ->with('SUMMER20')25 ->andReturn($this->discount);26 27 $pipeline = new ApplyDiscountCodes($discountService);28 29 $result = $pipeline->handle($this->order, function ($order) {30 return $order;31 });32 33 expect($result->discount_amount)->toBe(20.00);34 expect($result->subtotal)->toBe(80.00);35});36 37it('skips processing when no discount code is present', function () {38 $this->order->discount_code = null;39 40 $discountService = Mockery::mock(DiscountService::class);41 $discountService->shouldNotReceive('validate');42 43 $pipeline = new ApplyDiscountCodes($discountService);44 45 $result = $pipeline->handle($this->order, function ($order) {46 return $order;47 });48 49 expect($result->discount_amount)->toBeNull();50 expect($result->subtotal)->toBe(100.00);51});52 53it('handles invalid discount codes gracefully', function () {54 $discountService = Mockery::mock(DiscountService::class);55 $discountService->shouldReceive('validate')56 ->with('SUMMER20')57 ->andReturnNull();58 59 $pipeline = new ApplyDiscountCodes($discountService);60 61 $result = $pipeline->handle($this->order, function ($order) {62 return $order;63 });64 65 expect($result->discount_amount)->toBeNull();66 expect($result->subtotal)->toBe(100.00);67});
The beauty of this approach is that we can test each pipe independently without worrying about the entire pipeline chain. Want to test the whole pipeline? That's easy too:
1it('processes a complete order through the pipeline', function () { 2 $order = Order::factory()->create([ 3 'discount_code' => 'SAVE10', 4 'subtotal' => 100.00, 5 ]); 6 7 $result = Pipeline::send($order) 8 ->through([ 9 ValidateOrder::class,10 ApplyDiscountCodes::class,11 CalculateShipping::class,12 CalculateTaxes::class,13 ])14 ->thenReturn();15 16 expect($result)17 ->toBeInstanceOf(Order::class)18 ->discount_amount->not->toBeNull()19 ->shipping_cost->not->toBeNull()20 ->tax->not->toBeNull();21});
Advanced Pipeline Patterns
Once we got comfortable with the basics, we started discovering more powerful patterns. You can pass additional parameters to your pipeline classes:
1Pipeline::send($order)2 ->through([3 [ValidateOrder::class, 'strict' => true],4 [ApplyDiscountCodes::class, 'allow_stacking' => false],5 CalculateShipping::class,6 ])7 ->thenReturn();
And then access them in your pipeline class:
1class ValidateOrder 2{ 3 public function handle(Order $order, Closure $next, bool $strict = false) 4 { 5 if ($strict) { 6 // Perform strict validation 7 if (!$order->hasCompleteAddress()) { 8 throw new IncompleteOrderException('Address information incomplete'); 9 }10 }11 12 // Regular validation continues...13 14 return $next($order);15 }16}
We've also found conditional pipes incredibly useful when dealing with certain things, for instance if an order is international you might want to apply a different shipping process to the order or as the example here shows if the order is a gift you might want to do something specific to it.
1Pipeline::send($order) 2 ->through([ 3 ValidateOrder::class, 4 ApplyDiscountCodes::class, 5 ]) 6 ->when($order->is_international, function ($pipeline) { 7 return $pipeline->pipe(InternationalShippingCalculator::class) 8 ->pipe(CustomsDuties::class); 9 })10 ->when($order->is_gift, function ($pipeline) {11 return $pipeline->pipe(GiftWrapping::class)12 ->pipe(RemovePriceTagsFromInvoice::class);13 })14 ->then(function ($order) {15 $order->save();16 });
When NOT to Use Pipelines
Right, let's have an honest conversation about when pipelines might not be the best tool for the job. Pipelines work brilliantly when you have a clear, linear flow of data transformation. But they can become a nightmare when:
1. You Need Complex Branching Logic
If your process has lots of conditional paths that depend on the results of previous steps, pipelines can get messy:
1// This gets ugly fast 2Pipeline::send($data) 3 ->through([ 4 StepOne::class, 5 ]) 6 ->then(function ($result) { 7 if ($result->needsPath()) { 8 return Pipeline::send($result) 9 ->through([PathA::class])10 ->thenReturn();11 } else {12 return Pipeline::send($result)13 ->through([PathB::class, PathC::class])14 ->thenReturn();15 }16 });
In these cases, a service class with explicit method calls might be clearer and a lot easier to maintain in the long run.
2. When You Need to Return Multiple Values
Pipelines work on a single object passing through. If you need to collect results from multiple steps and return them all, it gets awkward and can get messy when you're constantly adding new data to the object for instance. As an example here is a pipeline class thats adding new metrics to the data object thats provided to it.
1// You end up doing weird stuff like this 2final class CollectMetrics 3{ 4 public function handle($data, Closure $next) 5 { 6 // Attaching extra data feels wrong 7 $data->metrics = [ 8 'processing_time' => now(), 9 'memory_usage' => memory_get_usage(),10 ];11 12 return $next($data);13 }14}
Yes this does work but it can really make it complicated to track things over the life of the pipeline if each class is adding new attributes to an object and not just modifying or inspecting the object provided to it.
3. Simple Operations
For dead simple operations, pipelines are overkill. We once saw someone use a pipeline for this:
1// Please don't do this 2Pipeline::send($user) 3 ->through([ 4 UpdateName::class, 5 UpdateEmail::class, 6 ]) 7 ->thenReturn(); 8 9// When this would suffice10$user->update($request->validated());
Performance Considerations
One concern we had was performance. Are we adding overhead by using pipelines? After some benchmarking on a recent project, we found the overhead to be negligible - we're talking microseconds. The clarity and testability benefits far outweigh any minimal performance impact.
That said, if you're processing thousands of items in a loop, you might want to instantiate your pipeline once and reuse it:
1// Instead of this 2foreach ($orders as $order) { 3 Pipeline::send($order)->through([...])->thenReturn(); 4} 5 6// Consider this 7$pipeline = Pipeline::through([ 8 ProcessStep::class, 9 AnotherStep::class,10]);11 12foreach ($orders as $order) {13 $pipeline->send($order)->thenReturn();14}
Our Pipeline Best Practices
After using pipelines in production for a few months now, here's what we've learned:
Keep pipes focused - Each pipe should do one thing well
Name them clearly -
ApplyDiscountCodes
is better thanDiscountPipe
Use dependency injection - Let Laravel's container handle your dependencies
Document the expected input/output - Future you will thank present you
Consider the order - Some operations naturally need to happen before others
Don't force it - If it feels awkward, maybe pipelines aren't the right tool
Summary
Laravel's Pipeline has become one of our favourite tools for handling complex data processing flows. It's transformed messy controllers into clean, testable, and maintainable code. The learning curve is minimal - if you understand middleware, you already understand pipelines.
The real power comes from the testability and reusability.
Is it perfect for everything? No. But for linear data transformation and processing chains, it's bloody brilliant. We're kicking ourselves for not discovering it sooner.
What's Your Experience?
Have you been using Laravel's Pipeline in your projects? We'd love to hear about the creative ways you're implementing them, or any gotchas you've discovered that we haven't mentioned here. Drop us a message or share your pipeline patterns - we're always looking to learn from the community's experiences.
And if you haven't tried pipelines yet, give them a go on your next refactoring task. You might just find yourself wondering, like we did, how you ever lived without them.
Bonus
No matter how great the pipeline class is and how many pipes you create we as developers know that things can go wrong and there would be nothing worse than an object going through the pipeline that somehow has caused an exception in your code. So now you have an object thats been partially run through the pipeline and could be in quite a horrible state.
There are a number of ways to defensively code around this to make sure it doesnt happen or you could use the handy method withTransaction()
which wraps every step in a single database transaction meaning if something does go wrong then things will be reverted :)
1$user = Pipeline::send($user)2 ->withinTransaction()3 ->through([4 ProcessOrder::class,5 TransferFunds::class,6 UpdateInventory::class,7 ])8 ->thenReturn();