In a Laravel Bus::batch, when exactly do then, catch and finally fire, and how does allowFailures() change what one failed job does?
answer
- then: every job succeeded
- catch: first failure only
- finally: every job ran once
- first failure cancels by default
- cancelled does not stop queued jobs
basics
~20 sthen fires only when every job has succeeded; catch fires on the first failed job; finally fires once every job has run. By default the first failure cancels the batch; allowFailures() keeps it going and can call a closure per failure.
solid answer
~40 sThe batch row counts `pending_jobs` and `failed_jobs`. A success decrements `pending_jobs`; a failure increments `failed_jobs` and leaves the job pending. So `then` runs when `pending_jobs` reaches 0 — only if **every** job succeeded. `catch` runs when `failed_jobs` becomes 1 — once, for the first failure. `finally` runs when pending equals failed — every job has run to success or failure. Without `allowFailures()`, that first failure also **cancels** the batch; cancelling does not pull queued jobs, it only makes `cancelled()` return true, so each job must check it or use `SkipIfBatchCancelled`. With `allowFailures()`, the batch is not cancelled, a closure passed to it runs on each failure, and `finally` still fires — but `then` waits until the failed jobs are retried successfully, for example with `php artisan queue:retry-batch`.
code
php · 19 lines<?php
use App\Jobs\ResizeImage;
use App\Models\Upload;
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
$uploadId = $upload->id;
Bus::batch($upload->images->map(fn ($image) => new ResizeImage($image)))
->allowFailures(function (Batch $batch, $e) use ($uploadId) {
logger()->warning('image failed', ['upload' => $uploadId]);
})
->finally(function (Batch $batch) use ($uploadId) {
$status = $batch->failedJobs === 0 ? 'published' : 'needs_review';
Upload::whereKey($uploadId)->update(['status' => $status]);
})
->name("upload {$uploadId}")
->dispatch();go deeper
Recall the three callbacks: then for full success, catch for a failure, finally for the end of the batch.
Explain the pending and failed counters, default cancellation on the first failure, and why jobs must check cancelled().
Design partial-failure handling with allowFailures(), finally and retries, and choose between cancelling and continuing.
Set the policy for partial failure in bulk processing: when a batch should abort, continue, or hand results to a human for review.
## The counters behind the callbacks When a batch is dispatched, its `job_batches` row stores `total_jobs`, `pending_jobs` (initially equal to the total), `failed_jobs` (0) and `failed_job_ids`. Every batched job reports back when it finishes: - **success** — `pending_jobs` goes down by one (and the job's id leaves `failed_job_ids` if it had failed before); - **failure** — the job ran out of tries: `failed_jobs` goes up by one and the job **stays counted as pending**. Each callback is a condition on the counters right after one of those updates: | Callback | Fires when | Receives | |---|---|---| | `before` | the batch record is created, before jobs are pushed, in the dispatching process | `Batch` | | `progress` | after each successful job (and each failure, with `allowFailures()`) | `Batch` | | `then` | `pending_jobs` reaches 0 — every job succeeded | `Batch` | | `catch` | `failed_jobs` becomes 1 — the first failure | `Batch`, `Throwable` | | `finally` | `pending_jobs - failed_jobs` reaches 0 — every job ran | `Batch` | Apart from `before`, callbacks run on the worker that processed the job which tipped the counter. An exception thrown inside a callback is reported, not rethrown. ## What the first failure does by default Without `allowFailures()`, the first failure **cancels** the batch: `cancelled_at` is set and a `BatchCanceled` event is dispatched. Cancelling is cooperative: 1. jobs already on the queue are **not** removed; 2. each one still runs unless it checks `$this->batch()->cancelled()` and returns, or uses the `SkipIfBatchCancelled` job middleware; 3. a job that returns early counts as a success for the counters. So for the stock-photo zip, if image 17 of 5,000 fails and the `ResizeImage` job does not check for cancellation, the other 4,999 images are still processed — then `finally` fires, `then` never does, and a publish step placed in `then` never runs. ## What allowFailures() changes `->allowFailures()` stops the automatic cancellation. The batch keeps processing, and: - a closure passed to it, `->allowFailures(function (Batch $batch, $e) { ... })`, runs on **every** failure, where `catch` runs only on the first; - `progress` callbacks also run on failures; - `finally` still fires when all jobs have run; - `then` still requires `pending_jobs` to reach 0, so it waits until each failed job is retried and succeeds. `php artisan queue:retry-batch {id}` retries every failed job of one batch; when they succeed, `pending_jobs` reaches 0 and `then` fires. ## Designing the upload batch For 5,000 images where a few corrupt files should not block publication: 1. Dispatch with `->allowFailures(fn (Batch $b, $e) => ...)` to record each bad file. 2. Put the publish decision in `finally`, which fires even when some jobs failed, and read `$batch->failedJobs` there to decide whether to publish the successful images or flag the upload for review. 3. Keep `then` for the all-succeeded case, if you need one. 4. Use `catch` only for a first-failure alert; it will not tell you about the second. If instead any failure must abort the upload, keep the default cancellation and add `SkipIfBatchCancelled` to `ResizeImage`, so remaining jobs finish immediately and `finally` arrives sooner. ## Where the callbacks run and what they may use Batch callbacks are closures serialized into the batch's `options` column when the batch is dispatched. Apart from `before`, they are unserialized and executed by whichever worker finishes the job that tips a counter, possibly minutes later and on another server. So: - they must not reference `$this` — the docs warn against it — and every variable they `use` must be serializable; - they should be short; heavy follow-up work belongs in a job the callback dispatches; - they run inside a worker, so they see the worker's config and code version; - an exception inside one is reported and swallowed, so a broken `finally` fails quietly unless you watch the error reports. ## Common misreadings - `then` is not "the batch finished"; `finally` is the callback for "everything has run". - `$batch->finished()` is true only after `pending_jobs` reached 0, so a batch with a permanently failed job never counts as finished. - `$batch->progress()` is the share of jobs that **succeeded**, from 0 to 100; failed jobs do not move it. - `catch` does not run once per failure; `allowFailures(closure)` does.
- A Laravel batch was dispatched with allowFailures() and two jobs failed; why has its then callback never run?Failed jobs stay counted in `pending_jobs`, and `then` fires only when that reaches 0. `allowFailures()` stops cancellation but does not change that rule. Retry the failed jobs, for example with `php artisan queue:retry-batch <id>`; when they succeed, the counter reaches 0 and `then` runs. Use `finally` for logic that must run regardless.
- Why do jobs keep running after a Laravel batch is cancelled?Cancelling only sets `cancelled_at` on the batch; the jobs already on the queue stay there. Each job must check `$this->batch()->cancelled()` and return, or declare the `SkipIfBatchCancelled` middleware, to skip its work.
saying these in an interview costs you the question
- then runs when the batch finishes, even if some jobs failed
- catch is called once for every failed job in the batch
- Cancelling a batch removes its remaining jobs from the queue
- allowFailures() makes then fire despite failed jobs
- finally only runs if at least one job failed