Understanding Laravel's SerializesModels
When dispatching an object onto the queue, behind the scenes Laravel is recursively serializing the object and all of its properties into a string representation that is then written to the queue. There it awaits a queue worker to retrieve it from the queue and unserialize it back into a PHP object (Phew!).
Problem
When complicated objects are serialized, their string representations can be atrociously long, taking up unnecessary resources both on the queue and application servers.
Solution
Because of this, Laravel offers a trait called SerializesModels
which, when added to an object, finds any properties of type Model
or Eloquent\Collection
during serialization and replaces them with a plain-old-PHP-object (POPO) known as a ModelIdentifier
. These identifier objects represent the original properties Model
type and ID, or IDs in the case of an Eloquent\Collection
, with a much smaller string representation when serialized. When these objects are unserialized, the ModelIdentifier
s are then replaced with the Model
or Eloquent\Collection
of Model
s that they temporarily represented.
Curious to know how the SerializesModels
trait is "replacing" these properties at runtime? Before jumping into the source code, you may want to read the PHP docs for a quick primer on what the reflection API offers. For a more detailed explanation including examples of how Laravel uses reflection check out this article.
Gotcha!
🗣 Because of the SerializesModels trait that the job is using, Eloquent models and their loaded relationships will be gracefully serialized and unserialized when the job is processing.
While this quote from the docs sounds promising, it can be misleading. Here is an example of how a Model
would be represented when serialized.
use App\Models\User;use App\Jobs\ArchiveUser; $user = User::latest() ->with( "comments", fn($comments) => $comments ->select("id", "user_id") ->latest() ->limit(3) )->first(); $job = new ArchiveUser($user); dd(serialize($job)); /*O:20:"App\Jobs\ArchiveUser":1:{s:4:"user";O:45:"Illuminate\Contracts\Database\ModelIdentifier":5:{s:5:"class";s:15:"App\Models\User";s:2:"id";i:3;s:9:"relations";a:1:{i:0;s:8:"comments";}s:10:"connection";s:5:"mysql";s:15:"collectionClass";N;}} */
As you can see, the relation is serialized as comments
, which is the name of a one-to-many relation between User
and Comment
as it exists on the User
model. However, even though we're only selecting the id
and user_id
columns and have a limit of 3 Comment
records to be returned with the User
model, there's no mention of which records, which properties, or how many in the serialized representation.
Let's see what the query log looks like when we unserialize this object.
use App\Models\User;use App\Jobs\ArchiveUser; $user = User::latest() ->with( "comments", fn($comments) => $comments ->select("id", "user_id") ->latest() ->limit(3) )->first(); $job = new ArchiveUser($user); $serialized = serialize($job); \DB::enableQueryLog();unserialize($serialized);dd(\DB::getQueryLog()); /*array:2 [ 0 => array:3 [ "query" => "select * from `users` where `users`.`id` = ? limit 1" "bindings" => array:1 [ 0 => 3 ] "time" => 0.2 ] 1 => array:3 [ "query" => "select * from `comments` where `comments`.`user_id` in (3)" "bindings" => [] "time" => 0.15 ]] */
Wow! We're selecting all Comment
models, in their entirety, associated to the User
! As you can probably imagine, this can cause unforeseen issues for the unexpecting artisan.
Workarounds
Hope is not lost! There are workarounds without having to sacrifice resources on the queue, application, or database servers.
Unload unnecessary relations
If you don't need the loaded relations to be re-loaded, you can simply call withoutRelations()
on your Model
before it's serialized.
use App\Models\User;use App\Jobs\ArchiveUser; $user = User::latest() ->with( "comments", fn($comments) => $comments ->select("id", "user_id") ->latest() ->limit(3) )->first(); $comments = $user->comments; $job = new ArchiveUser($user->withoutRelations()); dd(serialize($job)); /*O:20:"App\Jobs\ArchiveUser":1:{s:4:"user";O:45:"Illuminate\Contracts\Database\ModelIdentifier":5:{s:5:"class";s:15:"App\Models\User";s:2:"id";i:3;s:9:"relations";a:0:{}s:10:"connection";s:5:"mysql";s:15:"collectionClass";N;}} */
As you can see, there are no longer any relations that will be loaded when the User
model is unserialized.
Make necessary relations their own property
If the loaded relation is going to be required after the Model
is unserialized you can store the relation (which is an Eloquent\Collection
) as its own property on the object being serialized.
use App\Models\User;use App\Jobs\ArchiveUser; $user = User::latest() ->with( "comments", fn($comments) => $comments ->select("id", "user_id") ->latest() ->limit(3) )->first(); $comments = $user->comments; $job = new ArchiveUser($user->withoutRelations(), $comments); dd(serialize($job)); /*O:20:"App\Jobs\ArchiveUser":2:{s:4:"user";O:45:"Illuminate\Contracts\Database\ModelIdentifier":5:{s:5:"class";s:15:"App\Models\User";s:2:"id";i:3;s:9:"relations";a:0:{}s:10:"connection";s:5:"mysql";s:15:"collectionClass";N;}s:10:"collection";O:45:"Illuminate\Contracts\Database\ModelIdentifier":5:{s:5:"class";s:18:"App\Models\Comment";s:2:"id";a:3:{i:0;i:1;i:1;i:2;i:2;i:3;}s:9:"relations";a:0:{}s:10:"connection";s:5:"mysql";s:15:"collectionClass";N;}} */
As you can see, the serialized representation of the Eloquent\Collection
specifies what the Model
type is and which IDs need to be retrieved from the database.
use App\Models\User;use App\Jobs\ArchiveUser; $user = User::latest() ->with( "comments", fn($comments) => $comments ->select("id", "user_id") ->latest() ->limit(3) )->first(); $comments = $user->comments; $job = new ArchiveUser($user->withoutRelations(), $comments); $serialized = serialize($job); \DB::enableQueryLog();unserialize($serialized);dd(\DB::getQueryLog()); /*array:2 [ 0 => array:3 [ "query" => "select * from `users` where `users`.`id` = ? limit 1" "bindings" => array:1 [ 0 => 3 ] "time" => 0.21 ] 1 => array:3 [ "query" => "select * from `comments` where `comments`.`id` in (1, 2, 3)" "bindings" => [] "time" => 0.16 ]] */
Much better! We've modified the job class to take an instance of User
and an Eloquent\Collection
of Comment
models. Now, we're only selecting the Comment
records we need and no longer loading unnecessary and potentially large sets of data. We can even set the relation back on the Model
by using the setRelation
method.
class ArchiveUser implements ShouldQueue{ use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; /** * Create a new job instance. */ public function __construct(public User $user, public Collection $comments) { } public function handle(): void { $this->user->setRelation('comments', $this->comments); $this->user->comments; // no additional queries are run, and only the 3 records are returned! // ... } // ...}
Recap
While it's recommended to use the SerializesModels
trait on any and all objects that will be queued (or otherwise serialized) it is crucial to be at least aware of its potential pitfalls and shortcomings as well as how to avoid running into them, if not understand how it all works under the hood.