skip to content

In a Laravel job chain, what happens to the remaining jobs when one fails, and how do catch(), onQueue() and appendToChain() behave?

level: middleimportance: should knowfreq 38%

answer

  1. failure means tries exhausted
  2. later steps are never pushed
  3. catch receives the Throwable
  4. no $this inside callbacks
  5. prependToChain and appendToChain

basics

~20 s

When a chained job fails for good, later jobs are never pushed and the chain's catch() callbacks run with the exception. onConnection() and onQueue() set defaults for every step; a running step can add work with prependToChain() or appendToChain().

solid answer

~40 s

A chain advances only when a step finishes without failing, so once a step exhausts its tries and is marked failed, the steps after it never reach the queue. At that point the chain's `catch()` callbacks run with the `Throwable`, followed by the failing job's own `failed()` method; the callbacks are serialized with the chain, so they must not use `$this`. A step that is released for a retry keeps the chain waiting, and a step that calls `$this->delete()` does **not** stop it — the next job is still pushed. `Bus::chain([...])->onConnection('redis')->onQueue('uploads')` sets the connection and queue for every step that does not set its own. Inside a step, `$this->prependToChain($job)` inserts a job to run next and `$this->appendToChain($job)` adds one at the end.

code

php · 18 lines
php
<?php

use App\Jobs\PublishCollection;
use App\Jobs\UnpackUpload;
use App\Jobs\ValidateImages;
use App\Models\Upload;
use Illuminate\Support\Facades\Bus;
use Throwable;

$uploadId = $upload->id;

Bus::chain([
    new UnpackUpload($upload),
    new ValidateImages($upload),
    new PublishCollection($upload),
])->onQueue('uploads')->catch(function (Throwable $e) use ($uploadId) {
    Upload::whereKey($uploadId)->update(['status' => 'rejected']);
})->dispatch();

go deeper

for a junior

Recall that a chain stops when a job fails and that catch() lets you react to that failure.

for a middle

Explain completed versus released versus failed versus deleted steps, and how chain-level connection and queue defaults apply.

for a senior

Design chains with idempotent steps, compensation in catch(), outcomes in the last step, and a deliberate fail() to abort.

for a principal

Judge how much workflow logic belongs in chains before retries, compensation and visibility call for a dedicated workflow design.

## What "fails" means in a chain A chain is a list of queued jobs where each is pushed only after the previous one completes. The worker decides after each step: - **completed** — `handle()` returned and the job was neither released nor marked failed: the next job is pushed; - **released** — the job threw and has tries left, or called `release()`: it goes back on the queue and the chain simply waits for that step to run again; - **failed** — the job ran out of tries, or called `fail()`: it is recorded in `failed_jobs`, and the chain ends there; - **deleted** — the job called `$this->delete()`: this still counts as "not failed", so the next job is pushed. That last row surprises people; the docs warn that deleting a job does not prevent the rest of the chain from running. ## catch() on a chain `Bus::chain([...])->catch(function (Throwable $e) { ... })->dispatch()` registers callbacks that are serialized onto the chain. When a step fails, the queue handler runs, in order: 1. the batch bookkeeping, if the step also belongs to a batch; 2. the chain's `catch` callbacks, each receiving the exception; 3. the failing job's own `failed()` method, if it defines one. Because these callbacks are stored in the payload and run later on a worker, they cannot close over `$this` from the class that built the chain, and anything they `use` must be serializable. They run for the step that failed; there is no "chain succeeded" callback — to act on success, make that action the last step. ## Connection, queue and delay for the whole chain | Call on `Bus::chain(...)` | Effect | |---|---| | `->onConnection('redis')` | every step without its own connection uses `redis` | | `->onQueue('uploads')` | every step without its own queue uses `uploads` | | `->delay(60)` | delays the first job only if it has no delay of its own | | `->catch(fn)` | runs on the failure of any step | A job that explicitly sets its own connection or queue keeps it; the chain's values are defaults. ## Changing the chain while it runs A step can reshape what comes after it: - `$this->prependToChain(new ExtractMetadata($upload))` — runs right after the current step; - `$this->appendToChain(new NotifyPhotographer($upload))` — runs after the last step. For the stock-photo upload, `UnpackUpload` might discover a sidecar metadata file and prepend an `ExtractMetadata` step only when it exists. Both methods come from the bus `Queueable` trait, which the job stub's `Illuminate\Foundation\Queue\Queueable` includes. ## A worked failure `Bus::chain([new UnpackUpload($u), new ValidateImages($u), new PublishCollection($u)])`: 1. `UnpackUpload` succeeds; `ValidateImages` is pushed. 2. `ValidateImages` throws on a corrupt file. It allows three tries, so it is released and retried, and the chain waits. 3. The third attempt throws too; the job is failed and stored in `failed_jobs`. 4. The chain's `catch` callback marks the upload as rejected. 5. `PublishCollection` is never pushed. Retrying the failed `ValidateImages` job later from `failed_jobs` resumes the chain, because the remaining steps are still stored in that job's payload. ## Why split work into a chain at all One job could unpack, validate and publish in a single `handle()`. Splitting it into a chain buys three things: 1. **Independent retries** — a failure while publishing retries only the publish step, not the whole unpack. 2. **Per-step settings** — each step can have its own queue, connection, tries and timeout, so a slow validation step can run on a long-running connection. 3. **Visibility** — `failed_jobs` names the exact step that failed. The cost is overhead per step — a push, a pop and a model re-fetch — and state passed between steps must travel through the database or the constructor arguments, not memory. ## Design tips - Keep each step idempotent, because a released step runs again from the top. - Put the user-visible outcome — publishing, emailing — in the last step, so it only happens when all earlier steps succeeded. - Use `catch` for compensation such as marking a record failed, not for retries; retries belong to the job's own policy. - Do not rely on `$this->delete()` to abort a chain; call `$this->fail()` to stop it deliberately. - Keep the chain short and the payload small: every remaining step is serialized inside the job currently on the queue.

  • A Laravel chain step decides the rest of the chain must not run; should it call $this->delete() or $this->fail()?
    `$this->fail()`. The worker pushes the next step whenever the current job has not failed and was not released, and a deleted job has done neither, so the chain continues. Failing the job stops the chain, runs its `catch` callbacks and records the failure.
  • Can you retry a Laravel chain from the step that failed?
    Yes. The remaining steps are serialized inside the failed job's payload, so retrying that job from `failed_jobs` re-runs it and, on success, pushes the next step as usual. Earlier steps are not repeated.

saying these in an interview costs you the question

  • A chain moves on to the next job as soon as a step throws once
  • Calling $this->delete() in a chain step stops the remaining jobs
  • A chain's catch() callback can safely use $this from the dispatching class
  • onQueue() on a chain overrides a queue a job sets for itself
  • The chain's catch() callback runs instead of the failing job's failed() method