The Queue That Never Stopped

February 07, 2026

Emails were failing. That part was expected — broken SMTP credentials during a migration. What wasn't expected: they never stopped failing.


Horizon dashboard: green. Workers: healthy. Redis: slowly growing. No alerts, no errors in the logs. Just a quiet accumulation of jobs that kept trying and trying and trying.

I only noticed because Redis memory didn't come back down after fixing the SMTP config. Something was still in there, chewing through retries. Thousands of them.


I assumed the queue would handle it. That's the deal: a job fails, retries a few times, lands in failed_jobs. You move on.

Unless the job is a Mailable.

When you dispatch a Mailable to a queue, Laravel wraps it in a job. That job's maxTries comes from the Mailable's $tries property. If you don't set it — and why would you, the docs barely mention it — it serializes as null.

Null doesn't mean "use the supervisor default." Null means "no limit." Horizon sees null and thinks: this job wants to retry forever. So it does.


Turns out it's a known bug. Laravel Horizon issue #1346. The supervisor's --tries flag gets ignored when the serialized job payload carries maxTries: null. The job's own declaration wins, and its declaration says: never stop.

Twenty-nine Mailable classes. Every single one without an explicit $tries property. Every single one potentially immortal.


The fix is almost insulting in its simplicity:

class WelcomeEmail extends Mailable implements ShouldQueue
{
    public int $tries = 2;
    public int $maxExceptions = 2;
}

Two properties. Twenty-nine files. That's it.

One initial attempt, one retry, then failed_jobs. The way I assumed it always worked.


I test it the way you'd test a mousetrap. Break the SMTP config on purpose. Dispatch one email. Watch Horizon. Two attempts. Failed job. Done. No ghosts in the queue.

Then I fix the other twenty-eight.


Three lessons, condensed:

  1. Null is not "default." In serialized job payloads, null maxTries means unlimited. Your supervisor config is a suggestion, not a rule.
  2. Green dashboards lie. Horizon showed healthy workers happily processing jobs that would never finish.
  3. Framework defaults are not always sane. Laravel doesn't set $tries on Mailables. You have to. The docs won't warn you until you already have a fire.

The scariest bugs are the ones that look like normal operation. This one did — for weeks.


Boris D. Teoharov

Hey, I'm Boris

I write about software development, AI experiments, and the occasional deep dive into computer science topics that catch my interest.

Senior Software Developer at GetHookd AI with expertise in web development, AI/ML, DevOps, and low-level programming. Passionate about exploring theoretical computer science, mathematics, and the creative applications of AI.

Comments