Get "PHP 8 in a Nuthshell" (Now comes with PHP 8.3)
Amit Merchant

Amit Merchant

A blog on PHP, JavaScript, and more

The magic behind Laravel's new defer() helper

Laravel has recently been shipped with a new helper called defer() which can be used to defer the execution of a callback until after a successful response has been sent.

This way you can offload time-consuming work (such as calling an external API) to a callback and return a response to the user as soon as possible.

Here’s what it looks like.

Route::get('/defer', function () {
    defer(function () {
        // do time-consuming work here
        sleep(10);
    });

    return "Hello world";
});

As you can tell, the defer() helper takes a callback as its parameter and will be executed after the response has been sent. So, when you call this route, you’ll see the response instantaneously even if the callback takes 10 seconds to execute.

The deferred callback won’t be executed if the request results in a 4xx or 5xx HTTP response. This behavior can be changed by passing true as the third parameter to the defer() helper or calling the always() method on the returned object. The callback will be executed regardless of the response status code.

Route::get('/defer', function () {
    defer(function () {
        // do time-consuming work here
        sleep(10);
    })->always();

    return "Hello world";
});

Now, all this is great but I wanted to know how this works under the hood since Taylor has mentioned that it’s not using any queues or anything like that. And that intrigued me.

So, I decided to dig into the code and see what was going on. And after a lot of going back and forth in the source code, I finally found the answer.

If you also want to know the magic behind this little gem (like me), keep on reading.

The shape of the defer() helper

First things first, let’s take a look at the defer() helper.

use Illuminate\Foundation\Defer\DeferredCallback;
use Illuminate\Foundation\Defer\DeferredCallbackCollection;

/**
 * Defer execution of the given callback.
 *
 * @param  callable|null  $callback
 * @param  string|null  $name
 * @param  bool  $always
 * @return \Illuminate\Foundation\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
    );
}

As you can tell, nothing too fancy here. What the helper is essentially doing is creating an instance of the DeferredCallback class and then adding it to the DeferredCallbackCollection class that’s been resolved from the container.

The interesting thing to notice here is that the object of the DeferredCallback class will be assigned to an array of the DeferredCallbackCollection class instance. This means it will accumulate all the callbacks that are being deferred in the current request.

So, at the end of the request, this array will have all the callbacks that need to be executed.

The DeferredCallback class

The DeferredCallback class is responsible for defining the callbacks with a unique name and a few methods such as always(), name(), and __invoke().

So, if we dump the defer callback…

$d = defer(function () {
    sleep(10);
});

dd($d);

This will output something like this.

defer-dump

As you can see, the object has a name (to uniquely identify a callback), a callback (passed calledback), and an always property.

And that’s about the DeferredCallback class.

The real magic

We saw the definition of the defer() helper and the DeferredCallback class but it still doesn’t explain how the callbacks are executed at a later time.

The real magic happens in the new global middleware that’s been added in the core.

\Illuminate\Foundation\Http\Middleware\InvokeDeferredCallbacks::class

If you look at the source code of this middleware, it looks pretty simple at first glance. But check out the terminate() method.

Yep! That’s the real deal, my friend! Laravel piggybacks to this method to achieve the final result.

So, if you don’t know,

The terminate() method in the middleware is used to perform actions after the response has been sent to the user.

Here’s what the terminate() method of the InvokeDeferredCallbacks middleware looks like.

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

As you can tell, the terminate() method is invoking the DeferredCallbackCollection class which has all the deferred callbacks, as we saw earlier, that have been accumulated in the current request.

The invokeWhen() lets the container know that the callbacks should be invoked and executed only if the response status code is less than 400 or the callback has the always property set to true.

And that’s pretty much it. This is how Laravel achieves the magic behind the defer() helper.

As someone truly said, “The magic is in the simple things.”


A word on the terminate() method

After I published this article, many people wondered how Laravel magically knew how to terminate the request after the defer() callback had been executed. So, here’s a quick explanation.

So, essentially, Laravel relies upon a protocol called FastCGI (that first needs to be enabled on the server). FastCGI maintains a pool of PHP processes that persists beyond executing a single request.

This means that the PHP process does not terminate immediately after sending the response, allowing Laravel to execute additional code (like the terminate() method) after the response is dispatched.

Laravel uses the request lifecycle to ensure all terminate methods are called in the reverse order of their middleware registration. This happens at Laravel’s Kernel level.

Learn the fundamentals of PHP 8 (including 8.1, 8.2, and 8.3), the latest version of PHP, and how to use it today with my new book PHP 8 in a Nutshell. It's a no-fluff and easy-to-digest guide to the latest features and nitty-gritty details of PHP 8. So, if you're looking for a quick and easy way to PHP 8, this is the book for you.

Like this article? Consider leaving a

Tip

👋 Hi there! I'm Amit. I write articles about all things web development. You can become a sponsor on my blog to help me continue my writing journey and get your brand in front of thousands of eyes.

Comments?