Efficiently dispatching jobs with models
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 SerializesModels
trait serializes the model into a ModelIdentifier
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
andactivities
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 ->withoutRelationships()
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 $userId
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
$userId
instead of aModelIdentifier
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.