A batch runs jobs in parallel and reports on them as a group. Members use the Batchable trait (make:job --batched) and begin with if ($this->batch()?->cancelled()) { return; }. The invoice job renders a Blade view to storage/app/private/invoices/ and throws for a cancelled order:
Artisan::command('shop:invoices', function () {
$jobs = Order::orderBy('id')->get()->map(fn (Order $o) => new GenerateInvoice($o));
$batch = Bus::batch($jobs)
->name('September invoices')
->allowFailures()
->then(fn (Batch $b) => Log::info("{$b->name}: every invoice rendered"))
->catch(fn (Batch $b, Throwable $e) => Log::warning("first: {$e->getMessage()}"))
->finally(fn (Batch $b) => Log::info("finally: {$b->processedJobs()} processed"))
->dispatch();
$this->info("batch {$batch->id}: {$batch->totalJobs} jobs");
});then runs if every job succeeds, catch on the first failure and finally when all have run; without allowFailures() one failure cancels the rest. Of nine orders, order 5 is cancelled, so the log showed first: order 5 is cancelled and finally: 8 processed, and the batch's row read:
mysql -t shop -e "SET time_zone = '+00:00';
SELECT name, total_jobs AS total, pending_jobs AS pending, failed_jobs AS failed,
FROM_UNIXTIME(created_at) AS created,
FROM_UNIXTIME(finished_at) AS finished FROM job_batches"+--------------------+-------+---------+--------+---------------------+----------+ | name | total | pending | failed | created | finished | +--------------------+-------+---------+--------+---------------------+----------+ | September invoices | 9 | 1 | 1 | 2026-09-23 08:58:20 | NULL | +--------------------+-------+---------+--------+---------------------+----------+
A failed member still counts as pending. In an earlier run, fixing the order and running queue:retry-batch <id> completed the batch: then fired and finished_at was set, while failed stayed 1. Bus::findBatch($id)->progress() feeds a progress bar.