A batch gives progress and a completion hook for a set of jobs, and the callbacks run on the worker that finished the last one.
Bus::batch($chunks->map(fn ($ids) => new ExportChunk($ids)))
->then(fn (Batch $b) => ExportReady::dispatch($b->id))
->catch(fn (Batch $b, Throwable $e) => report($e))
->finally(fn (Batch $b) => Cache::forget("export:{$b->id}"))
->allowFailures()
->dispatch();
The callbacks are serialised closures stored with the batch, which means they cannot capture anything unserialisable and cannot be changed after dispatch — a deploy that removes the class a closure references leaves a batch that cannot complete. Dispatching a job from then rather than doing the work inline is the shape that survives, because the callback runs on a worker with no retry semantics of its own.