Queues & jobs
Queued jobs: the worker did not get the model you dispatched
The mistake
You pass a model to a job and the job holds it, so the worker gets the object you passed. That is what the code looks like.
SerializesModels does not put the model on the queue. It puts the class name and
the primary key there, and the worker loads the row again before handle() runs.
The job sees the row as it is when it runs. That is a different row from the one you dispatched, whenever anything has touched it since.
The machine
Every outcome runs on the tested reducer. All of it is checked against laravel/framework v13.34.0 and a real MySQL by docs/verify-job-serializes-models.sh, 23 checks.
Drive it
The panel opens on the ordinary path: a row, a job holding it, a worker that read what it expected.
- Read the payload. The class is there and the key is there. The title is not, which is why the worker has to go and fetch it.
- Insert, dispatch, then edit the row before you run the worker. Nothing on the
queue changed, because nothing on the queue held the title. The job reads
Published. - Put the queue on its own connection, then dispatch inside a transaction and
roll it back. The row is gone and the job is not. Run the worker and it fails
before
handle()is called. Then switch the queue back and do it again.
The mechanism
SerializesModels replaces every model property with a ModelIdentifier when the
job is serialised. A jobs row holds a JSON envelope, and the serialised job sits
in its data.command field. This is that field:
O:11:"PublishPost":1:{s:4:"post";O:45:"Illuminate\Contracts\Database\ModelIdentifier":5:{
s:5:"class";s:15:"App\Models\Post";
s:2:"id";i:1;
s:9:"relations";a:0:{}
s:10:"connection";s:7:"default";
s:15:"collectionClass";N;}}
There are five fields and the attributes are not among them. They are the class, the key, the relations to eager load, the connection to load from, and the collection class, which is filled in when the property held a collection rather than one model.
On the way back, restoreModel turns that into a query:
return $this->getQueryForModelRestoration(
(new ($value->getClass()))->setConnection($value->connection), $value->id
)->useWritePdo()->firstOrFail()->loadMissing($value->relations ?? []);
Two things in that line matter. useWritePdo() means the restore reads the write
connection, so a replica that has not caught up cannot cause a miss. And
firstOrFail() means a missing row throws rather than handing you null.
The throw happens during unserialization, before handle() is called. That is
the part that surprises people. CallQueuedHandler::call() wraps only the step
that rebuilds the job:
try {
$command = $this->setJobInstanceIfNecessary(
$job, $this->getCommand($data)
);
} catch (ModelNotFoundException $e) {
return $this->handleModelNotFound($job, $e);
}
So your handle() body never runs, and no code of yours gets a chance to check
whether the model is still there.
handleModelNotFound then has two outcomes, and the choice is already made:
if ($job->payload()['deleteWhenMissingModels'] ?? false) {
return $job->delete();
}
return $job->fail($e);
It reads the payload, not the class. The flag is written into the payload when
the job is queued, so it is fixed at dispatch. Changing
$deleteWhenMissingModels on a job that is already sitting on the queue changes
nothing about that job.
With the flag unset, the job fails. It leaves the queue, and a ModelNotFoundException lands in failed_jobs. With the flag set, the job is deleted and nothing is recorded anywhere.
In your code
The expensive version of this bug is a dispatch inside a transaction. The row is not committed yet, and a worker on another machine can reach the job before your transaction ends.
Whether a rollback saves you depends on where the queue writes. With the database driver on the application connection, the job row is written inside your transaction, so a rollback takes the job with it and nothing runs. With Redis, SQS, or a second database connection, the queue write is not part of your transaction. The row rolls back and the job does not.
The fix does not depend on which of those you have:
PublishPost::dispatch($post)->afterCommit();
Or set it once, for every job on that connection, in config/queue.php:
'redis' => [
// ...
'after_commit' => true,
],
Then the dispatch waits for the outermost commit. If the transaction rolls back, the job is never queued at all.
The fine print
- A property holding a collection serialises to one identifier with an array of
keys, and the restore uses
whereIn. A row that has gone missing from that set is dropped from the collection rather than throwing. That is a different rule from the single-model case. deleteWhenMissingModelscan also be set with the#[DeleteWhenMissingModels]attribute in Laravel 13. Both end up in the same payload key.- An unsaved model serialises with a null key, and the restore fails the same way a deleted row does.
- The
failed_jobsrow is not written byJob::fail(). It is written by aJobFailedlistener thatqueue:workregisters, andfail()dispatches that event from afinallyblock. A worker that is not running will not record it. - Relations already loaded are listed in the identifier and reloaded with
loadMissing. Anything else your job reads off the model is a fresh read. - This page does not model queued listeners, queued mailables, batches or chains. They serialise models the same way.
- Measured against
laravel/frameworkv13.34.0 and MySQL 9.6 bydocs/verify-job-serializes-models.sh, 23 checks.
Further reading
- Laravel: queues and model serialization
is the reference for
SerializesModels, relations, and theafterCommitoptions. - SerializesAndRestoresModelIdentifiers.php
is the trait that does the swap, in about a hundred lines. Read
getSerializedPropertyValueandrestoreModelnext to each other. - Laravel: database transactions is the other half of the rollback case, and why only the outermost commit makes anything durable.
Spotted a problem, or have a way to make this clearer? Suggest an improvement.