skip to content

In a Laravel 13 queued job, how do tries, backoff and retryUntil control retries, and what happens after the last attempt?

level: middleimportance: must knowfreq 64%

answer

  1. attempts counted, not retries
  2. worker --tries defaults to 1
  3. #[Tries] and #[Backoff] attributes
  4. backoff array: last value repeats
  5. retryUntil beats tries; failed() then failed_jobs

basics

~20 s

Tries 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 s

An 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
<?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

for a junior

Remember that a job runs once unless you give it tries, and that failed jobs land in failed_jobs where you can see them.

for a middle

Explain attempts versus retries, how an array backoff is indexed and repeats its last value, and why retryUntil overrides tries.

for a senior

Show that limits are frozen into the payload at dispatch, pair deadlines with MaxExceptions, and know which exception failed() receives in each case.

for a principal

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.