Table of Contents
You dispatch a queue job right after saving a model. The job picks it up in milliseconds. And then — ModelNotFoundException.
The model definitely exists. You just created it. You can query it manually. But the queue worker says otherwise.
The Culprit: Read Replicas
If your database uses read replicas (and most production setups do), there’s a lag between the primary and the replicas. Usually milliseconds, sometimes longer under load.
Laravel’s SerializesModels trait only stores the model’s ID when the job is serialized. When the worker deserializes it, it runs a fresh query — against the read replica. If the replica hasn’t caught up yet, the model doesn’t exist from the worker’s perspective.
The cruel part: this happens before your handle() method runs. Your retry logic never fires because the job fails during deserialization.
The Fix: afterCommit()
Laravel has a built-in solution. Add afterCommit to your job:
class GenerateInvoice implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $afterCommit = true;
public function handle(): void
{
// This now runs after the transaction commits
// and the replica has had time to sync
}
}
Or dispatch with the method:
GenerateInvoice::dispatch($invoice)->afterCommit();
Other Options
If afterCommit isn’t enough (replicas can still lag after commit), you have two more tools:
// Option 1: Add a small delay
GenerateInvoice::dispatch($invoice)->delay(now()->addSeconds(5));
// Option 2: Skip missing models instead of failing
public $deleteWhenMissingModels = true;
Option 2 is a silent skip — the job just disappears. Use it only when the job is truly optional (like sending a notification for a model that might get deleted).
The Lesson
If you’re dispatching queue jobs immediately after writes and seeing phantom ModelNotFoundException errors, check your database topology. Read replicas + SerializesModels + fast workers = a race condition that only shows up under load. afterCommit() is the cleanest fix.
Leave a Reply