Efficiently dispatching jobs with models

Published on

If you're trying to write code that efficiently gets models from the database, then you should be careful when passing those models to queued jobs. For example, consider the following Eloquent query:

$user = User::query()
    // Only select specific columns from the User model
    ->select('id', 'email', 'name')
    // Load the "posts" relation, but only specific columns
    ->with('posts:id,title')
    // Load the "activities" relation, but only the latest 5
    ->with(['activities' => fn (Builder $query) => $query->latest()->take(5)])
    ->firstWhere('email', 'user@example.com');

Now imagine that you dispatch a queued job with this $user as payload:

Queue::push(
    new ExampleJob($user)
);

Laravel's Serializes​Models trait serializes the model into a Model​Identifier and stores it as the payload of this job:

// This is how the model is stored in the queued job payload
$identifier = new ModelIdentifier(
    class: User::class,
    id: 1,
    relations: ['posts', 'activities'],
    connection: 'mysql'
)

// This is how the model is eventually retrieved from the database
$user = User::on($identifier->connection)
    ->with($identifier->relations)
    ->find($identifier->id);

From a performance perspective there are a few problems with how Laravel serializes and deserializes models in queued jobs. When the model is retrieved from the database, Laravel will:

  • Select all columns of the User model, not just the ones that were initially selected.
  • Load the posts and activities relationships because they were loaded when the job was initially dispatched. The job might not need these relationships.
  • Select all columns from the posts relationship, not just the ones that were initially selected.
  • Get the entire activities relationship, forgetting the limit of 5 that was originally specified.

There are a few ways to prevent relationships from being loaded when a queued job retrieves the model:

// Prevent relationships from being loaded when dispatching the job:

Queue::push(
    new ExampleJob($user->withoutRelationships())
);

// or in the job's constructor:

class ExampleJob
{
    public function __construct(User $user)
    {
        $this->user = $user->withoutRelations();
    }
}

// or via the WithoutRelations attribute:

class ExampleJob
{
    public function __construct(
        #[WithoutRelations]
        public User $user,
    ) {}
}

Calling ->without​Relationships() when you dispatch the job is easy to forget, and none of these options are very elegant. You also still won't have control over which database columns are selected when the queue retrieves the model.

When performance is key, another option is only passing the $user->id into the job instead of the model:

Queue::push(
    new ExampleJob($user->id)
);

Then in the job class the model can be retrieved like this:

class ExampleJob implements ShouldQueue
{
    use InteractsWithQueue, Queueable;

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

    public function handle()
    {
        $user = User::query()
            ->select('id', 'email')
            ->find($this->userId);
    }
}

Passing only the $user​Id into the job instead of a model has some benefits:

  • You can select only the columns you need when querying the model
  • You can explicitly load only the relationships you need, instead of relationships being implicitly loaded based on the model state when the job was dispatched
  • The job payload is smaller, it only stores the $user​Id instead of a Model​Identifier

Of course, for smaller applications everything described in this post doesn't really matter. But when you're dispatching thousands of jobs at scale, passing only the model id to queued jobs might make a significant performance difference.