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?

Larevel_conveyer

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:

  1. Check the comment for any profanity. If any exists, replace the profanity with asterisks (*).
  2. Check for any spam in the comment (such as links to malicious external websites) and remove it.
  3. 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:

use App\Pipelines\Comments\RemoveProfanity;
use App\Pipelines\Comments\RemoveSpam;
use App\Pipelines\Comments\RemoveHarmfulContent;
use Illuminate\Pipeline\Pipeline;

// Grab the comment from the incoming request
$commentText = $request->validated('comment')

// Pass the comment string through the pipeline.
// $comment will be equal to a string with all the checks and modifications applied.
$comment = app(Pipeline::class)
    ->send($commentText)
    ->through([
        RemoveProfanity::class,
        RemoveSpam::class,
        RemoveHarmfulContent::class,
    ])
    ->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:

declare(strict_types=1);

namespace App\Pipelines\Comments;

use Closure;

final readonly class RemoveProfanity
{
    private const array PROFANITIES = [
        'fiddlesticks',
        'another curse word'
    ];

    public function __invoke(string $comment, Closure $next): string
    {
        $comment = str_replace(
            search: self::PROFANITIES,
            replace: '****',
            subject: $comment,
        );

        return $next($comment);
    }
}

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 (the RemoveProfanity 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:

use App\Pipelines\Comments\RemoveProfanity;
use App\Pipelines\Comments\RemoveSpam;
use App\Pipelines\Comments\RemoveHarmfulContent;
use App\Pipelines\Comments\ShortenUrls;
use Illuminate\Pipeline\Pipeline;

$commentText = $request()->validated('comment')

$comment = app(Pipeline::class)
    ->send($commentText)
    ->through([
        RemoveProfanity::class,
        RemoveSpam::class,
        RemoveHarmfulContent::class,
        ShortenUrls::class,
    ])
    ->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:

public function getGlobalMiddleware()
{
    $middleware = $this->global ?: array_values(array_filter([
        $this->trustHosts ? \Illuminate\Http\Middleware\TrustHosts::class : null,
        \Illuminate\Http\Middleware\TrustProxies::class,
        \Illuminate\Http\Middleware\HandleCors::class,
        \Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance::class,
        \Illuminate\Http\Middleware\ValidatePostSize::class,
        \Illuminate\Foundation\Http\Middleware\TrimStrings::class,
        \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
    ]));

    // The rest of the method...
}

The sendRequestThroughRouter method (that contains the pipeline) looks like this:

/**
 * Send the given request through the middleware / router.
 *
 * @param  \Illuminate\Http\Request  $request
 * @return \Illuminate\Http\Response
 */
protected function sendRequestThroughRouter($request)
{
    $this->app->instance('request', $request);

    Facade::clearResolvedInstance('request');

    $this->bootstrap();

    return (new Pipeline($this->app))
                ->send($request)
                ->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)
                ->then($this->dispatchToRouter());
}

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:

return (new Pipeline($this->app))
            ->send($request)
            ->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)
            ->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:

/**
 * Run the given route within a Stack "onion" instance.
 *
 * @param  \Illuminate\Routing\Route  $route
 * @param  \Illuminate\Http\Request  $request
 * @return mixed
 */
protected function runRouteWithinStack(Route $route, Request $request)
{
    $shouldSkipMiddleware = $this->container->bound('middleware.disable') &&
                            $this->container->make('middleware.disable') === true;

    $middleware = $shouldSkipMiddleware ? [] : $this->gatherRouteMiddleware($route);

    return (new Pipeline($this->container))
                    ->send($request)
                    ->through($middleware)
                    ->then(fn ($request) => $this->prepareResponse(
                        $request, $route->run()
                    ));
}

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:

This 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:

This 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:

  1. App\Pipelines\Comments\RemoveProfanity
  2. App\Pipelines\Comments\RemoveHarmfulContent
  3. App\Pipelines\Comments\ShortenUrls

We'll look at the RemoveProfanity class first:

declare(strict_types=1);

namespace App\Pipelines\Comments;

use Closure;

final readonly class RemoveProfanity
{
    private const array PROFANITIES = [
        'fiddlesticks',
        'another curse word'
    ];

    public function __invoke(string $comment, Closure $next): string
    {
        $comment = str_replace(
            search: self::PROFANITIES,
            replace: '****',
            subject: $comment,
        );

        return $next($comment);
    }
}

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:

declare(strict_types=1);

namespace App\Pipelines\Comments;

use Closure;

final readonly class RemoveHarmfulContent
{
    public function __invoke(string $comment, Closure $next): string
    {
        // Remove the harmful content from the comment.
        $comment = str_replace(
            search: $this->harmfulContent(),
            replace: '****',
            subject: $comment,
        );

        return $next($comment);
    }

    private function harmfulContent(): array
    {
        // Code goes here that determines what is harmful content.
        // Here's some hardcoded harmful content for now.

        return [
            'I hate Laravel!',
        ];
    }
}

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:

declare(strict_types=1);

namespace App\Pipelines\Comments;

use AshAllenDesign\ShortURL\Facades\ShortURL;
use Closure;

final readonly class ShortenUrls
{
    public function __invoke(string $comment, Closure $next): string
    {
        $urls = $this->findUrlsInComment($comment);

        foreach ($urls as $url) {
            $shortUrl = ShortURL::destinationUrl($url)->make();

            $comment = str_replace(
                search: $url,
                replace: $shortUrl->default_short_url,
                subject: $comment,
            );
        }

        return $next($comment);
    }

    private function findUrlsInComment(string $comment): array
    {
        // Code goes here to find all URLs in the comment and
        // return them in an array.

        return [
            'https://honeybadger.io',
        ];
    }
}

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:

use App\Pipelines\Comments\RemoveHarmfulContent;
use App\Pipelines\Comments\RemoveProfanity;
use App\Pipelines\Comments\ShortenUrls;
use Illuminate\Pipeline\Pipeline;

$comment = 'This is a comment with a link to https://honeybadger.io and some fiddlesticks. I hate Laravel!';

$modifiedComment = app(Pipeline::class)
    ->send($comment)
    ->through([
        RemoveProfanity::class,
        RemoveHarmfulContent::class,
        ShortenUrls::class,
    ])
    ->thenReturn();

If we were to run the above code, we should expect the $modifiedComment variable to contain the following string:

This 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:

$commentPipeline = app(Pipeline::class)
    ->send($comment)
    ->through([
        RemoveProfanity::class,
        RemoveHarmfulContent::class,
    ]);

// If the user is an admin, we don't want to shorten the URLs.
if (!auth()->user()->isAdmin()) {
    $commentPipeline->pipe(ShortenUrls::class);
}

$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:

$modifiedComment = app(Pipeline::class)
    ->send($comment)
    ->through([
        RemoveProfanity::class,
        RemoveHarmfulContent::class,
    ])
    ->when(
        value: !auth()->user()->isAdmin(),
        callback: function (Pipeline $pipeline): void {
            $pipeline->pipe(ShortenUrls::class);
        }
    )
    ->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):

return (new Pipeline($this->app))
        ->send($request)
        ->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)
        ->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:

/**
 * Get the route dispatcher callback.
 *
 * @return \Closure
 */
protected function dispatchToRouter()
{
    return function ($request) {
        $this->app->instance('request', $request);

        return $this->router->dispatch($request);
    };
}

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:

$modifiedComment = app(Pipeline::class)
    ->send($comment)
    ->through([
        RemoveProfanity::class,
        RemoveHarmfulContent::class,
        ShortenUrls::class,
        function (string $comment, Closure $next): string {
            return $next(strtoupper($comment));
        },
    ])
    ->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:

public function __invoke(string $comment, Closure $next): string

to this:

public 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:

public function execute(string $comment, Closure $next): string

When creating your pipeline, you can specify this using the via method like so:

$modifiedComment = app(Pipeline::class)
    ->send($comment)
    ->through([
        RemoveProfanity::class,
        RemoveHarmfulContent::class,
        ShortenUrls::class,
    ])
    ->via('execute')
    ->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:

declare(strict_types=1);

namespace Tests\Unit\Pipelines\Comments;

use App\Pipelines\Comments\RemoveProfanity;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class RemoveProfanityTest extends TestCase
{
    #[Test]
    public function profanity_is_removed(): void
    {
        $comment = 'fiddlesticks This is a comment with some fiddlesticks.';

        $pipelineStage = new RemoveProfanity();

        // Invoke the class (so the __invoke) method is called. Pass it
        // a callback that will return the comment as a string.
        $cleanedComment = $pipelineStage(
            comment: $comment,
            next: fn (string $comment): string => $comment
        );

        // Check the profanity has been removed.
        $this->assertSame(
            '**** This is a comment with some ****.',
            $cleanedComment,
        );
    }
}

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:

declare(strict_types=1);

namespace App\Services;

use App\Models\Article;
use App\Models\Comment;
use App\Models\User;
use App\Pipelines\Comments\RemoveHarmfulContent;
use App\Pipelines\Comments\RemoveProfanity;
use App\Pipelines\Comments\ShortenUrls;
use Illuminate\Pipeline\Pipeline;

final class CommentService
{
    public function storeComment(
        string $comment,
        Article $commentedOn,
        User $commentBy
    ): Comment {
        $comment = $this->prepareCommentForStoring($comment, $commentBy);

        return $commentedOn->comments()->create([
            'comment' => $comment,
            'user_id' => $commentBy->id,
        ]);
    }

    private function prepareCommentForStoring(string $comment, User $commentBy): string
    {
        $commentPipeline = app(Pipeline::class)
            ->send($comment)
            ->through([
                RemoveProfanity::class,
                RemoveHarmfulContent::class,
            ]);

        // If the user is an admin, we don't want to shorten the URLs.
        if (!$commentBy->isAdmin()) {
            $commentPipeline->pipe(ShortenUrls::class);
        }

        return $commentPipeline->thenReturn();
    }
}

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:

declare(strict_types=1);

namespace Tests\Feature\Services\CommentService;

use App\Models\Article;
use App\Models\User;
use App\Services\CommentService;
use AshAllenDesign\ShortURL\Models\ShortURL;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

final class StoreCommentTest extends TestCase
{
    use LazilyRefreshDatabase;

    private const COMMENT = 'This is a comment with a link to https://honeybadger.io and some fiddlesticks. I hate Laravel!';

    #[Test]
    public function comment_can_be_stored_by_a_non_admin_user(): void
    {
        $user = User::factory()->admin(false)->create();

        $article = Article::factory()->create();

        (new CommentService())->storeComment(
            comment: self::COMMENT,
            commentedOn: $article,
            commentBy: $user,
        );

        // Find the short URL that should have just been generated so we
        // can check the comment was stored with the correct short URL.
        $shortURL = ShortURL::query()->sole();

        $this->assertDatabaseHas('comments', [
            'comment' => 'This is a comment with a link to '.$shortURL->default_short_url.' and some ****. ****',
            'user_id' => $user->id,
            'article_id' => $article->id,
        ]);
    }

    #[Test]
    public function comment_can_be_stored_by_an_admin_user(): void
    {
        $user = User::factory()->admin()->create();

        $article = Article::factory()->create();

        $service = new CommentService();

        $service->storeComment(
            comment: self::COMMENT,
            commentedOn: $article,
            commentBy: $user,
        );

        $this->assertDatabaseHas('comments', [
            'comment' => 'This is a comment with a link to https://honeybadger.io and some ****. ****',
            'user_id' => $user->id,
            'article_id' => $article->id,
        ]);
    }
}

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?

If you found this article useful and want to keep leveling up your Laravel knowledge, sign up for the Honeybadger newsletter. You'll get notified whenever other tips and tricks like this are published.

author photo
Ashley Allen

Ashley is a freelance Laravel web developer who loves contributing to open-source projects and building exciting systems to help businesses succeed.

More articles by Ashley Allen