Laravel Pipelines & Composable Job Middleware

Davey Shafik - Oct 2 - - Dev Community

A feature you are probably familiar with in Laravel — even if you don't know it — is the Pipeline.

Laravel features a Pipeline implementation that allows you to easily create a series of pipes through which a value should pass:

use \Illuminate\Pipeline\Pipeline;

$pipeline = new Pipeline(null);

$greeting = $pipeline->send(new Stringable(''))
     ->through(
         fn (Stringable $greeting, Closure $next) => 
             $next($greeting->append('Hello World!')
     )
     ->then(fn (Stringable $greeting) => $greeting->toString()); 

// Hello World!
Enter fullscreen mode Exit fullscreen mode

What the heck is a Pipe?

A pipe is a callable, or any class with a specific public method (by default: handle()) — it should take two arguments, a "passable", and a Closure. The passable is the value you are sending through the pipeline, and the Closure is a wrapper that will call the next pipe in the pipeline, passing through the argument passed to it (which should be the passable) and handle the return value.

In the example above, the pipe is a short closure that accepts our Stringable passable, and then calls the $next closure passing in a modified string.

A more complex set of pipes might look like this:

use Illuminate\Support\Arr;
class Greeting {
    public function __invoke(Stringable $str, Closure $next)
    {
        $greetings = ['Hey', 'Hello', 'Hi', 'Hola', 'Howdy'];

        $str = $str->append(Arr::random($greetings));

        $str = $next($str);

        return $str->finish('!');
    }
}

use Illuminate\Support\Facades\Auth;
class AddName {
    public function handle(Stringable $str, Closure $next)
    {
        if (Auth::hasUser()) {
            $str = $str->append(' ')->append(Auth::user()->name);
        }

        return $next($str);
    }
}

$pipeline = new Pipeline(null);
$greeting = $pipeline
    ->send(new Stringable(''))
    ->through([
        Greeting::class,
        AddName::class,
    ])
    ->then(fn (Stringable $greeting) => $greeting->toString());
Enter fullscreen mode Exit fullscreen mode

These two pipes work together to construct a greeting:

  1. The first pipe (Greeting) will add a random friendly greeting word
  2. Then it will call the second pipe (AddName) that will conditionally add the users name
  3. Finally the updated string will be returned back to the first pipe to make sure it ends in an exclamation point.

It looks something like this:

Greeting Pipeline Sequence Diagram

Using a different handler method

If you look closely at the example above, I use __invoke() with the first pipe, but handle() on the second. The Pipeline class will accept any valid callable, or, if it's a string, it will resolve the class using the service container and if invokable (has __invoke()) it will execute that, otherwise it will use the handler method, which defaults to handle().

You can change the named handler method by calling Pipeline->via():

$result = $pipeline
    ->send($passable)
    ->through()
    ->via('METHOD NAME HERE')
    ->then();
Enter fullscreen mode Exit fullscreen mode

Why Does This Look So Familiar?

If this is starting to look familiar, it's because it looks a heck of a lot like HTTP middleware… right? Well, that's because it is HTTP middleware… or rather, internally, Laravel uses Pipelines for multiple different things, including the HTTP request lifecycle:

// Illuminate\Foundation\Http\Kernel->sendRequestThroughRouter()

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

Job Middleware

What you might be surprised to find out is that Laravel Jobs also go through a pipeline when executed, and can also have middleware.

Job middleware is extremely powerful, it can be used to track metrics about your jobs, to handle errors, to verify that a job hasn't expired for some reason, or whatever else you can imagine.

Example: Authenticated Users in Jobs

A common problem that is encountered when reusing (particularly older code) within jobs is that they are tightly coupled to authentication using the Auth facade to retrieve the user, and this fails because a job running in the background is no longer executing within the users authenticated web session.

We can use middleware to ensure that jobs that require an authenticated user are logged in during job execution:

use Illuminate\Queue\Jobs\Job;

class Auth {
    public function __invoke(Job $job, Closure $next)
    {
        if (method_exists($job, 'getUser')) {
            Auth::login($job->getUser());
        }

        return $next($job);
    }
}
Enter fullscreen mode Exit fullscreen mode

Composable Job Middleware

A common pattern in Laravel is to use traits to add features to classes, for example HasTimestamps, SerializesModels, etc.

This would be a great way to add middleware to our jobs. Laravel will automatically call a jobs middleware() method which should return an array of middleware to add to the pipeline. We can use this feature to create composable middleware.

The HasMiddleware Trait

The goal of the HasMiddleware trait is to add a middleware() method that will dynamically create an array of middleware based on the traits it uses. To do this, for each middleware we create a trait that has a middleware<TraitName>() method — e.g. for the HasDeadline middleware trait, it would have a middlewareHasDeadline() method — which is called by the middleware() function and it's result is added to the list of middleware.

namespace App\Jobs\Traits;

use function class_basename;
use function class_uses_recursive;
use function method_exists;

trait HasMiddleware
{
    public function middleware(): array
    {
        $middleware = [];
        if (method_exists(parent::class, 'middleware')) {
            $middleware = parent::middleware();
        }

        foreach (class_uses_recursive($this) as $trait) {
            $method = 'middleware' . class_basename($trait);
            if (method_exists($trait, $method)) {
                $middleware = array_merge($middleware, $this->{$method}());
            }
        }

        return $middleware;
    }
}
Enter fullscreen mode Exit fullscreen mode

You can then add middleware by creating a trait:

namespace App\Jobs\Traits;

use App\Jobs\Middleware\Auth;

trait HasAuth {
     use HasMiddleware;

     protected function middlewareHasAuth(): array
     {
         return [Auth::class];
     }
}
Enter fullscreen mode Exit fullscreen mode

You Job class can then use the HasAuth trait:

use App\Jobs\Traits\HasAuth;

class MyJob extends Job {
    use HasAuth;

    public function __invoke()
    {
        // Auth::user()
    }
}
Enter fullscreen mode Exit fullscreen mode

This trait uses the HasMiddleware trait itself so that the job doesn't need to use it explicitly in addition to the HasAuth trait.

Creating Job Middleware

Starting in Laravel 11.26, you can now use the artisan make:job-middleware command to generate Job middleware.

In Summary…

The Laravel Pipeline is very versatile — although for Bag I went with League\Pipeline as it was faster and functionally, it's almost identical — and can be used for any composable series of operation that act on a value — be that HTTP request middleware, Job middleware, or anything else you want.

. . . .
Terabox Video Player