How do you make a Laravel job batchable, report a batch's progress to the UI, and cancel the batch from inside one of its jobs?
answer
- the Illuminate Bus Batchable trait
- $this->batch() returns the Batch
- Bus::findBatch($id) is JSON-ready
- cancel() plus SkipIfBatchCancelled
- queue:prune-batches --hours=24
basics
~10 sAdd the Batchable trait (make:job --batched does it) so $this->batch() returns the batch. Return Bus::findBatch($id) from a route for progress JSON, and call $this->batch()->cancel() in a job; other jobs skip work by checking cancelled().
solid answer
~40 sA job joins a batch through the `Illuminate\Bus\Batchable` trait, which adds `batch()`, `batching()` and `withBatchId()`; `php artisan make:job --batched` generates it with a `cancelled()` check. Batch state lives in the `job_batches` table, which the Laravel 13 skeleton's jobs migration already creates (`make:queue-batches-table` adds it to apps without it). Keep `$batch->id` after dispatch and expose `Bus::findBatch($id)` from a route: `Batch` is JSON-serializable, giving `totalJobs`, `pendingJobs`, `failedJobs`, `progress()` and cancellation state. To stop the batch, a job calls `$this->batch()->cancel()`; other jobs return early on `$this->batch()->cancelled()` or use the `SkipIfBatchCancelled` middleware. Schedule `queue:prune-batches` (24 hours by default, with `--unfinished` and `--cancelled`) so the table does not grow forever.
code
php · 26 lines<?php
use App\Models\Image;
use Illuminate\Bus\Batchable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\SkipIfBatchCancelled;
class ResizeImage implements ShouldQueue
{
use Batchable, Queueable;
public function __construct(public Image $image) {}
public function middleware(): array
{
return [new SkipIfBatchCancelled];
}
public function handle(): void
{
if ($this->image->upload->owner->isSuspended()) {
$this->batch()->cancel();
}
}
}go deeper
Recall the Batchable trait, $this->batch(), and that Bus::findBatch() returns the batch for a progress endpoint.
Explain the job_batches columns, what progress() counts, and why cancel() needs cooperating jobs or SkipIfBatchCancelled.
Build a progress UI, grow large batches with loader jobs, and schedule pruning that covers unfinished and cancelled batches.
Decide how batch state is stored, exposed and retained, including who may view or cancel another user's batch.
## Making a job batchable A batched job is an ordinary queued job with one more trait: - `Illuminate\Bus\Batchable` stores the batch id on the job when the batch adds it; - `$this->batch()` fetches the current `Illuminate\Bus\Batch` from the batch repository, or returns null outside a batch; - `$this->batching()` is true while the batch is neither finished nor cancelled. `php artisan make:job ResizeImage --batched` generates the queued stub with `use Batchable, Queueable;` and an early return when `$this->batch()->cancelled()`. A job without the trait cannot report to a batch. ## Where batch state lives `config/queue.php` has a `batching` section: the `database` connection (`DB_CONNECTION` in the skeleton) and the `table`, `job_batches`. The Laravel 13 skeleton's `0001_01_01_000002_create_jobs_table` migration creates `jobs`, `job_batches` and `failed_jobs` together, so a new app needs nothing extra. Apps without it run `php artisan make:queue-batches-table` and migrate. The table holds, per batch: | Column | Meaning | |---|---| | `id`, `name` | UUID and the optional `->name()` | | `total_jobs`, `pending_jobs`, `failed_jobs` | the counters behind every callback | | `failed_job_ids` | ids of jobs that failed | | `options` | serialized callbacks, connection and queue | | `cancelled_at`, `created_at`, `finished_at` | lifecycle timestamps | DynamoDB is also supported as batch storage, through `queue.batching.driver`. ## Reporting progress 1. Dispatch the batch and keep its id: `$batch = Bus::batch($jobs)->name("upload {$upload->id}")->dispatch();`, then store `$batch->id` on the upload. 2. Add a route that returns `Bus::findBatch($upload->batch_id)`; because `Batch` implements `JsonSerializable`, Laravel returns its fields as JSON. 3. Poll it from the page to draw a progress bar. The useful fields: - `totalJobs`, `pendingJobs`, `failedJobs` — raw counters; - `processedJobs()` — `totalJobs - pendingJobs`, so successful jobs only; - `progress()` — that as a rounded percentage from 0 to 100; - `finished()` and `cancelled()` — lifecycle flags. A `progress` callback on the batch can push the same data to the UI instead of polling, since it runs after each successful job. ## Exposing progress safely `Bus::findBatch()` takes any batch id and returns the `Batch`, or null when none exists; it knows nothing about who started the batch. That is why the route above resolves the batch through the user's own `Upload` record instead of accepting a raw batch id in the URL: - store the batch id on a model the user owns; - authorize access to that model as for any other page; - return a 404 when the stored id finds no batch, for example after pruning. The JSON also includes the batch's stored `options` array, so consider returning only the counters and `progress` the page needs rather than the whole object. ## Cancelling from inside a job A job that discovers the whole batch is pointless — the photographer's account was suspended mid-upload — calls `$this->batch()->cancel()` and returns. Cancelling sets `cancelled_at`; it does not remove queued jobs. The other jobs therefore need a guard: - an explicit `if ($this->batch()->cancelled()) { return; }` at the top of `handle()`; - or `public function middleware(): array { return [new SkipIfBatchCancelled]; }`, which skips `handle()` for a cancelled batch. Either way, the skipped jobs still count as processed, so `finally` eventually fires and the batch can be cleaned up. ## Growing a batch from inside Pushing 5,000 jobs from a web request is slow. The docs suggest dispatching a few loader jobs in the batch and letting each call `$this->batch()->add($moreJobs)`; jobs can only be added from a job that belongs to the same batch. Each `add()` increases `total_jobs` and bulk-pushes the new jobs inside a repository transaction. ## Batch lifecycle events Instead of callbacks, application code can listen for events the bus dispatches as a batch moves through its life, all in `Illuminate\Bus\Events`: - `BatchDispatched` — after `dispatch()` has stored the batch and pushed its jobs; - `BatchStarted` — when the first job is processed, added in Laravel 13.3; - `BatchFinished` — when `pending_jobs` reaches 0; - `BatchCanceled` — when the batch is cancelled, carrying the exception that caused it when there was one. Listeners suit cross-cutting reactions, such as metrics for every batch in the app, while callbacks suit logic specific to one batch. ## Keeping the table small `job_batches` gains a row per batch and never deletes it on its own. Schedule `php artisan queue:prune-batches` daily: - by default it deletes **finished** batches older than 24 hours (`--hours=24`); - `--unfinished=72` also removes batches that never finished, such as those with a permanently failed job; - `--cancelled=72` also removes cancelled batches. A batch whose failed jobs are never retried never finishes, so without `--unfinished` it stays forever.
- Why must a Laravel batch that dispatches 5,000 jobs from a controller be restructured?`dispatch()` serializes and pushes every job before the request returns, which can take long enough to time out. Dispatch a few loader jobs in the batch instead and let each call `$this->batch()->add(...)` with its share; the batch's totals grow as they add jobs.
- Which Laravel batches does queue:prune-batches delete when run with no options?Only finished batches — those whose `pending_jobs` reached 0 — older than 24 hours. Batches with permanently failed jobs never finish, and cancelled ones are handled separately, so add `--unfinished=<hours>` and `--cancelled=<hours>` to remove those too.
saying these in an interview costs you the question
- Any queued job can report to a batch without the Batchable trait
- Calling cancel() deletes the batch's remaining jobs from the queue
- A new Laravel 13 app must run make:queue-batches-table before batching
- progress() counts failed jobs as processed
- queue:prune-batches removes unfinished batches by default