Laravel Queue Retry Logic: A Practical Guide
Your Laravel queue worker crashes at 2am, three jobs fail silently, and by the time anyone notices, a customer's invoice never got emailed and a webhook never fired. Laravel's queue system gives you retry logic and failed-job handling out of the box — most teams just never configure it past the defaults, which is exactly how jobs go missing without anyone knowing.
Why Default Queue Retry Behavior Isn't Enough
Out of the box, a failed Laravel job retries based on your queue worker's --tries flag (often left at 1) and gets dumped into the failed_jobs table with no automatic alerting. That's fine for a side project. It's not fine for anything that sends emails, charges cards, syncs inventory, or talks to a third-party API that occasionally times out — the kind of job where a silent failure costs you a customer, not just a log entry.
Configuring Retries and Backoff Per Job
Instead of relying on worker-level flags, set retry behavior directly on the job class, so it travels with the job regardless of which worker picks it up:
class SyncInventoryJob implements ShouldQueue
{
use Queueable;
public $tries = 5;
public function backoff(): array
{
// Exponential-ish backoff: 10s, 30s, 60s, 120s, 120s
return [10, 30, 60, 120, 120];
}
public function retryUntil(): \DateTime
{
return now()->addHours(2);
}
public function handle(InventoryService $inventory): void
{
$inventory->sync($this->productId);
}
}
tries caps the attempt count, backoff() spaces retries out instead of hammering a struggling API immediately, and retryUntil() gives a hard time ceiling independent of attempt count — useful for jobs where "still failing after 2 hours" means something is actually broken, not just slow.
Handling Permanent Failures Gracefully
Not every failure should retry. A job that fails because a record was deleted, or an API returns a 400 for genuinely bad input, will fail identically five times in a row and waste worker capacity. Fail it immediately instead:
public function handle(): void
{
$order = Order::find($this->orderId);
if (! $order) {
$this->fail(new \RuntimeException('Order no longer exists.'));
return;
}
// normal processing
}
Calling $this->fail() moves the job straight to failed_jobs without burning through retry attempts — reserve retries for genuinely transient failures (network timeouts, rate limits, temporary API outages), not for logically impossible states.
Getting Notified When Jobs Actually Fail
A failed_jobs table nobody checks is the same as no failure handling at all. Define a failed() method on the job to fire real alerting:
public function failed(\Throwable $exception): void
{
Log::error('SyncInventoryJob permanently failed', [
'productId' => $this->productId,
'error' => $exception->getMessage(),
]);
Notification::route('slack', config('services.slack.alerts_webhook'))
->notify(new JobFailedNotification($this, $exception));
}
This runs once, after all retries are exhausted — the right place for a Slack ping, an email to on-call, or writing to a monitoring service, rather than alerting on every individual attempt.
Testing Retry and Failure Logic
It's easy to write a job's happy path and never actually test what happens when it fails. Laravel makes this straightforward with Queue::fake() and a bit of exception-forcing:
public function test_job_fails_permanently_when_order_missing(): void
{
Queue::fake();
$job = new ProcessOrderJob(orderId: 999999); // doesn't exist
$job->handle();
// Assert your fail() path ran — e.g. no side effects occurred,
// or a mocked notification was sent
Notification::assertSentTo(...);
}
Testing the retry path itself is harder to do end-to-end, but you can at least unit test that backoff() and tries return what you expect, and integration-test the failed() handler by manually invoking it with a thrown exception. A queue system's failure handling is exactly the kind of code that only gets exercised in production if it's untested — worth the extra few minutes.
Common Mistakes Teams Make
- Leaving
triesat the worker level only. Worker flags apply to everything that queue processes — job-level$triesis explicit and survives worker reconfiguration. - No backoff, immediate retries. Retrying a rate-limited API call instantly just trips the rate limit again. Always back off.
- Retrying non-idempotent jobs blindly. If a job charges a card or sends an email, make sure retrying it doesn't double-charge or double-send — check for existing state before acting, or make the operation idempotent.
- Ignoring the
failed_jobstable. Without afailed()handler or a scheduled check, failed jobs sit invisibly until someone happens to query the table.
Best Practices for Production Queue Handling
Use php artisan queue:retry all cautiously — it's useful for bulk-recovering from an outage, but blindly retrying everything can re-trigger the same root cause. Pair job-level retry config with Horizon (or basic queue monitoring) so failure rates are visible before a customer reports the problem. And write jobs to be idempotent wherever the operation allows it — a job that's safe to run twice is a job you can retry with confidence instead of anxiety.
Frequently Asked Questions
What's the difference between tries and retryUntil?
tries caps the number of attempts regardless of elapsed time; retryUntil caps elapsed time regardless of attempt count. Use whichever constraint actually matters for the job — a payment sync might need a tight time window, while a low-priority cleanup job might just need a low attempt cap.
Should every job have a failed() method?
Not every job needs custom alerting, but anything customer-facing or revenue-related should have one. A background cache-warming job failing silently is fine; a payment webhook handler failing silently is not.
Does $this->fail() count against tries?
No — calling it ends the job immediately regardless of remaining attempts, which is exactly why it's the right call for permanent, non-retryable failures.
Key Takeaways
Laravel's queue system already has the tools for reliable retry handling — job-level tries, backoff(), retryUntil(), and a failed() hook — they just need to be configured deliberately instead of left at defaults. Set retry limits and backoff per job based on whether the failure is likely transient or permanent, and wire failed() into real alerting so a failed job becomes a Slack message instead of a silent row in a database table nobody checks.