DEV Community

Faisal Nadeem
Faisal Nadeem

Posted on

The Laravel Queue Bug That Only Shows Up in Production

You won't find this bug in local development. Your queue driver is probably set to sync, or you're testing with a handful of seeded rows that never change state mid-flight. It only shows up once you're running Horizon against real traffic, with real users doing things like cancelling an order, deleting an account, or triggering a refund at the exact moment a background job for that same record is sitting in a Redis queue waiting its turn.

Then one morning you open the Horizon dashboard and see a wall of ModelNotFoundException entries in your failed jobs table — for records that, as far as anyone can tell, definitely exist.

Why this happens

Laravel's SerializesModels trait is one of those pieces of framework magic that's easy to use without fully understanding. When you type-hint an Eloquent model in a queued job's constructor, Laravel doesn't serialize the entire model object into the queue payload. It serializes a lightweight reference instead — the model's class and its primary key — and re-fetches the actual record from the database when a worker picks the job up.

class SendWelcomeEmail implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public User $user) {}

    public function handle(): void
    {
        Mail::to($this->user->email)->send(new WelcomeEmail($this->user));
    }
}
Enter fullscreen mode Exit fullscreen mode

That's a sensible design — you don't want to bloat your queue payloads with full serialized objects, and you want the job to operate on fresh data rather than a stale snapshot from dispatch time. But it creates a gap. Between the moment SendWelcomeEmail::dispatch($user) runs and the moment a worker actually calls handle(), anything can happen to that row. If the record gets soft-deleted, hard-deleted, or the primary key changes for any reason, Laravel throws a ModelNotFoundException while trying to re-hydrate the model — before your handle() method ever runs.

The scenario that actually bites you

Here's a realistic version of how this plays out. A user requests account deletion. Somewhere in your deletion flow, a job gets dispatched — maybe a SendAccountExportJob triggered moments earlier, or a SyncUserToAnalyticsJob that was queued from an unrelated action. The user's account deletion request processes first (or the queue happens to be backed up), the record is gone, and now the job in flight has no model to re-fetch.

Multiply this by a payment refund happening seconds after a SendReceiptJob was dispatched, or an order cancellation racing a GenerateInvoicePdfJob. None of these are exotic edge cases — they're normal user behavior colliding with normal queue latency. The difference between "fine in staging" and "alert fatigue in production" is just concurrency and volume.

The fix isn't a try/catch

The instinctive fix is to wrap handle() in a try/catch for ModelNotFoundException. That doesn't work, because the exception happens during the framework's own unserialization step, before your job's handle() method is even invoked. By the time your code would run, it's too late to catch anything inside it.

There are two patterns that actually solve this:

1. Don't rely on automatic re-hydration for anything that might disappear. Pass the ID instead of the model, and do the lookup yourself with an explicit existence check:

class SendWelcomeEmail implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public function __construct(public int $userId) {}

    public function handle(): void
    {
        $user = User::withTrashed()->find($this->userId);

        if (! $user) {
            // Decide deliberately: log it, skip silently, or fail the job.
            return;
        }

        Mail::to($user->email)->send(new WelcomeEmail($user));
    }
}
Enter fullscreen mode Exit fullscreen mode

This trades a little convenience for control. You decide what "the record is gone" means for this specific job, instead of letting a generic exception decide for you.

2. When you do use automatic model injection, implement failed() deliberately. If a job's model genuinely should always exist and its absence is worth investigating, let it fail — but route that failure somewhere useful instead of a Slack channel that everyone eventually mutes:

public function failed(\Throwable $exception): void
{
    if ($exception instanceof ModelNotFoundException) {
        Log::warning('Job skipped: model no longer exists', [
            'job' => static::class,
        ]);
        return;
    }

    // Genuine failures still get escalated.
    report($exception);
}
Enter fullscreen mode Exit fullscreen mode

The broader lesson

This bug is really a special case of a more general problem: the state a job was dispatched with and the state the world is in when that job executes are two different snapshots in time, and the gap between them is not zero. It's easy to write queue jobs as if dispatch() and handle() happen atomically. They don't, and under real load, the gap is exactly wide enough for something to change underneath you.

Once you start treating that gap as a real design constraint — checking for existence explicitly, wrapping state-mutating jobs in transactions, and deciding on purpose what "not found anymore" means for each job — this entire category of 2am alerts mostly disappears.


I'm Faisal Nadeem, a full-stack engineer who's hit this exact bug more than once shipping production Laravel SaaS products. If you're dealing with queue reliability issues, or building a Laravel backend that needs to hold up under real concurrency, I take on Laravel engagements — details at faisalnadeem.net/hire-a-laravel-developer.

Top comments (0)