skip to content

Retries & Failure Handling

A job retries per $tries, $backoff or retryUntil, can release or fail itself, and lands in failed_jobs after its last attempt. Interviewers probe backoff, timeouts and replaying failed work.

on this pageshow

explore

questions

5

In Laravel, which Artisan commands list, retry and delete failed queued jobs, and what does queue:retry actually do with a failed job?

level: juniorimportance: must knowfreq 56%

answer

  1. the failed_jobs table
  2. queue:failed lists UUIDs
  3. queue:retry <id> or all
  4. queue:forget one, queue:flush all
  5. queue:prune-failed keeps 24 hours

basics

~10 s

php artisan queue:failed lists failed_jobs, queue:retry <id> or all pushes the stored payload back onto its original queue, queue:forget <id> deletes one record, queue:flush deletes all, and queue:prune-failed removes old ones.

solid answer

~40 s

`php artisan queue:failed` lists the `failed_jobs` table with each job's UUID, connection, queue, class and failure time. `queue:retry` takes one or more IDs, `all`, or `--queue=name`: it resets the attempt count, recomputes any `retryUntil()` deadline, pushes the stored payload onto the original connection and queue, and deletes the failed row. The payload is the one created at dispatch, so constructor data and limits come back unchanged, but the worker runs the class's current code, so deploy the fix first. `queue:forget <id>` deletes one record, `queue:flush` deletes them all (`--hours` keeps newer ones), and `queue:prune-failed` deletes records older than 24 hours by default.

code

bash · 12 lines
bash
# see what failed and why
php artisan queue:failed

# after deploying the fix: one first, then the rest of the queue
php artisan queue:retry 9f3c2d1e-6b7a-4c2e-9d4f-1a2b3c4d5e6f
php artisan queue:retry --queue=geocoding

# drop a job that should never run again
php artisan queue:forget 0b1c2d3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e

# keep one week of history
php artisan queue:prune-failed --hours=168

go deeper

for a junior

Know queue:failed to look, queue:retry to replay, queue:forget and queue:flush to delete, and that IDs are UUIDs.

for a middle

Explain that queue:retry re-pushes the dispatch-time payload with a reset attempt count and removes the failed row.

for a senior

Replay safely: fix and deploy first, retry one job, consider repeated side effects, and prune failed_jobs on a schedule.

for a principal

Define who owns failed_jobs triage, how long payloads are kept, and when a failure class warrants code changes rather than replays.

## Where failed jobs go When a queued job runs out of attempts, or calls `$this->fail()`, the `queue:work` worker writes it to the **`failed_jobs`** table. New Laravel applications already include its migration; older ones can create it with `php artisan make:queue-failed-table`. Each row holds: - a **UUID** (the skeleton's failed-job driver is `database-uuids`), used as the ID by every command below; - the **connection** and **queue** the job came from; - the full serialized **payload**; - the **exception** text with its stack trace; - **`failed_at`**. ## The commands | Command | What it does | |---|---| | `php artisan queue:failed` | Lists failed jobs: ID, connection, queue, class and failure time (`--json` for machine output) | | `php artisan queue:retry <id> [<id>...]` | Pushes the given jobs back onto their queues | | `php artisan queue:retry all` | Retries every failed job; `--queue=name` limits it to one queue, `--range=1-5` takes numeric IDs | | `php artisan queue:forget <id>` | Deletes one failed-job record without retrying it | | `php artisan queue:flush` | Deletes all failed-job records; `--hours=48` keeps newer ones, `--queue=name` limits it to one queue | | `php artisan queue:prune-failed` | Deletes records older than 24 hours by default; `--hours=N` changes the window | When Horizon manages the queues, `horizon:forget` replaces `queue:forget`. ## What queue:retry actually does For each ID, `queue:retry`: 1. reads the stored payload from `failed_jobs`; 2. resets the attempt count, so the job starts with its full `tries` again; 3. re-evaluates `retryUntil()`, if the job defines one, so an expired deadline does not fail it instantly; 4. pushes the payload onto the **original connection and queue**; 5. deletes the row from `failed_jobs`. The payload is the one created at **dispatch**. The job's constructor data, and the `tries`, `backoff` and `timeout` values baked in at dispatch, come back unchanged. The **code** is not stored: the worker loads the job class as it exists on disk, so a fix to `handle()` applies once the workers run the new release. ## A safe replay routine 1. Find the cause in the `exception` column of the `failed_jobs` rows; `queue:failed` itself shows only the ID, connection, queue, class and time. 2. Fix the code or the data, deploy, and make sure the workers have picked up the new code. 3. Retry a single ID first and watch it succeed. 4. Retry the rest with `queue:retry all` or `--queue=`. 5. `queue:forget` the jobs that should never run again. A retry runs the job **again**. If the first attempt got halfway through, charging a card or sending an email before it failed, the retry repeats that part unless the job is written to be safe to repeat. ## Where the records live The `failed` block of `config/queue.php` chooses the storage. Its driver comes from `QUEUE_FAILED_DRIVER` and the skeleton defaults to `database-uuids`; the supported values listed there are `database-uuids`, `dynamodb`, `file` and `null`. All the commands above work through that driver, so they behave the same whichever store holds the rows; with the default driver, the IDs they take are the stored UUIDs. ## When not to retry - The failure came from **bad data** that is still bad: fix the data first or `queue:forget` the job. - The job's model was **deleted** while it waited: the retry fails with the same `ModelNotFoundException`, unless the job has `#[DeleteWhenMissingModels]`. - Newer work has **superseded** it, such as a later geocoding job for the same listing: replaying the old one would overwrite fresh results. ## Housekeeping `failed_jobs` grows forever unless something removes rows. Scheduling `queue:prune-failed` keeps it bounded, while `queue:flush` is an all-at-once reset best kept out of production routines. To stop storing failed jobs entirely, set `QUEUE_FAILED_DRIVER=null`, at the cost of losing the payloads you would need to replay. ```bash php artisan queue:failed php artisan queue:retry 9f3c2d1e-6b7a-4c2e-9d4f-1a2b3c4d5e6f php artisan queue:retry --queue=geocoding php artisan queue:forget 9f3c2d1e-6b7a-4c2e-9d4f-1a2b3c4d5e6f php artisan queue:prune-failed --hours=168 ```

  • A failed job's handle() charged a card, then threw. Is queue:retry safe for it?
    Not by itself. `queue:retry` runs the whole job again from the stored payload, so any side effect before the failure repeats. Check whether the work already happened before retrying, or make the job skip steps it has completed, and `queue:forget` jobs that must not run twice.
  • Why can a retried job still fail with the old limits after you changed #[Tries] in code?
    `tries`, `backoff` and `timeout` are copied into the payload at dispatch, and `queue:retry` re-pushes that same payload; it only resets the attempt count and refreshes the `retryUntil()` deadline. The new attribute applies to jobs dispatched after the deploy.

saying these in an interview costs you the question

  • queue:retry re-runs the old code that was stored with the job.
  • queue:flush only removes failed jobs older than 24 hours.
  • The failed_jobs row stays until the retried job succeeds.
  • queue:retry puts jobs onto the default queue, not their original one.
  • Retrying a failed job is always safe because the first run failed.
open as a page

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%

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.

open as a page

Inside a Laravel queued job's handle() method, what is the difference between calling $this->release(), calling $this->fail() and throwing an exception?

level: middleimportance: should knowfreq 44%

basics

~20 s

$this->release($delay) requeues the job for a later attempt, $this->fail() marks it failed immediately whatever tries remain, and throwing lets the worker retry with backoff until tries run out. Neither call stops handle(), so return afterwards.

open as a page

In Laravel, when would you attach the RateLimited, WithoutOverlapping or ThrottlesExceptions job middleware, and how does each one affect a job's attempts?

level: seniorimportance: should knowfreq 30%

basics

~20 s

RateLimited enforces a known quota through a named limiter, WithoutOverlapping lets one job per key run at a time using a cache lock, and ThrottlesExceptions backs off after repeated exceptions. All three release jobs, and each release consumes an attempt.

open as a page

A Laravel job that geocodes imported property listings uses the RateLimited job middleware, and during a large import many jobs land in failed_jobs with MaxAttemptsExceededException although handle() never threw — why, and how do you fix it?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Every release by RateLimited consumes an attempt, and with the default of one try the next pickup exceeds the limit and fails. Give the job a retryUntil() deadline plus #[MaxExceptions] so real errors still fail fast.

open as a page