DEV Community

Cover image for Improving Laravel's Queue Performance
Lucas Porfirio
Lucas Porfirio

Posted on • Updated on

Improving Laravel's Queue Performance

I didn't always use Laravel the way I do today. In the beginning, I made a lot of mistakes, and I believe that's part of the maturing and evolving process, so let's call it normal. Perhaps I've made the same mistakes you have, or maybe even more. One of the many mistakes I made was, for example, making all my validations inside the controller😂 Fortunately, I recognized my mistake very early on and was able to correct it in time.

Now, considering the context of queues (jobs), another mistake I've made is this:

class SaveAccessLog implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public function __construct(protected User $user)
    {
        //
    }

    public function handle(): void
    {
        Log::create([
            'user_id' => $this->user->id,
            // ...
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Do you know what the problem is with this piece of code? I already know what you're thinking: "It's not creating the log via a relationship!" Did I get it right? Don't worry; this is just an example! If you're not a hardcore Laravel user, you probably won't see the issue; everything seems normal. If you couldn't find the answer, don't worry. I'll explain it to you.

Understanding the Problem

First of all, this is not an error but rather a Laravel behavior that prevents us from extracting the maximum potential from our queues, resulting in a performance issue in terms of execution time.

Have you noticed that every Laravel queue class includes four traits?

class SaveAccessLog implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Here's where the point to be observed lies. One of these traits is "SerializesModels." This trait is responsible for serializing and deserializing all the properties in the queue class. Inside it, it uses another trait called "SerializesAndRestoresModelIdentifiers," which, in turn, performs other tasks by consuming other Laravel classes, for example, identifying if a serialized property is an instance of a model so it can be correctly retrieved from the database.

Basically, what Laravel needs to do at this point in the execution is, as mentioned above, serialize the properties of the queue class so that they can be stored in databases - regardless of their type. When the queue is actually executed, these properties should be available for use in their original state, preserving the values given to the properties when the queue record was created. This helps us understand that the larger the object to be serialized, the more work Laravel needs to do to perform serialization and deserialization, and that's where the problem lies.

A queue is stored in the database like this:

{
    "uuid": "31378037-ebd2-4cd0-9abf-b9cfac94c8c9",
    "displayName": "App\\Jobs\\SaveAccessLog",
    "job": "Illuminate\\Queue\\CallQueuedHandler@call",
    "maxTries": null,
    "maxExceptions": null,
    "failOnTimeout": false,
    "backoff": null,
    "timeout": null,
    "retryUntil": null,
    "data": {
        "commandName": "App\\Jobs\\SaveAccessLog",
        "command": "O:22:\"App\\Jobs\\SaveAccessLog\":1:{s:6:\"\u0000*\u0000car\";O:45:\"Illuminate\\Contracts\\Database\\ModelIdentifier\":5:{s:5:\"class\";s:14:\"App\\Models\\Car\";s:2:\"id\";i:1;s:9:\"relations\";a:0:{}s:10:\"connection\";s:6:\"sqlite\";s:15:\"collectionClass\";N;}}"
    }
}
Enter fullscreen mode Exit fullscreen mode

If, after deserialization, the property is a model, then:

// Illuminate/Queue/SerializesAndRestoresModelIdentifiers.php

protected function getRestoredPropertyValue($value)
{
    // ...

    return is_array($value->id)
        ? /* ... */
        : $this->restoreModel($value); // 👈🏻
}

public function restoreModel($value)
{
    return $this->getQueryForModelRestoration(
        (new $value->class)->setConnection($value->connection), $value->id
    )->useWritePdo()->firstOrFail()->load($value->relations ?? []); // 👈🏻
}
Enter fullscreen mode Exit fullscreen mode

Solving This Problem

Many developers insist on "injecting" an entire object into the queue class—often as an instance of a model, carrying numerous relationships—without realizing that for the context of that task, perhaps only one, two, or three properties of the model would be necessary.

Let's look at the example again:

class SaveAccessLog implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public function __construct(protected User $user)
    {
        //
    }

    public function handle(): void
    {
        Log::create([
            'user_id' => $this->user->id,
            // ...
        ]);
    }
}

// ...

SaveAccessLog::dispatch(User::first());
Enter fullscreen mode Exit fullscreen mode

Can you see that only the user's id is REALLY in use in the above example? So, why do I need to send the entire user object? That doesn't make any sense! The best approach to IMPROVE THIS CODE would be:

class SaveAccessLog implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public function __construct(protected int $userId)
    {
        //
    }

    public function handle(): void
    {
        Log::create([
            'user_id' => $this->userId,
            // ...
        ]);
    }
}

// ...

SaveAccessLog::dispatch(User::first()->id);
Enter fullscreen mode Exit fullscreen mode

Notice the difference? Basically, if I only need the user's id, then I send ONLY the user's id. At this point, you might ask, "But won't Laravel serialize this the same way?" I confess that this is a GREAT question! The answer is: YES, but the effort Laravel needs to serialize and deserialize an int is MUCH LESS than for an entire object.

Here is the serialization of the improved code example:

{
    "uuid": "8fd5f768-8421-467c-8cb6-4a0973ca9829",
    "displayName": "App\\Jobs\\SaveAccessLog",
    "job": "Illuminate\\Queue\\CallQueuedHandler@call",
    "maxTries": null,
    "maxExceptions": null,
    "failOnTimeout": false,
    "backoff": null,
    "timeout": null,
    "retryUntil": null,
    "data": {
        "commandName": "App\\Jobs\\SaveAccessLog",
        "command": "O:22:\"App\\Jobs\\SaveAccessLog\":1:{s:8:\"\u0000*\u0000userId\";i:1;}"
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice how it is smaller in the data section? It's worth noting that with the improved code example approach, Laravel won't perform a database query when the queue is actually executed since we haven't injected a model instance, but only the user's id.

Did you think I forgot about situations where we need to inject more than one property into the queue class? For such cases, in my view, the ideal approach is to send an array with everything needed for execution:

class SaveAccessLog implements ShouldQueue
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public function __construct(protected array $user)
    {
        //
    }

    public function handle(): void
    {
        Log::create([
            'user_id'    => $this->user['id'],
            'user_email' => $this->user['email'],
            // ...
        ]);
    }
}

// ...

SaveAccessLog::dispatch(User::first()->toArray());
Enter fullscreen mode Exit fullscreen mode

Conclusion

It's okay if you don't see the point in this right now! Take the learning and carry it with you because I guarantee that one day you will need it, and then you will see the difference. In my case, this only made sense to me when I needed to scale the performance and execution time of my queues because it was with this technique that I achieved IMPRESSIVE queue execution results.

Top comments (2)

Collapse
 
bertugkorucu profile image
Bertug Korucu

I guess you aren't have issues while passing 3-5 models. That's extra database calls, fair enough, but is it affecting that much? I'd love to know your benchmarks in real world project - can you please share?

I had similar issue and it was due to loading relationships within the job. Then I realised, the relationships on the model when firing the job were loaded again in the job due to SerializesModels trait.

Since then, I started firing my jobs as dispatch(new MyJob($user->withoutRelations));

Collapse
 
nicolus profile image
Nicolas Bailly

Great tip. It would be interesting to have benchmarks of the difference between the two approaches in a typical production environment to get an idea of how much potential gain there is.

Also I kinda disagree about the array approach to pass several properties, it's way too easy to make a mistake and use a wrong index (what if at some point someone used this job but passes "Email" or "e-mail" instead of "email" ?). So I'd much rather use DTOs than an array in this case.