The SerializesModels Trap: Why Your Laravel Job Retries Never Run

πŸ“– 2 minutes read

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.

Daryle De Silva

VP of Technology

11+ years building and scaling web applications. Writing about what I learn in the trenches.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *