In a Laravel 13 queued job, how do tries, backoff and retryUntil control retries, and what happens after the last attempt?
answer
- attempts counted, not retries
- worker --tries defaults to 1
- #[Tries] and #[Backoff] attributes
- backoff array: last value repeats
- retryUntil beats tries; failed() then failed_jobs
basics
~20 sTries caps total attempts (one by default), backoff sets the seconds before an exception-triggered retry, and retryUntil swaps the count for a deadline. After the last attempt the job is deleted, failed() runs and failed_jobs records it.
solid answer
~40 sAn attempt is every pickup by a worker, so `tries` counts total attempts, not retries. The job's own value (`#[Tries(5)]`, a `$tries` property or a `tries()` method) overrides the worker's `--tries`, which defaults to 1, so by default a job runs once. When `handle()` throws and attempts remain, the worker releases the job after the delay from `#[Backoff]`, `$backoff` or `backoff()`; an array such as `[10, 60, 300]` gives one delay per retry and repeats the last. A `retryUntil()` method returning a `DateTime` replaces the count with a deadline, fixed at dispatch. Once the limit is hit, the job is deleted from the queue, `failed(?Throwable $e)` runs on a fresh instance, a `JobFailed` event fires and the worker writes a row to `failed_jobs`.
code
php · 30 lines<?php
namespace App\Jobs;
use App\Models\Listing;
use App\Services\Geocoder;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\Tries;
use Throwable;
#[Tries(4)]
#[Backoff([10, 60, 300])]
class GeocodeListing implements ShouldQueue
{
use Queueable;
public function __construct(public Listing $listing) {}
public function handle(Geocoder $geocoder): void
{
$this->listing->update($geocoder->lookup($this->listing->address));
}
public function failed(?Throwable $exception): void
{
$this->listing->update(['geocode_status' => 'failed']);
}
}go deeper
Remember that a job runs once unless you give it tries, and that failed jobs land in failed_jobs where you can see them.
Explain attempts versus retries, how an array backoff is indexed and repeats its last value, and why retryUntil overrides tries.
Show that limits are frozen into the payload at dispatch, pair deadlines with MaxExceptions, and know which exception failed() receives in each case.
Frame retry policy as a team default: which jobs deserve deadlines, how backoff protects dependencies, and who owns reviewing failed_jobs.
## What counts as an attempt In Laravel's queue system an **attempt** is one pickup of the job by a worker, not one successful run of `handle()`. The attempt counter goes up whenever: - `handle()` throws an unhandled exception; - the job calls `$this->release()` on itself; - job middleware such as `RateLimited` or `WithoutOverlapping` releases it without running it; - the job times out; - the job runs and completes normally. So `tries` is the **total number of attempts allowed**, not the number of retries after the first one: a job with `#[Tries(3)]` that keeps throwing runs three times and then fails. ## Where the limit comes from | Source | Example | Notes | |---|---|---| | Worker flag | `php artisan queue:work --tries=3` | Defaults to `1`; used for every job that sets no limit of its own | | Attribute (Laravel 13) | `#[Tries(5)]` | `Illuminate\Queue\Attributes\Tries` | | Property | `public $tries = 5;` | Still supported; a non-default property value wins over the attribute | | Method | `public function tries(): int` | Overrides both the attribute and the property | A job-level value always beats the worker flag. A value of `0`, on the job or as `--tries=0`, means **retry forever**, which is only sensible together with a deadline or an exception cap. ## Spacing the retries: backoff When `handle()` throws and attempts remain, the worker releases the job with a **backoff** delay in seconds: - `--backoff` on the worker defaults to `0`, so by default a failing job is retried immediately. - `#[Backoff(30)]`, `public $backoff = 30;` or a `backoff()` method set it per job. - An array such as `#[Backoff([10, 60, 300])]` gives one delay per retry: 10 s before the second attempt, 60 s before the third, 300 s before the fourth, and **the last value repeats** for any retry after that. Backoff applies only when the *worker* releases a job after an uncaught exception. `$this->release(120)` and releases made by job middleware carry their own delays. ## A deadline instead of a count: retryUntil A `retryUntil()` method that returns a `DateTime` switches the job from counting to a **deadline**: while the current time is before it, the job may be attempted any number of times, and `tries` is ignored. Two details matter in production: 1. The deadline is computed **when the job is dispatched** and stored in the payload as a timestamp, so it is measured from dispatch, not from the first attempt. 2. A deadline allows unlimited exception-driven retries, so pair it with `#[MaxExceptions(3)]` (or `$maxExceptions`) when a job that keeps throwing should still fail before the deadline. ## What happens after the last attempt When the limit is reached, Laravel: 1. marks the job as failed and deletes it from the queue; 2. unserializes a **fresh instance** of the job and calls its `failed(?Throwable $exception)` method, if it has one, so property changes made inside `handle()` are gone; 3. dispatches the `Illuminate\Queue\Events\JobFailed` event; 4. has the `queue:work` worker record it in the `failed_jobs` table (the skeleton's failed-job driver is `database-uuids`) with its UUID, connection, queue, payload, exception and `failed_at`. The exception handed to `failed()` depends on how the job ran out. It is the exception thrown on the final attempt when that attempt threw; `MaxAttemptsExceededException` when the job is picked up after it has already used its attempts, typically after releases; and `TimeoutExceededException` when it timed out. Jobs dispatched synchronously never reach `failed_jobs`: their exception surfaces immediately in the calling code. Setting `QUEUE_FAILED_DRIVER=null` discards failed jobs instead of storing them. ## Common misreadings - **"Tries means retries."** It means total attempts, the first run included. - **"Jobs retry a few times by default."** A job that declares nothing runs once, because the worker's `--tries` is 1. - **"Backoff spaces every retry."** Only exception-triggered retries; a manual `release()` uses the delay you pass. - **"retryUntil adds a deadline on top of tries."** It replaces the count: while the deadline holds, `tries` is not checked. ## Settings are frozen at dispatch `tries`, `backoff`, `maxExceptions`, `timeout`, `failOnTimeout` and the `retryUntil` timestamp are read when the job is **dispatched** and written into its payload; the worker reads them from there. Changing the class and deploying affects new dispatches only, while jobs already waiting keep the limits they were queued with. ```php use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Queue\Attributes\Backoff; use Illuminate\Queue\Attributes\Tries; #[Tries(4)] #[Backoff([10, 60, 300])] class GeocodeListing implements ShouldQueue { use Queueable; // at most 4 runs: now, then +10 s, +60 s and +300 s after each failure } ```
- If a Laravel job defines both #[Tries(5)] and a retryUntil() method, which one wins?`retryUntil()` wins. When the payload carries a retry-until timestamp, the worker ignores the attempt count and keeps retrying until that time passes; a failure after the deadline ends the job. Add `#[MaxExceptions]` if repeated exceptions should stop it earlier.
- You raise $tries on a job class and deploy. Do jobs already waiting on the queue get the new limit?No. Laravel copies tries, backoff, timeout and the retry-until timestamp into the job's payload at dispatch, and the worker reads them from the payload. Jobs queued before the deploy keep their old limits; only new dispatches pick up the change.
- What does the #[FailOnTimeout] attribute change for a Laravel job?By default a timed-out job consumes one attempt and is retried if attempts remain. With `#[FailOnTimeout]` (or `public $failOnTimeout = true`) the first timeout marks it failed regardless of `tries`, and `failed()` receives a `TimeoutExceededException`.
saying these in an interview costs you the question
- $tries = 3 means three retries after the first run, four runs in total.
- Queued jobs retry three times by default without any configuration.
- Backoff delays also apply to manual $this->release() calls.
- When both are set, tries still caps attempts even before the retryUntil deadline.
- Changing $tries in code immediately changes jobs already waiting in the queue.