Introduction
When building web applications, it's handy to break down a feature's complex processes into smaller, more manageable pieces, whether by using separate functions or classes. Doing this helps to keep the code clean, maintainable, and testable.
An approach you can take to split out these smaller steps in your Laravel application is to use Laravel pipelines. Pipelines allow you to send data through multiple layers of logic before returning a result. In fact, Laravel actually uses pipelines internally to handle requests and pass them through each of your application's middleware (we'll take a look at this later).
In this article, we'll take a look at what pipelines are and how Laravel uses them internally, and we'll show you how to create your own pipelines. We'll also cover how to write tests to ensure your pipelines work as expected.
By the end of the article, you should understand what pipelines are and have the confidence to use them in your Laravel applications.
What is the Laravel Pipeline class?
According to the Laravel documentation, pipelines provide "a convenient way to pipe a given input through a series of invokable classes, closures, or callables, giving each class the opportunity to inspect or modify the input and invoke the next callable in the pipeline."
But what does this mean? To get an understanding of what Laravel pipelines are, let's take a look at a basic example.
Imagine you're building a blogging platform, and you're currently writing the feature to allow users to comment on blog posts. You might want to follow these steps when a user submits a comment before we store it in the database:
- Check the comment for any profanity. If any exists, replace the profanity with asterisks (*).
- Check for any spam in the comment (such as links to malicious external websites) and remove it.
- Remove any harmful content from the comment.
Each of these steps can be considered a stage in the pipeline. The comment data is passed through each stage, and each stage performs a specific task before passing the modified data to the next stage. By the time the comment comes out on the other side of the pipeline, it should have had all the necessary checks and modifications applied to it.
If we used Laravel's pipeline feature to implement this, it might look something like this:
1use App\Pipelines\Comments\RemoveProfanity; 2use App\Pipelines\Comments\RemoveSpam; 3use App\Pipelines\Comments\RemoveHarmfulContent; 4use Illuminate\Pipeline\Pipeline; 5 6// Grab the comment from the incoming request 7$commentText = $request->validated('comment') 8 9// Pass the comment string through the pipeline.10// $comment will be equal to a string with all the checks and modifications applied.11$comment = app(Pipeline::class)12 ->send($commentText)13 ->through([14 RemoveProfanity::class,15 RemoveSpam::class,16 RemoveHarmfulContent::class,17 ])18 ->thenReturn();
In the example above, we're starting by grabbing the initial data (in this case, the comment field from the request) and then passing it through each of the three stages. But what does each stage look like? Let's take a look at an example of what a very basic RemoveProfanity
stage might look like:
app/Pipelines/Comments/RemoveProfanity
1declare(strict_types=1);23namespace App\Pipelines\Comments;45use Closure;67final readonly class RemoveProfanity8{9 private const array PROFANITIES = [10 'fiddlesticks',11 'another curse word'12 ];1314 public function __invoke(string $comment, Closure $next): string15 {16 $comment = str_replace(17 search: self::PROFANITIES,18 replace: '****',19 subject: $comment,20 );2122 return $next($comment);23 }24}
In the RemoveProfanity
class above, we can see that the class has an __invoke
method that accepts two arguments:
-
$comment
- This is the comment itself from which we want to remove any profanity. It may have been passed in at the beginning of the pipeline, or it may have been modified by a previous stage (theRemoveProfanity
class does not know or care about this). -
$next
- This second parameter is a closure that represents the next stage in the pipeline. When we call$next($comment)
, we're passing the modified comment (with the profanity removed) to the next stage in the pipeline.
As a side note, we're using a hardcoded array to identify and remove any profanity. Of course, in a real-world application, you'd likely have a more sophisticated way of determining what is profanity. But this is just a simple example to demonstrate what a stage in the pipeline might look like.
You might have noticed that this class looks a lot like a middleware class. In fact, each stage in the pipeline looks a lot like a middleware class that you might typically write. This is because middleware classes are essentially pipeline stages. Laravel uses the Illuminate\Pipeline\Pipeline
class to handle HTTP requests and pass them through each of your application's middleware classes. We'll delve into this in more detail soon.
The beauty of using Laravel pipelines for handling this type of workflow is that each stage can be isolated and specifically designed to do one thing. This makes them much easier to unit test and maintain. As a result, Laravel pipelines provide a flexible and customizable architecture that you can use to perform multiple operations in order. Suppose you discover that a certain curse word isn't being removed by the profanity filter. Instead of having to dig through a large service class trying to find the specific lines of code related to the profanity filter, you can look at the RemoveProfanity
class and make the necessary changes there. This also makes it easy to write isolated tests for each stage in the pipeline.
Generally, each stage of the pipeline should have no knowledge of the other stages before or after it. This makes Laravel pipelines highly extendable because you can add a new stage to the pipeline without affecting the other stages. This makes pipelines such a powerful tool to have in your arsenal when building a Laravel project.
For example, let's say you wanted to add a new stage to the comment pipeline that shortens any URLs in the comment. For instance, you might want to do this so you can provide tracking statistics to authors on how many times a link has been clicked. You could do this without affecting the other stages in the pipeline by adding a new \App\Pipelines\Comments\ShortenUrls::class
:
1use App\Pipelines\Comments\RemoveProfanity; 2use App\Pipelines\Comments\RemoveSpam; 3use App\Pipelines\Comments\RemoveHarmfulContent; 4use App\Pipelines\Comments\ShortenUrls; 5use Illuminate\Pipeline\Pipeline; 6 7$commentText = $request()->validated('comment') 8 9$comment = app(Pipeline::class)10 ->send($commentText)11 ->through([12 RemoveProfanity::class,13 RemoveSpam::class,14 RemoveHarmfulContent::class,15 ShortenUrls::class,16 ])17 ->thenReturn();
How does Laravel use the Pipeline class?
Now that we have an understanding of what pipelines are and how they can be used to break complex operations down into smaller, more manageable pieces, let's take a look at how Laravel uses pipelines internally.
As we've already briefly mentioned, Laravel itself uses pipelines to handle requests and pass them through each of your application's middleware.
When Laravel handles a request, it passes the Illuminate\Http\Request
instance to the sendRequestThroughRouter
method in the \Illuminate\Foundation\Http\Kernel
class. This method sends the request through the global middleware, which all requests should pass through. This middleware is defined in the getGlobalMiddleware
method in the \Illuminate\Foundation\Configuration\Middleware
class and looks like this:
1public function getGlobalMiddleware() 2{ 3 $middleware = $this->global ?: array_values(array_filter([ 4 $this->trustHosts ? \Illuminate\Http\Middleware\TrustHosts::class : null, 5 \Illuminate\Http\Middleware\TrustProxies::class, 6 \Illuminate\Http\Middleware\HandleCors::class, 7 \Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance::class, 8 \Illuminate\Http\Middleware\ValidatePostSize::class, 9 \Illuminate\Foundation\Http\Middleware\TrimStrings::class,10 \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,11 ]));12 13 // The rest of the method...14}
The sendRequestThroughRouter
method (that contains the pipeline) looks like this:
1/** 2 * Send the given request through the middleware / router. 3 * 4 * @param \Illuminate\Http\Request $request 5 * @return \Illuminate\Http\Response 6 */ 7protected function sendRequestThroughRouter($request) 8{ 9 $this->app->instance('request', $request);10 11 Facade::clearResolvedInstance('request');12 13 $this->bootstrap();14 15 return (new Pipeline($this->app))16 ->send($request)17 ->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)18 ->then($this->dispatchToRouter());19}
In this method, after booting some necessary parts of the framework, the request is passed through a pipeline. Let's focus on what this pipeline is doing:
1return (new Pipeline($this->app))2 ->send($request)3 ->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)4 ->then($this->dispatchToRouter());
A pipeline is being created, and it's being instructed to send the Illuminate\Http\Request
object through each of the middleware (assuming that we're not skipping middleware, such as when running some tests). The then
method is then called so that we can grab the result of the pipeline (which should be a modified Illuminate\Http\Request
object) and pass it to the dispatchToRouter
method.
After the request has been passed through this pipeline to modify the request object, it's passed deeper into the framework for further handling. Along the way, the Illuminate\Http\Request
object will be passed through another pipeline in the runRouteWithinStack
method in the \Illuminate\Routing\Router
class. This method passes the request through the route-specific middleware, such as those defined on the route itself, the route group, and the controller:
1/** 2 * Run the given route within a Stack "onion" instance. 3 * 4 * @param \Illuminate\Routing\Route $route 5 * @param \Illuminate\Http\Request $request 6 * @return mixed 7 */ 8protected function runRouteWithinStack(Route $route, Request $request) 9{10 $shouldSkipMiddleware = $this->container->bound('middleware.disable') &&11 $this->container->make('middleware.disable') === true;12 13 $middleware = $shouldSkipMiddleware ? [] : $this->gatherRouteMiddleware($route);14 15 return (new Pipeline($this->container))16 ->send($request)17 ->through($middleware)18 ->then(fn ($request) => $this->prepareResponse(19 $request, $route->run()20 ));21}
In the method above, the request is passed through all the middleware that can be gathered for the given request's route. Following this, the final result is then passed to the prepareResponse
method, which will build the HTTP response that will be sent back to the user from the HTTP controller (as determined in the $route->run()
method).
I think being able to see Laravel using this feature internally is a great way to understand how powerful pipelines can be—especially because it's being used to deliver one of the fundamental parts of the request lifecycle. It's also a perfect example to show how you can build complex workflows which can be broken down into smaller, isolated pieces of logic that can be easily tested, maintained, and extended.
How to create Laravel pipelines
Now that we've seen how Laravel uses pipelines internally, let's take a look at how you can create your own pipelines.
We're going to build a very simple (and naive) pipeline that can be used as part of a commenting system for a blog. The pipeline will take a comment string and pass it through the following stages:
- Remove any profanity and replace it with asterisks.
- Remove any harmful content.
- Replace external links with a shortened URL.
We're not going to focus too much on the business logic used in each of these stages—we're more interested in how the pipeline works. However, we'll provide a simple example of what each stage might look like.
Of course, we don't want to use any actual curse words in the article, so we're going to assume that fiddlesticks
is a curse word. We're also going to deem the term I hate Laravel
as being harmful content.
So, if we pass the following comment through the pipeline:
1This is a comment with a link to https://honeybadger.io and some fiddlesticks. I hate Laravel!
We should expect something like this to come out the other side:
1This is a comment with a link to https://my-app.com/s/zN2g4 and some ****. ****
Let's first start by creating our three pipeline stages. We'll create a new app/Pipelines/Comments
directory and then create three new classes in there:
-
App\Pipelines\Comments\RemoveProfanity
-
App\Pipelines\Comments\RemoveHarmfulContent
-
App\Pipelines\Comments\ShortenUrls
We'll look at the RemoveProfanity
class first:
app/Pipelines/Comments/RemoveProfanity
1declare(strict_types=1);23namespace App\Pipelines\Comments;45use Closure;67final readonly class RemoveProfanity8{9 private const array PROFANITIES = [10 'fiddlesticks',11 'another curse word'12 ];1314 public function __invoke(string $comment, Closure $next): string15 {16 $comment = str_replace(17 search: self::PROFANITIES,18 replace: '****',19 subject: $comment,20 );2122 return $next($comment);23 }24}
In the RemoveProfanity
class above, we can see that the class has an __invoke
method (meaning it's an invokable class) that accepts two arguments:
-
$comment
- This is the comment itself from which we want to remove any profanity. -
$next
- This is a closure that represents the next stage in the pipeline. When we call$next($comment)
, we're passing the modified comment (with the profanity removed) to the next stage in the pipeline.
The RemoveProfanity
class replaces any profanity in the comment with asterisks and then passes the modified comment to the next stage in the pipeline, which is the RemoveHarmfulContent
class:
app/Pipelines/Comments/RemoveHarmfulContent.php
1declare(strict_types=1);23namespace App\Pipelines\Comments;45use Closure;67final readonly class RemoveHarmfulContent8{9 public function __invoke(string $comment, Closure $next): string10 {11 // Remove the harmful content from the comment.12 $comment = str_replace(13 search: $this->harmfulContent(),14 replace: '****',15 subject: $comment,16 );1718 return $next($comment);19 }2021 private function harmfulContent(): array22 {23 // Code goes here that determines what is harmful content.24 // Here's some hardcoded harmful content for now.2526 return [27 'I hate Laravel!',28 ];29 }30}
In the code example above, we can see that we're using a hardcoded array to identify and remove any harmful content. Of course, in a real-world application, you'd likely have a more sophisticated way of determining what is harmful content. The RemoveHarmfulContent
class then passes the modified comment to the next stage in the pipeline, which is the ShortenUrls
class:
app/Pipelines/Comments/ShortenUrls
1declare(strict_types=1);23namespace App\Pipelines\Comments;45use AshAllenDesign\ShortURL\Facades\ShortURL;6use Closure;78final readonly class ShortenUrls9{10 public function __invoke(string $comment, Closure $next): string11 {12 $urls = $this->findUrlsInComment($comment);1314 foreach ($urls as $url) {15 $shortUrl = ShortURL::destinationUrl($url)->make();1617 $comment = str_replace(18 search: $url,19 replace: $shortUrl->default_short_url,20 subject: $comment,21 );22 }2324 return $next($comment);25 }2627 private function findUrlsInComment(string $comment): array28 {29 // Code goes here to find all URLs in the comment and30 // return them in an array.3132 return [33 'https://honeybadger.io',34 ];35 }36}
In this invokable class, we're using my "ashallendesign/short-url
" Laravel package to shorten any URLs in the comments. To keep the example simple, we're using a hardcoded URL for now, but in a real-world application, you'd have a more sophisticated way of finding URLs in the comment, such as using regular expressions.
Now, let's tie all these classes together in a pipeline:
1use App\Pipelines\Comments\RemoveHarmfulContent; 2use App\Pipelines\Comments\RemoveProfanity; 3use App\Pipelines\Comments\ShortenUrls; 4use Illuminate\Pipeline\Pipeline; 5 6$comment = 'This is a comment with a link to https://honeybadger.io and some fiddlesticks. I hate Laravel!'; 7 8$modifiedComment = app(Pipeline::class) 9 ->send($comment)10 ->through([11 RemoveProfanity::class,12 RemoveHarmfulContent::class,13 ShortenUrls::class,14 ])15 ->thenReturn();
If we were to run the above code, we should expect the $modifiedComment
variable to contain the following string:
1This is a comment with a link to https://my-app.com/s/zN2g4 and some ****. ****
Conditionally running a stage in the pipeline
There may be times when you want to run a stage in the pipeline conditionally. For example, if the user leaving the comment is an admin, you might want to skip the ShortenUrls
stage.
There are two different ways that you can approach this. The first would be to use the pipe
method that's available on the Illuminate\Pipeline\Pipeline
class. This method allows you to push new stages onto the pipeline:
1$commentPipeline = app(Pipeline::class) 2 ->send($comment) 3 ->through([ 4 RemoveProfanity::class, 5 RemoveHarmfulContent::class, 6 ]); 7 8// If the user is an admin, we don't want to shorten the URLs. 9if (!auth()->user()->isAdmin()) {10 $commentPipeline->pipe(ShortenUrls::class);11}12 13$modifiedComment = $commentPipeline->thenReturn();
Similarly, you can use the when
method that's available on the Illuminate\Pipeline\Pipeline
class. This works the same as the "when"
method that you can use when building queries using the Eloquent query builder. It method allows you to run a stage in the pipeline conditionally:
1$modifiedComment = app(Pipeline::class) 2 ->send($comment) 3 ->through([ 4 RemoveProfanity::class, 5 RemoveHarmfulContent::class, 6 ]) 7 ->when( 8 value: !auth()->user()->isAdmin(), 9 callback: function (Pipeline $pipeline): void {10 $pipeline->pipe(ShortenUrls::class);11 }12 )13 ->thenReturn();
Both of the above code examples perform the same task. The ShortenUrls
stage is only run if the user is not an admin. In the code example above, if the first argument passed to the when
method is truthy, the callback will be executed. Otherwise, the callback will not be executed.
The difference between then
and thenReturn
As we've already covered, each stage in the pipeline returns $next(...)
so that we can pass the data to the next stage in the pipeline. But to run the pipeline and execute each of the tasks to get the result from the final stage, we need to use either the then
or thenReturn
methods. These are both very similar, but there is a small difference between the two.
As we've seen in our previous examples, the thenReturn
method runs the final callback that was returned from the last stage in the pipeline to get the result. For instance, in our previous examples, we were returning return $next($comment);
from each of the stages in the pipeline. So when we call thenReturn
it will return the value that was passed to the final callback (in this case, whatever $comment
is equal to).
However, the then
method is used to get the result from the final stage of the pipeline and then pass it to a callback. This is useful if you want to perform some extra logic on the result before returning it. If we look at our internal Laravel example from before, we can see that the then
method is used to pass the result of the pipeline to the dispatchToRouter
method in the (Illuminate\Foundation\Http\Kernel
class):
1return (new Pipeline($this->app))2 ->send($request)3 ->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)4 ->then($this->dispatchToRouter());
So Laravel is running the request through the global middleware. Then after the pipeline has finished, it passes the result to the dispatchToRouter
method, which will run any route-specific middleware, call the correct controller, and return the response. In this case, the dispatchToRouter
method looks like:
1/** 2 * Get the route dispatcher callback. 3 * 4 * @return \Closure 5 */ 6protected function dispatchToRouter() 7{ 8 return function ($request) { 9 $this->app->instance('request', $request);10 11 return $this->router->dispatch($request);12 };13}
As we can see, the method returns a closure that accepts a request object, dispatches it to the router, and then returns the result.
Using closures in Laravel pipelines
Although you may wish to keep the pipeline stages separated as classes, there may be times when you want to use a closure) instead. This can be useful for very simple tasks that don't warrant a whole class. As a very basic example, let's imagine you wanted to add a stage to the pipeline that converts all the comment text to uppercase; you could do so like this:
1$modifiedComment = app(Pipeline::class) 2 ->send($comment) 3 ->through([ 4 RemoveProfanity::class, 5 RemoveHarmfulContent::class, 6 ShortenUrls::class, 7 function (string $comment, Closure $next): string { 8 return $next(strtoupper($comment)); 9 },10 ])11 ->thenReturn();
In the above example, we've added a new stage to the pipeline that converts the comment text to uppercase. The has the same method signature as the __invoke
methods in the other classes we've been using and returns $next
so that we can continue with the pipeline.
As a side note, if you do opt towards using closures in your pipeline like this, you can lose some of the unit-testability of the class. We're going to cover testing in more depth later, but a huge benefit of using classes for each stage in the pipeline is that you can write isolated unit tests specifically for that stage. But with closures, you can't do this as easily. This might be a trade-off you're willing to make for very simple tasks if you have tests covering the pipeline as a whole.
Changing the method that's called using via
So far, we've made all our classes invokable by using the __invoke
method. However, this isn't the only option you can use on the pipeline stages.
In fact, by default, Laravel will actually attempt to call a handle
method on the pipeline classes. Then if the handle
method doesn't exist, it will attempt to invoke the class or callable.
Therefore, this means we could update the example pipeline method signatures from this:
1public function __invoke(string $comment, Closure $next): string
to this:
1public function handle(string $comment, Closure $next): string
And the pipeline would still work as expected.
However, if you'd prefer to use a different method name, you can use the via
method on the pipeline. For example, let's say you wanted your pipeline's classes to use an execute
method instead like so:
1public function execute(string $comment, Closure $next): string
When creating your pipeline, you can specify this using the via
method like so:
1$modifiedComment = app(Pipeline::class)2 ->send($comment)3 ->through([4 RemoveProfanity::class,5 RemoveHarmfulContent::class,6 ShortenUrls::class,7 ])8 ->via('execute')9 ->thenReturn();
How to write tests for Laravel pipelines and each layer
One of the great things about using Laravel pipelines is that they make it easy to write isolated tests for each stage in the Laravel pipeline. In my opinion, this helps you to build high-quality, focused layers that only do one thing, and do that one thing well. As a bonus, this helps you to write cleaner tests with code readability in mind.
Let's take our example pipeline from earlier and think about how we could test it.
Alongside using other code-quality tools (such as Larastan and PHP Insights), we could write some feature tests that check that the function using our pipeline runs as expected. After all, in our tests, we're not too interested in how we're achieving the content moderation feature. We want to ensure that the profanity was removed from the comment before storing it. A feature test can help us with this if we decide to refactor our code in the future.
However, as you can imagine, as the pipeline grows in complexity, the feature tests will also grow in complexity—especially if there is conditional logic—making it harder to debug when something goes wrong.
So, a great way to test different scenarios and use cases is to write more in-depth unit tests for each stage in the pipeline. This way, you can test each stage in isolation and ensure it works as expected without worrying about the other stages in the pipeline.
You may also want to consider writing Pest architecture tests to assert the structure of each stage in the pipeline. This can help to keep your codebase consistent.
Let's look at how we could write some tests for our pipeline example from earlier.
Testing a single stage in the pipeline
We'll start by looking at how to write a unit test for a single stage in the pipeline. Let's take the RemoveProfanity
class as an example. We'll create a new test file at tests/Unit/Pipelines/Comments/RemoveProfanityTest.php
and write a test that checks the profanity is removed from the comment:
test/Unit/Pipelines/Comments/RemoveProfanityTest
1declare(strict_types=1);23namespace Tests\Unit\Pipelines\Comments;45use App\Pipelines\Comments\RemoveProfanity;6use PHPUnit\Framework\Attributes\Test;7use PHPUnit\Framework\TestCase;89final class RemoveProfanityTest extends TestCase10{11 #[Test]12 public function profanity_is_removed(): void13 {14 $comment = 'fiddlesticks This is a comment with some fiddlesticks.';1516 $pipelineStage = new RemoveProfanity();1718 // Invoke the class (so the __invoke) method is called. Pass it19 // a callback that will return the comment as a string.20 $cleanedComment = $pipelineStage(21 comment: $comment,22 next: fn (string $comment): string => $comment23 );2425 // Check the profanity has been removed.26 $this->assertSame(27 '**** This is a comment with some ****.',28 $cleanedComment,29 );30 }31}
In the test above, we're creating a new instance of the RemoveProfanity
class and then invoking it. We're passing it a comment that contains profanity (this would be passed in at the beginning of the pipeline or from the previous stage) and a callback that will act as the next stage in the pipeline. The callback is set up to return the comment as a string so we can check that the profanity has been removed. We then use PHPUnit's assertSame
method to check that the profanity has been removed and give us confidence the stage works as expected.
We could then write similar tests for the RemoveHarmfulContent
and ShortenUrls
classes to ensure they're also working as intended.
Testing the pipeline as a whole
As we've already mentioned, writing tests for single stages can make it easier to debug and ensure that each stage is working. It also makes it much easier to write tests for different (or new) scenarios and use cases.
But knowing that separate stages work as expected doesn't necessarily mean that the pipeline as a whole works as expected. For example, we might accidentally remove a stage from the pipeline, or we might accidentally pass the wrong data to a stage. This is where feature tests come in.
Let's take a look at how we could write a feature test for our pipeline example from earlier. We'll assume that we've created a new App\Services\CommentService
class that uses the pipeline to process comments and then store them:
app/Services/CommentService.php
1declare(strict_types=1);23namespace App\Services;45use App\Models\Article;6use App\Models\Comment;7use App\Models\User;8use App\Pipelines\Comments\RemoveHarmfulContent;9use App\Pipelines\Comments\RemoveProfanity;10use App\Pipelines\Comments\ShortenUrls;11use Illuminate\Pipeline\Pipeline;1213final class CommentService14{15 public function storeComment(16 string $comment,17 Article $commentedOn,18 User $commentBy19 ): Comment {20 $comment = $this->prepareCommentForStoring($comment, $commentBy);2122 return $commentedOn->comments()->create([23 'comment' => $comment,24 'user_id' => $commentBy->id,25 ]);26 }2728 private function prepareCommentForStoring(string $comment, User $commentBy): string29 {30 $commentPipeline = app(Pipeline::class)31 ->send($comment)32 ->through([33 RemoveProfanity::class,34 RemoveHarmfulContent::class,35 ]);3637 // If the user is an admin, we don't want to shorten the URLs.38 if (!$commentBy->isAdmin()) {39 $commentPipeline->pipe(ShortenUrls::class);40 }4142 return $commentPipeline->thenReturn();43 }44}
As we can see in the service class above, we're using the pipeline to remove the profanity and harmful content from the comment. If the user is not an admin, we're also shortening any URLs in the comment. We're then storing the comment in the database.
From this code, we can see that there are two scenarios we need to test:
- A comment can be stored by a non-admin user and the URLs are shortened.
- A comment can be stored by an admin user and the URLs are not shortened.
So we'll create a new tests/Feature/Services/CommentService/StoreCommentTest.php
file and write some tests for these scenarios:
tests/Features/Services/CommentService/StoreCommentTest.php
1declare(strict_types=1);23namespace Tests\Feature\Services\CommentService;45use App\Models\Article;6use App\Models\User;7use App\Services\CommentService;8use AshAllenDesign\ShortURL\Models\ShortURL;9use Illuminate\Foundation\Testing\LazilyRefreshDatabase;10use PHPUnit\Framework\Attributes\Test;11use Tests\TestCase;1213final class StoreCommentTest extends TestCase14{15 use LazilyRefreshDatabase;1617 private const COMMENT = 'This is a comment with a link to https://honeybadger.io and some fiddlesticks. I hate Laravel!';1819 #[Test]20 public function comment_can_be_stored_by_a_non_admin_user(): void21 {22 $user = User::factory()->admin(false)->create();2324 $article = Article::factory()->create();2526 (new CommentService())->storeComment(27 comment: self::COMMENT,28 commentedOn: $article,29 commentBy: $user,30 );3132 // Find the short URL that should have just been generated so we33 // can check the comment was stored with the correct short URL.34 $shortURL = ShortURL::query()->sole();3536 $this->assertDatabaseHas('comments', [37 'comment' => 'This is a comment with a link to '.$shortURL->default_short_url.' and some ****. ****',38 'user_id' => $user->id,39 'article_id' => $article->id,40 ]);41 }4243 #[Test]44 public function comment_can_be_stored_by_an_admin_user(): void45 {46 $user = User::factory()->admin()->create();4748 $article = Article::factory()->create();4950 $service = new CommentService();5152 $service->storeComment(53 comment: self::COMMENT,54 commentedOn: $article,55 commentBy: $user,56 );5758 $this->assertDatabaseHas('comments', [59 'comment' => 'This is a comment with a link to https://honeybadger.io and some ****. ****',60 'user_id' => $user->id,61 'article_id' => $article->id,62 ]);63 }64}
In the code example above, we've created a test for each scenario mentioned earlier. We're creating a new user and article, and then calling the storeComment
method on the CommentService
class. We're then checking that the comment has been stored in the database as expected. If we were to break the pipeline accidentally—or even completely move away from using Laravel pipelines in the future—we'll still have confidence that the feature works as intended.
How will you use Laravel pipelines?
In this article, we looked at what Laravel pipelines are, how Laravel uses them, and how you can create your own pipelines. We've also covered how to write tests for your pipelines to ensure they work as expected.
Now that you understand how they work, how will you use pipelines in your own applications and packages?