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(fn(Batch $b) => /* all succeeded */)
->catch(fn(Batch $b, Throwable $e) => /* the FIRST failure */)
->finally(fn(Batch $b) => /* always */)
->allowFailures()
->dispatch();
allowFailures 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 makes a progress bar possible for the first time.