Developer Guides

Laravel Mail Queue Failed Jobs: Retries, Timeouts, and Recovery

Fix Laravel mail queue failed jobs: inspect failed_jobs, keep timeout below retry_after, add backoff and stop double-sends. A production runbook for Laravel 13.

Published

Published by Arawa Mail.

Laravel Mail Queue Failed Jobs: Retries, Timeouts, and Recovery

Laravel mail queue jobs fail when they run out of attempts, hit a timeout, or throw an unhandled exception. Inspect failed mail jobs with php artisan queue:failed, retry them selectively with queue:retry, and prevent double-sends by keeping the job timeout at least several seconds shorter than the connection's retry_after. Add backoff for temporary errors and make every send idempotent.

How we analyzed this

We worked from the Laravel 13.x queue and Horizon documentation (failed jobs, attempts, timeouts, backoff and the job attributes) and the failure modes typical of mail jobs: slow SMTP or HTTP providers, rate limits and connection resets. The timeout and retry_after ordering rule below comes straight from the Laravel docs and is the most common cause of duplicate execution.

Inspect and recover failed mail jobs

Asynchronous jobs that exceed their maximum attempts are written to the failed_jobs table. New Laravel applications usually ship with its migration; if yours does not, create it with php artisan make:queue-failed-table followed by php artisan migrate.

php artisan queue:failed                 # list ID, connection, queue, failure time
php artisan queue:retry <id>             # retry one job
php artisan queue:retry --queue=emails   # retry every failed job from the emails queue
php artisan queue:retry all              # retry everything (fix the root cause first)
php artisan queue:forget <id>            # delete a failed job without retrying
php artisan queue:prune-failed --hours=48

Read the exception stored with each failed job for mail-specific clues: connection timeouts, SMTP 535/530 authentication errors, rate-limit responses, or template rendering failures. If support needs to see what the API actually sent, see Support Should See the Transactional Email Log.

The timeout < retry_after rule (prevents double execution)

retry_after (set per connection in config/queue.php; the Redis connection defaults to 90 seconds) is how long the queue waits before handing a job that is still being processed to another worker. The worker --timeout (default 60 seconds), or the job's own timeout, must be at least several seconds shorter so a frozen worker is killed before the job is retried. If the timeout is longer than retry_after, the job can be processed twice.

// config/queue.php
'redis' => [
    'driver' => 'redis',
    'retry_after' => 180,  // longest expected job + margin
],

// Job class (Laravel 13 attributes)
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\FailOnTimeout;

#[Timeout(120)]   // must be shorter than retry_after
#[FailOnTimeout]  // optional: fail instead of retrying after a timeout
class SendInvoiceMail implements ShouldQueue
{
    // ...
}

With Horizon the ordering is: job timeout < Horizon supervisor timeout < retry_after. Horizon's docs require the supervisor timeout to be greater than any job-level timeout and at least a few seconds shorter than retry_after.

Sockets and outgoing HTTP connections may not respect the job timeout, so also set a timeout on the client itself. The ArawaMail Laravel package uses a 10-second HTTP timeout by default (SDP_EMAIL_TIMEOUT=10), which fits comfortably inside a 60–120 second job timeout.

Retries and backoff (Laravel 13)

By default Laravel attempts a job only once. Set attempts and backoff explicitly on mail jobs:

use Illuminate\Queue\Attributes\Tries;
use Illuminate\Queue\Attributes\Backoff;

#[Tries(3)]
#[Backoff([10, 60, 300])]   // wait 10 s, then 60 s, then 300 s between retries
class SendInvoiceMail implements ShouldQueue
{
    // ...
}

You can also pass --tries and --backoff to queue:work, or define a backoff() method for computed delays. Use retryUntil() when a wall-clock deadline matters more than the attempt count. Calling $this->release($seconds) puts the job back on the queue with a delay, but it still uses an attempt, so raise the allowed tries if you release on rate limits.

Mail-specific failure patterns

  • Slow provider or large messages — raise the job timeout carefully, keep it below retry_after, and keep the SMTP or HTTP client timeout lower than the job timeout.
  • Rate limits and temporary errors — release with a delay or rely on backoff. The ArawaMail API can return 429 when there are too many send attempts, and applies a per-key limit of 60 accepted messages per minute for API-originated sending.
  • Permanent errors — do not retry them. On the ArawaMail API, 401 (invalid API key), 403 (domain not registered, key restricted to another domain, or outbound sending not enabled) and 422 (validation errors) will fail the same way every time; fix the configuration instead.
  • Double sends — queues deliver at least once, so make the job idempotent (store a unique key and check it before sending). See Prevent Duplicate Transactional Emails with Idempotency.

Horizon and production notes

Horizon supervisors have their own tries, timeout and backoff settings; a job class value for tries takes precedence. Restart workers on every deploy and prune old failed jobs so the table stays useful. For queuing mailables in the first place, see Queue Laravel Mailables with ShouldQueue, Workers, and afterCommit and Send email in Laravel. The ArawaMail Laravel package (sharasolns/sdp-email) supports queued mail through the same transport: Mail::to($user)->queue(new OrderShipped($order)) with a normal php artisan queue:work worker.

FAQ

Why does my mail job run twice?

Usually because the effective job timeout is equal to or longer than retry_after, so the job is handed to a second worker while the first is still running.

Should I use FailOnTimeout?

Use it for jobs that will not succeed after a timeout (for example a stuck connection to the mail provider). By default a timed-out job uses one attempt and is released back to the queue if retries remain.

How do I retry only email queue failures?

Run php artisan queue:retry --queue=emails after confirming the underlying issue is fixed.

What does MaxAttemptsExceededException mean?

The job used up its allowed attempts, often after repeated timeouts or releases. Check the stored exception and the timeout versus retry_after relationship first.

Sources: Laravel 13.x documentation (Queues: failed jobs, job attempts, timeouts, backoff; Horizon: supervisor tries and timeout) and the ArawaMail documentation (Laravel quickstart, API errors and rate limits).

Simple, transparent plans

Start free. Grow when your email does.

Get one domain, API access and 3,000 transactional emails every month at no cost.

Compare plans