A job gets one attempt by default. When handle() throws, the worker retries it or, after the last attempt, calls failed() and moves it to failed_jobs. Attributes override the worker's --tries, --backoff and --timeout flags:
#[Tries(3)]
#[Backoff(2, 5)]
#[Timeout(20)]
class NotifyWarehouse implements ShouldQueue
{
public function handle(): void
{
Log::info("warehouse: order {$this->order->id}, attempt {$this->attempts()}");
Http::timeout(5)->post(config('services.warehouse.url'), [
'order' => $this->order->id,
])->throw();
}
public function failed(Throwable $e): void
{
Log::error("warehouse gave up on order {$this->order->id}: {$e->getMessage()}");
}
}#[Backoff(2, 5)] waits 2 seconds, then 5, so the attempts fell at 08:58:00, :03 and :09. php artisan queue:failed then listed the job by UUID as database@default App\Jobs\NotifyWarehouse, and its row read:
mysql -E shop -e "SELECT uuid, JSON_EXTRACT(payload, '$.maxTries') AS max_tries,
SUBSTRING_INDEX(exception, ':', 2) AS exception, failed_at FROM failed_jobs"*************************** 1. row ***************************
uuid: ce8a3f0c-3eb4-4012-b31d-6387e8c43ee5
max_tries: 3
exception: Illuminate\Http\Client\RequestException: HTTP request returned status code 503
failed_at: 2026-09-23 08:58:09With the warehouse back, php artisan queue:retry all pushed the job again and it succeeded on a fresh attempt 1; queue:forget and queue:prune-failed delete rows. #[MaxExceptions(2)] caps exceptions separately from releases, retryUntil() sets a deadline, and #[FailOnTimeout] fails a timed-out job instead of retrying. Timeouts need pcntl: the worker kills itself and is restarted. Keep every timeout a few seconds below retry_after, or a slow job reaches a second worker while still running, and the customer gets two emails.