Deferring tasks with Laravel's defer() helper

Laravel has released a new defer() helper. Here's how it's described in the docs:

Deferred functions allow you to defer the execution of a closure until after the HTTP response has been sent to the user, keeping your application feeling fast and responsive. To defer the execution of a closure, simply pass the closure to the defer function

Let's have a look at a simple example and then take a deeper dive into how it works.

Example

Let's say you have a reservations api endpoint that returns all the reservations for a given user.

class ReservationController extends Controller
{
    public function index(Request $request)
    {
        $collection = ReservationResource::collection(Reservation::paginate());

        return response()->json($collection);
    }
}

The response is fast, and everything functions as intended.

Now you are tasked with adding some logging to this resource. You have to log some details about the incoming request and the response. You don't want to deal with pesky middlewares for just one endpoint, so you create a trait with a couple of methods. You could be logging to an external system, but for this example we'll just use the log file.

trait LogsApiRequests
{
    protected function logRequest(Request $request): void
    {
        Log::info(
            'API request received',
            [
                'url' => $request->fullUrl(),
                'method' => $request->method(),
                'headers' => Arr::except($request->header(), ['authorization']),
                'body' => $request->all(),
            ]
        );

        // send logs to external system
    }

    protected function logResponse(JsonResponse $response): void
    {
        $responseTime = round(microtime(true) - LARAVEL_START, 2) . ' seconds';

        Log::info(
            'API response sent',
            [
                'responseTime' => $responseTime,
                'statusCode' => $response->getStatusCode(),
                'headers' => $response->headers->all(),
                'content' => $response->getContent(),
            ]
        );

        // send logs to external system
    }
}
class ReservationController extends Controller
{
    use LogsApiRequests;

    public function index(Request $request)
    {
        $this->logRequest($request);

        $response = response()->json(ReservationResource::collection(Reservation::query()->paginate()));

        $this->logResponse($response);

        return $response;
    }
}

However, this has increased the response time. Because the logging functionality is executed before the response is sent back to the client, the duration of the request has doubled.

This is where the new defer() helper is very useful. You simply pass the logging methods as closures to defer.

// ReservationController.php

public function index(Request $request)
{
    defer(fn () => $this->logRequest($request));

    $response = response()->json(ReservationResource::collection(Reservation::query()->paginate()));

    defer(fn () => $this->logResponse($response));

    return $response;
}

Now if we make the request again, the response time drops down to normal levels.

// Note: To log the request even if an exception is thrown,
// chain the always() method at the end.
...
  defer(fn () => $this->logRequest($request))->always();
...

A deeper dive

So how does defer() work? Remember the "pesky middlewares" I was talking about earlier – the example above was a bit contrived because the right thing to do would've been to use a terminable middleware for logging.

<?php

namespace App\Http\Middleware;

class LogApiRequests
{
    public function handle(Request $request, Closure $next): Response
    {
        return $next($request);
    }

    public function terminate(Request $request, Response|JsonResponse $response): void
    {
        $this->logRequest($request);
        $this->logResponse($response);
    }

    // Define methods here
}

In your routes file:

Route::get('/reservations', [ReservationController::class, 'index'])
    ->middleware(['auth:sanctum', LogApiRequests::class]);

The terminate method is always called after the response is sent back to the client.

The defer() helper essentially does the same thing. Let's follow the trail in the source code.

    function defer(?callable $callback = null, ?string $name = null, bool $always = false)
    {
        if ($callback === null) {
            return app(DeferredCallbackCollection::class);
        }

        return tap(
            new DeferredCallback($callback, $name, $always),
            fn ($deferred) => app(DeferredCallbackCollection::class)[] = $deferred
        );
    }

When you pass a callback to defer(), it uses the tap() method to:

  • create a new DeferredCallback – which stores the callback and can be invoked as a function
  • add it to the DeferredCallbackCollection collection - stores a collection of DeferredCallback objects and can invoke them all.

Along with the defer() helper, a new middleware called Illuminate\Foundation\Http\Middleware\InvokeDeferredCallbacks has been added to the global middleware stack:

class InvokeDeferredCallbacks
{
    /**
     * Handle the incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return \Symfony\Component\HttpFoundation\Response
     */
    public function handle(Request $request, Closure $next)
    {
        return $next($request);
    }

    /**
     * Invoke the deferred callbacks.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Symfony\Component\HttpFoundation\Response  $response
     * @return void
     */
    public function terminate(Request $request, Response $response)
    {
        Container::getInstance()
            ->make(DeferredCallbackCollection::class)
            ->invokeWhen(fn ($callback) => $response->getStatusCode() < 400 || $callback->always);
    }
}

Similar to the example middleware above, it has a terminate method that retrieves the DeferredCallbackCollection and invokes the callbacks.

The big advantage here is that instead of creating middlewares for all your use cases, you can simply wrap them as closures in defer() and one middleware will handle it for you.

But hang on, middlewares only work with HTTP requests. However, the documentation says it also works for artisan commands and queued jobs. How does that work?

The answer lies in Illuminate\Foundation\Providers\FoundationServiceProvider, where the framework's core services are bootstrapped and configured. A new method registerDeferHandler() has been added:

    protected function registerDeferHandler()
    {
        $this->app->scoped(DeferredCallbackCollection::class);

        $this->app['events']->listen(function (CommandFinished $event) {
            app(DeferredCallbackCollection::class)->invokeWhen(fn ($callback) => app()->runningInConsole() && ($event->exitCode === 0 || $callback->always)
            );
        });

        $this->app['events']->listen(function (JobAttempted $event) {
            app(DeferredCallbackCollection::class)->invokeWhen(fn ($callback) => $event->connectionName !== 'sync' && ($event->successful() || $callback->always)
            );
        });
    }

We can see that listeners have been registered for CommandFinished and JobAttempted events. Similar to the InvokeDeferredCallbacks middleware, the callbacks in DeferredCallbackCollection are invoked when these events are dispatched.

It is important to note that for queued jobs, the callbacks are invoked when a job is successfully added to the queue for processing, not when the job is completed. Since queued jobs run in a separate process, they won’t have access to the DeferredCallbackCollection context.


Liked this article? Share it on X