Why Default Retry Behaviour Isn't Enough
Laravel's queue system ships with $tries and $backoff on every job. Most teams set $tries = 3 and call it done. That works until you hit a flaky third-party API, a brief database overload, or a downstream service that needs 30 seconds to recover — not 3 seconds.
This article covers three concrete improvements: exponential backoff with jitter, per-exception retry logic, and a dead-letter pattern that keeps failed jobs observable and replayable.
Exponential Backoff with Jitter
A flat $backoff = 5 means every retry hammers the same resource at the same cadence. Exponential backoff spreads load; jitter prevents the thundering-herd problem when many jobs fail simultaneously.
class SyncOrderToWarehouse implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 6;
public int $maxExceptions = 3;
// Called by Laravel to determine delay before each attempt.
public function backoff(): array
{
return [
$this->jitter(10), // attempt 2: ~10s
$this->jitter(30), // attempt 3: ~30s
$this->jitter(60), // attempt 4: ~60s
$this->jitter(120), // attempt 5: ~120s
$this->jitter(300), // attempt 6: ~300s
];
}
private function jitter(int $base): int
{
return $base + random_int(0, (int) ($base * 0.2));
}
public function handle(WarehouseClient $client): void
{
$client->sync($this->order);
}
}
Returning an array from backoff() maps each value to the corresponding retry attempt. Laravel falls back to the last value for any remaining attempts beyond the array length.
Per-Exception Retry Logic
Not all exceptions are equal. A RateLimitException deserves a long wait; a ValidationException should fail immediately without retrying at all.
public function handle(WarehouseClient $client): void
{
try {
$client->sync($this->order);
} catch (RateLimitException $e) {
// Re-release with a specific delay, not the backoff schedule.
$this->release($e->retryAfter());
} catch (\InvalidArgumentException $e) {
// Permanent failure — don't retry, go straight to failed table.
$this->fail($e);
}
}
$this->release(int $delay) puts the job back on the queue with a custom delay without consuming a retry attempt. $this->fail(Throwable $e) marks the job failed immediately, bypassing remaining tries.
Dead-Letter Queue Pattern
Laravel's failed_jobs table is a dead-letter store, but it's passive. A production system needs active monitoring and a replay path.
Step 1 — Custom Failed Job Handler
Register a callback in AppServiceProvider:
Queue::failing(function (JobFailed $event) {
Log::critical('Job permanently failed', [
'job' => $event->job->getName(),
'connection' => $event->connectionName,
'queue' => $event->job->getQueue(),
'payload' => $event->job->payload(),
'exception' => $event->exception->getMessage(),
]);
// Optionally push to a dedicated dead-letter queue for inspection.
dispatch(new DeadLetterJob($event->job->payload()))
->onQueue('dead-letter');
});
Step 2 — Replay Command
class ReplayDeadLetterCommand extends Command
{
protected $signature = 'queue:replay-dead-letter {--limit=50}';
public function handle(): void
{
DB::table('failed_jobs')
->latest()
->limit((int) $this->option('limit'))
->get()
->each(function (object $row) {
Artisan::call('queue:retry', ['id' => [$row->uuid]]);
$this->line("Retried: {$row->uuid}");
});
}
}
Pair this with a Filament resource over failed_jobs for a UI-driven replay workflow.
$maxExceptions vs $tries
These two properties are frequently confused:
| Property | Meaning |
|---|---|
| $tries | Maximum total attempts (including first run) |
| $maxExceptions | Max unhandled exceptions before marking failed, regardless of $tries |
Set $maxExceptions lower than $tries when you use $this->release() manually — otherwise a job that keeps rate-limiting itself will never count those releases against $tries, but unhandled exceptions will still accumulate.
Key Takeaways
- Return an array from
backoff()to define per-attempt delays; add jitter to avoid thundering herds. - Use
$this->release($delay)for recoverable waits and$this->fail($e)for permanent errors — both bypass the default backoff schedule. $maxExceptionscaps unhandled exceptions independently of$tries; understand the distinction before combining them.- A
Queue::failing()callback turns the passivefailed_jobstable into an active alerting and dead-letter pipeline. - A replay command over
failed_jobsgives you a safe, auditable path back to production queues.