A job blows up in failed_jobs with a message like this:

Illuminate\Database\Eloquent\ModelNotFoundException:
No query results for model [App\Models\Listing] 41

Listing 41 definitely existed when you dispatched the job. It does not exist now. Nothing is broken in your code: this is the normal consequence of how Laravel puts models into a queue payload.

The job never stored your model

When a job uses SerializesModels, Laravel does not write the model's attributes into the payload. It writes the class name and the primary key, and reloads the record from the database when the worker picks the job up. Our post on how Laravel prepares a job for the queue walks through what that payload actually contains.

That behaviour is deliberate and usually what you want. Payloads stay small, and the job works with current data rather than a snapshot that went stale in the queue. The trade-off is that the reload can fail.

The job stores only the model id at dispatch; if the row is deleted before the worker runs, restoring it throws ModelNotFoundException

Anything that removes the row in that gap produces the exception: a user deleting their own listing, a cleanup command, a cascading delete from a parent record, or an admin moderating something out of existence. On a busy queue the gap can be minutes, and minutes is plenty.

The direct fix

If a missing record means the job simply has nothing to do, tell Laravel to drop it instead of failing:

class ModerateListing implements ShouldQueue
{
    use Queueable, SerializesModels;

    public bool $deleteWhenMissingModels = true;

    public function __construct(public Listing $listing) {}

    public function handle(): void
    {
        // ...
    }
}

With that property set, a job whose models cannot be restored is deleted quietly. It never reaches failed_jobs, never burns attempts, and never pages anyone.

Do not set it everywhere out of habit

The property makes failures silent, which is right for some jobs and wrong for others. Moderating a listing that no longer exists is genuinely a no-op. But a job that reconciles a payment, writes an audit record, or notifies somebody about a record that has vanished may be telling you about a real bug in your delete logic, and swallowing it hides that.

The question to ask is whether a missing record is an expected outcome or a surprise. Expected means delete the job. A surprise deserves to fail loudly.

Soft deletes hit this too

SerializesModels restores the model with an ordinary query, so a soft-deleted record looks missing even though the row is still there. If a job should still run for trashed records, override the restoration query on the model:

public function newQueryForRestoration($ids)
{
    return $this->newQueryWithoutScopes()->whereKey($ids);
}

That drops the soft-delete scope for restoration only, and leaves the rest of your queries alone.

When the row was never committed

There is a second cause worth knowing, because it produces the same exception for a completely different reason: dispatching a job inside a database transaction. The worker can pick the job up before the transaction commits, so it queries for a row that is not visible yet. That one is not about deletion at all, and the fix is afterCommit, which we cover in database transactions in Laravel.

If your exception happens immediately and reproducibly rather than occasionally, this is usually the reason.

Or stop depending on the record

Some jobs do not need the live model. If everything the job needs is known at dispatch time, pass those values instead of the model, and the reload cannot fail because there is no reload:

public function __construct(
    public int $listingId,
    public string $title,
) {}

This is the self-contained job approach, and it is worth reaching for when the job must behave the same regardless of what changed while it was queued.

Wrapping up

ModelNotFoundException in a worker is almost always the gap between dispatch and execution doing its job. Set $deleteWhenMissingModels on the jobs where a vanished record genuinely means there is nothing to do, leave it off where the absence is suspicious, and check for a transaction if the failure is instant rather than occasional.

Related