Knowing when a set of queued jobs had all finished meant a counter in Redis and a race nobody was confident about.
Bus::batch([
new ImportChunk(1), new ImportChunk(2), new ImportChunk(3),
])->then(function (Batch $batch) {
// every job succeeded
})->catch(function (Batch $batch, Throwable $e) {
// the FIRST failure — and by default the batch is cancelled
})->finally(function (Batch $batch) {
// always, after then or catch
})->allowFailures()->dispatch();
allowFailures is the flag that decides the semantics: without it the first failure cancels every job not yet started, which is right for a multi-step operation and wrong for an import where each chunk is independent. The batch record lives in a database table, so the callbacks survive a worker restart — and that table needs pruning, which nothing does automatically. Progress is queryable, which is what makes a progress bar possible for the first time.