How does the Laravel defer helper work? (Plain PHP example included!)

How does the Laravel defer helper work? (Plain PHP example included!)

By now we’ve all seen that you can use the Laravel defer helper to defer the execution of a simple task after the response has been sent to the browser.

In this example, the route handler will simply return a view, and we will defer a message to the logs after 2 seconds.

Route::get('/defer', function() {
    defer(function() {
        sleep(2);
        \Log::info('Hello world!');
    });
    return view('welcome');
});

And - while that’s pretty neat, this had me wondering how it actually worked behind the scenes - and that’s exactly what I will explain to you today.

Terminable middleware

Executing something after the response has been sent to the browser is actually nothing new in Laravel. Laravel already had this thing called terminable middleware for quite some time - and this allows you to execute something after the response has been sent to the browser.

To achieve this, you can add a ‘terminate’ function to your middleware, which receives both the request and the response, and this terminate function will execute after the response has been sent to the browser.

<?php
 
namespace Illuminate\Session\Middleware;
 
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
 
class TerminatingMiddleware
{
    /**
     * Handle an incoming request.
     *
     * @param  \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response)  $next
     */
    public function handle(Request $request, Closure $next): Response
    {
        return $next($request);
    }
 
    /**
     * Handle tasks after the response has been sent to the browser.
     */
    public function terminate(Request $request, Response $response): void
    {
        // ...
    }
}

And as you might have figured out - this is exactly what defer uses behind the scenes. Laravel has added an InvokeDeferredCallbacks middleware class to the core, and if we jump in, we can see that the terminate function will execute our deferred callbacks through a DeferredCallbackCollection.

<?php

namespace Illuminate\Foundation\Http\Middleware;

use Closure;
use Illuminate\Container\Container;
use Illuminate\Http\Request;
use Illuminate\Support\Defer\DeferredCallbackCollection;
use Symfony\Component\HttpFoundation\Response;

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);
    }
}

When we call ‘defer’ - we will add a callback to a DeferredCallbackCollection, which holds all our deferred callbacks.

if (! function_exists('Illuminate\Support\defer')) {
    /**
     * Defer execution of the given callback.
     *
     * @param  callable|null  $callback
     * @param  string|null  $name
     * @param  bool  $always
     * @return \Illuminate\Support\Defer\DeferredCallback
     */
    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
        );
    }
}

Alright - so now we know how it gets executed on a high level, but I want to go one level deeper and see how this actually works in plain PHP.

Finishing up a response

The thing that got me wondering was how a PHP process could signal to the browser that the response has been sent in full - and thus the browser should close the connection and stop loading.

This requires us to dive deeper into how a response works in Laravel. Let’s dig into our Http/Response class and here we can see it inherits from a Symfyony Response class. When we dive deeper and check out the ‘send’ method, we can see a couple of things happening here.

namespace Symfony\Component\HttpFoundation;

class Response
{
  // ...
  public function send(bool $flush = true): static
  {
    $this->sendHeaders();
    $this->sendContent();

    if (!$flush) {
        return $this;
    }

    if (\function_exists('fastcgi_finish_request')) {
        fastcgi_finish_request();
    } elseif (\function_exists('litespeed_finish_request')) {
        litespeed_finish_request();
    } elseif (!\in_array(\PHP_SAPI, ['cli', 'phpdbg', 'embed'], true)) {
        static::closeOutputBuffers(0, true);
        flush();
    }

    return $this;
  }
  // ...
}

First, we send the headers and the content - and then we see if the function fastcgi_finish_request exists and execute it if it does (we do the same for litespeed because it does not support fastcgi_finish_request).

And this is where the magic happens! When you’re running PHP on a server, chances are quite high that you’re running a server with the FastCGI protocol.

The function fastcgi_finish_request will signal to the process that the response has been sent to the client. And this is how a client - in our case a browser - knows that it can close the connection, since everything it needs has been sent over.

However - the process that was responsible for handling the request will not terminate yet and you can do some time consuming tasks afterwards.

As an example, I created this vanilla PHP file to illustrate this.

<?php

echo "Hello world!";

// If you don't add this, the browser will wait for 2 additional seconds before the loading stops
if (\\function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request();
}

sleep(2);

// You will see the log appear after 2 seconds
file_put_contents('defer.log', 'Hello world!' . PHP_EOL, FILE_APPEND);

Limitations

The next question that naturally occurred was: what are the limitations of deferring tasks like this?

And the answer is actually pretty simple. The process will respect the configuration values in your PHP-FPM’s php.ini configuration file.

This means that if your max_execution_time is set to 10 seconds, you need to be able to serve the response and handle your deferred tasks within those 10 seconds, as the timer doesn’t reset.

And the same goes for things like memory_limit and actually all the other configurations in your php.ini file.

If you need more power, or an entirely different configuration - queues are definitely the way to go. So my approach would still be to set up a queue worker for every project, but I might do lighter work like sending an email using the defer helper from now on.

Subscribe to sabatino.dev

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe