Laravel: When to use Pipelines? How to test them?
Clean and testable code, and even order-free!
Most of you have been confronted with a series of steps or “actions” that must run in order to accomplish one single task. This is usually normal in the world of computing, and it’s no shame to have a method somewhere calling other methods in succession.
The thing about this approach is that it can quickly become out of control, and it becomes hard to test, let alone skip, a single action.
As a postmortem about my Pipelines series, I wanted to expand a little bit about how Pipelines offer the same functionality minus the spaghetti code, plus the flexibility for testing the pipeline partially, totally, and even single pipes on their own.
I use Pipelines in my on-premises Subscription package extensively. When the developer wants to subscribe an user to a Plan, there is a series of steps that must run in order, and that require multiple dependencies — in some cases, call other Pipelines.
GitHub - Laragear/SubscriptionsDemo: Subscriptions on-premises, without any payment system! [README…
Manage subscriptions on-premises, without any payment systems! Become a Sponsor and get instant access to this package…
While these actions can be created in one single class file, where one method is responsible of calling each step in order, a Pipeline will offer the same functionality while keeping your code cleaner and more testable.
When should I use a Pipeline?
If you want a simple reason, here is it:
When you have multiple steps to accomplish a single task, and you need to prepare, test or mock each step.
A single method calling two or three actions may be not good candidate for a Pipeline, but the more you add, the more you may think your code is become bloated and unexpressive.
Let’s say I have a Podcast application where users can subscribe to Podcasts that are both free and paid. Suddenly, an user wants to subscribe to a Podcast that costs $9.99 monthly. These are the actions I have to run in order to complete that particular task:
- Retrieve the Podcast.
- Check the payment is enough to subscribe to the Podcast.
- Check the User has not already subscribed to the Podcast.
- Confirm the payment.
- Attach the User to the Podcast through a Subscription pivot table.
- Set the time the Subscription will end.
- Send an email with the successful subscription information.
I could make a class called “SubscriptionProcedure” and a
subscribe() method that would execute all of the steps necessary to accomplish that single task.
This will work, but it can be better. Instead of shoving each variable back and forth inside a method, which is already difficult to test by itself, we can use a Pipeline class. The Pipeline will have a list of actions (called pipes), each one with its own responsibility, dependencies and procedures.
The first step is to create a class that extends the Pipeline class included in Laravel, and add a list of pipes with the name of each action in the
$pipes array. I usually use the
Pipes subdirectory to keep them in one place.
Next, we need to create the object that will be shared across all pipes. In our case, a class called “SubscribeProcedure” is enough, and with it, we will declare some public properties that will hold the information of the whole procedure like the User and the Payment, while keeping placeholders for the Podcast and the the Subscription.
Finally, we need to setup our Pipe classes. Since the Pipeline invokes
handle() by default, all of our pipelines should have that public method declared, and these should accept a
SubscribeProcedure instance as first argument.
If you’re wondering why it looks like a Middleware, is because Pipelines are also used by the Router in Laravel.
We would do this
handle() with all other Pipes, each doing its own job to accomplish its own action. Since the Pipeline resolves each class using the Service Container, we can use Dependency Injection to resolve other object instances when the pipe is instanced. For example, we could inject a custom date calculator.
Finally, once all our Pipes are ready, we can safely call the Pipeline with our data through the
app() global helper, call the
send() method with the object data, and run the pipeline using
The Pipeline class allows for more flexibility. You can even replace the whole list of pipes using
through(), but I’m sure that most of the time you will want to add something to the list using
Both of these operations doesn’t require to change the source code of the pipe, and can run programmatically at runtime.
The next step is testing that each pipe works as expected.
Testing a Pipe
Luckily for us, since each Pipe is just a class, we can separate a single pipe from the rest and test it in isolation. Also, not only that, but we can also mock Facades or replace injected services.
For example, we can test the
ConfirmPayment pipe if it actually calls the Payment Processor, without knowing if it was through a facade or using Dependency Injection. We only need to resolve it through the Service Container and call
We could do this for each pipe, but this kind of technique is mostly needed for these pipes that can seem problematic and you need to ensure everything always goes as expected.
For those other times where you will be testing the whole pipeline, things can get interesting.
Testing the whole Pipeline
One of the advantages of using a Pipeline is that we can mock a pipe to skip its logic, or even change the outcome of that pipe and see how the rest of the Pipeline behaves.
For example, let’s start with an easy “skip”. We won’t want to send an email, because doing so means to set up some logic. Using mocking we can just make it to only return the data as it comes:
Using the same technique of a naive function, we can throw a monkey wrench and check how the Pipeline handles an “unexpected” scenario. Following the same example, let’s say that for some weird reason the Payment Processor couldn’t confirm the payment.
Usually Pipelines are meant to be tested as whole, but sometimes it’s just easier for other secondary tests to use “mocked” pipes with no-logic to avoid setting them up every single time, or worse, make the test slower. Think about how costly is to deal with fake storage, external HTTP requests, or complex SQL queries.
We can also check that other steps didn’t run when another pipes throws an exception. This is useful if you expect that pipes change order, but not expect that one pipe runs before another.
For example, if the
CheckUserCanPayPodcast returns an exception, no subscription should be created. This ensures the check is always done before the user is attached to the subscription, and nobody makes a PR to your project without passing this test.
These are just some bare examples of how we can use Pipelines to our advantage to keep our code clean, and most importantly, testable in multiple conditions.
You shouldn’t be afraid to use them, specially when you have to deal with business logic that sometimes is the core of your application, like I do in my on-premises Subscription package.