skip to content

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?

level: middleimportance: should knowfreq 35%

answer

  1. the Illuminate Bus Batchable trait
  2. $this->batch() returns the Batch
  3. Bus::findBatch($id) is JSON-ready
  4. cancel() plus SkipIfBatchCancelled
  5. queue:prune-batches --hours=24

basics

~10 s

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

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

for a junior

Recall the Batchable trait, $this->batch(), and that Bus::findBatch() returns the batch for a progress endpoint.

for a middle

Explain the job_batches columns, what progress() counts, and why cancel() needs cooperating jobs or SkipIfBatchCancelled.

for a senior

Build a progress UI, grow large batches with loader jobs, and schedule pruning that covers unfinished and cancelled batches.

for a principal

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