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:
- Null is not "default." In serialized job payloads, null maxTries means unlimited. Your supervisor config is a suggestion, not a rule.
- Green dashboards lie. Horizon showed healthy workers happily processing jobs that would never finish.
- Framework defaults are not always sane. Laravel doesn't set
$trieson 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.

Comments