Table of Contents
If you’ve ever set $tries and $backoff on a Laravel queue job and wondered why they’re completely ignored when a model goes missing, you’ve hit the SerializesModels trap.
The Problem
When a job uses the SerializesModels trait, Laravel stores just the model’s ID in the serialized payload. When the job gets picked up by a worker, Laravel calls firstOrFail() to restore the model before your handle() method ever runs.
If that model was deleted between dispatch and execution, firstOrFail() throws a ModelNotFoundException. This exception happens during deserialization β outside the retry/backoff lifecycle entirely. Your carefully configured retry logic never gets a chance to run.
class ProcessOrder implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 5;
public $backoff = [10, 30, 60];
public function __construct(
public Order $order // Serialized as just the ID
) {}
public function handle()
{
// This never executes if the Order was deleted.
// $tries and $backoff are completely bypassed.
}
}
Why It Happens
The model restoration happens in the RestoreModel class, which uses firstOrFail(). This is called by PHP’s __wakeup() / unserialize() pipeline β way before Laravel’s retry middleware kicks in. The job fails immediately with zero retries.
The Fix: A Nullable Models Trait
Create a custom trait that returns null instead of throwing when a model is missing:
trait SerializesNullableModels
{
use SerializesModels {
SerializesModels::__serialize as parentSerialize;
SerializesModels::__unserialize as parentUnserialize;
}
public function __unserialize(array $values): void
{
try {
$this->parentUnserialize($values);
} catch (ModelNotFoundException $e) {
// Set the property to null instead of exploding
// Your handle() method can then check for null
}
}
}
Then in your handle() method:
public function handle()
{
if ($this->order === null) {
// Model was deleted β release for retry or handle gracefully
$this->release(30);
return;
}
// Normal processing...
}
The Takeaway
SerializesModels is convenient, but it creates a blind spot in your retry logic. If there’s any chance your model might be deleted between dispatch and execution β webhook jobs, async processing after user actions, anything with eventual consistency β either use the nullable trait pattern or pass the ID manually and look it up yourself in handle().
Your $tries config only works when the exception happens inside handle(). Everything before that is a different world.
Leave a Reply