A customer uploads a 250,000-row CSV. The first version of the import was one queued job that looped over every row: it ran for 40 minutes, hit the worker timeout at row 180,000, was retried from row zero, and nobody could tell the customer how far along it was. The second version split the file into 250 jobs, which fixed the timeout but created new questions. How do you know when all of them are done? What happens when chunk 117 fails three times? And how do you send exactly one "your import is ready" email?
Laravel has two tools for this, and they answer different questions. Bus::chain runs jobs one after another and stops at the first failure. Bus::batch runs jobs in parallel and tracks them as a group, with progress and lifecycle callbacks. Picking the wrong one is how you end up with half-charged orders or imports that never report completion.
Chain vs Batch: The Core Difference
A chain is a sequence. Job B is only pushed to the queue after job A succeeds. If A fails permanently (after its retries), B and C are discarded and the chain's catch callback runs. Use it when each step depends on the previous one: reserve stock, then charge the card, then send the receipt.
A batch is a group. All jobs are pushed at once and any number of workers can pick them up. Laravel stores a row in the job_batches table with counters (total_jobs, pending_jobs, failed_jobs) and updates them atomically as each job finishes. Use it when the jobs are independent and you care about the group outcome: import chunks, thumbnail generation, sending a newsletter in slices.
The lifecycle of a batch is where most of the confusion lives:
Three rules to memorize:
thenruns only if every job succeeded. One permanent failure andthennever fires, even withallowFailures().catchruns once, on the first job that fails for good (after it has used up its$tries). Later failures do not trigger it again.finallyruns when every job has run, whatever the outcome. This is where cleanup and "import finished" notifications belong.
Setting Up Batches
Batches need the job_batches table. Laravel ships a generator for its migration:
php artisan make:queue-batches-table
php artisan migrate
Every job you put in a batch must use the Batchable trait. It gives the job $this->batch(), which returns the Batch instance so a job can check whether the batch was cancelled or add more jobs to it. The cleanest way to stop work after cancellation is the SkipIfBatchCancelled job middleware:
namespace App\Jobs;
use App\Services\CustomerCsv;
use Illuminate\Bus\Batchable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\SkipIfBatchCancelled;
final class ImportCustomerChunk implements ShouldQueue
{
use Batchable, Queueable;
public int $tries = 3;
public function __construct(
public readonly int $importId,
public readonly int $offset,
public readonly int $limit,
) {}
/** Skip the work entirely once the batch has been cancelled. */
public function middleware(): array
{
return [new SkipIfBatchCancelled()];
}
public function handle(CustomerCsv $csv): void
{
foreach ($csv->rows($this->importId, $this->offset, $this->limit) as $row) {
$csv->upsertCustomer($row);
}
}
/** @return list<int> */
public function backoff(): array
{
return [10, 60];
}
}
Note that a job skipped by the middleware still counts as processed. That matters for progress bars: a cancelled batch can reach 100% "progress" while doing almost no work.
Chains for Dependent Steps
Checkout is the classic chain. Charging the card before stock is reserved is a bug, and sending a receipt for a failed charge is worse:
use App\Jobs\ChargePayment;
use App\Jobs\ReleaseStock;
use App\Jobs\ReserveStock;
use App\Jobs\SendReceipt;
use Illuminate\Support\Facades\Bus;
$orderId = $order->id;
Bus::chain([
new ReserveStock($orderId),
new ChargePayment($orderId),
new SendReceipt($orderId),
])
->onQueue('orders')
->catch(function (Throwable $e) use ($orderId): void {
// Runs once, when a job in the chain fails for good.
// The jobs after the failed one are never executed.
ReleaseStock::dispatch($orderId);
logger()->error('Checkout chain failed', ['order_id' => $orderId, 'error' => $e->getMessage()]);
})
->dispatch();
The catch closure is serialized and stored with the chain, so capture plain values like $orderId, never $this or a big model graph. If ChargePayment throws on its last attempt, SendReceipt is never queued and the compensation job releases the stock.
Batches for Parallel Work With Progress
Here is the import, split into chunks, with all four callbacks wired to a CustomerImport record that the UI polls:
namespace App\Services;
use App\Jobs\ImportCustomerChunk;
use App\Models\CustomerImport;
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
use Throwable;
final class CustomerImporter
{
public function start(int $importId, int $rows, int $chunkSize = 1000): Batch
{
$jobs = [];
for ($offset = 0; $offset < $rows; $offset += $chunkSize) {
$jobs[] = new ImportCustomerChunk($importId, $offset, $chunkSize);
}
return Bus::batch($jobs)
->name("customer-import-{$importId}")
->onQueue('imports')
->allowFailures()
->progress(function (Batch $batch) use ($importId): void {
CustomerImport::whereKey($importId)->update(['progress' => $batch->progress()]);
})
->then(function (Batch $batch) use ($importId): void {
// Only when EVERY job succeeded.
CustomerImport::whereKey($importId)->update(['status' => 'completed']);
})
->catch(function (Batch $batch, Throwable $e) use ($importId): void {
// Only for the FIRST job that failed for good.
CustomerImport::whereKey($importId)->update(['status' => 'has_errors']);
})
->finally(function (Batch $batch) use ($importId): void {
// When every job has run, success or not.
CustomerImport::whereKey($importId)->update([
'finished_at' => now(),
'failed_chunks' => $batch->failedJobs,
]);
})
->dispatch();
}
}
Why allowFailures() here? Without it, the first permanently failed chunk cancels the batch, and every chunk still in the queue is skipped by the middleware. For a checkout-style "all or nothing" job that is what you want. For an import, losing 1 chunk out of 250 should not throw away the other 249; you would rather finish, report failed_chunks, and let support retry the failures with php artisan queue:retry-batch {id}.
The progress callback fires after each successful job. It does a small write, which is fine at a few hundred jobs. At tens of thousands of jobs, throttle it (only write when the integer percentage changes) or drop it and let the UI read the batch directly:
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Route;
Route::get('/imports/batches/{batchId}', function (string $batchId) {
$batch = Bus::findBatch($batchId);
abort_if($batch === null, 404);
return [
'name' => $batch->name,
'progress' => $batch->progress(),
'total' => $batch->totalJobs,
'pending' => $batch->pendingJobs,
'failed' => $batch->failedJobs,
'finished' => $batch->finished(),
'cancelled' => $batch->cancelled(),
];
})->middleware('auth');
Bus::findBatch() reads the job_batches row, so this endpoint is cheap and always consistent with what the workers have recorded. Keep it behind auth: a batch ID is not a secret token.
Mixing Chains and Batches
Real workflows are rarely pure. Laravel lets you nest them both ways. An array inside a batch is a chain; a batch inside a chain is a step that waits for the whole batch:
use App\Jobs\BuildSearchIndex;
use App\Jobs\ExportInvoices;
use App\Jobs\ExportPayments;
use App\Jobs\NotifyFinanceTeam;
use App\Jobs\ZipExports;
use Illuminate\Support\Facades\Bus;
// A batch whose items are chains: each region runs its two steps in order,
// while the regions run in parallel with each other.
Bus::batch([
[new ExportInvoices('eu'), new ExportPayments('eu')],
[new ExportInvoices('us'), new ExportPayments('us')],
[new ExportInvoices('apac'), new ExportPayments('apac')],
])->name('month-end-exports')->dispatch();
// A chain that contains a batch: the zip step waits for the whole batch.
Bus::chain([
Bus::batch([
new ExportInvoices('eu'),
new ExportInvoices('us'),
]),
new ZipExports(),
new NotifyFinanceTeam(),
])->dispatch();
// A running job can also grow its own batch.
// Inside a Batchable job's handle():
// $this->batch()->add([new BuildSearchIndex($this->importId)]);
The second form replaces a common hack where the batch's then callback dispatches the next job. With the batch as a chain step, the dependency is visible at the dispatch site instead of being hidden in a callback.
Testing Batches and Chains
You rarely want real workers in a feature test. Bus::fake() records what was dispatched and gives you assertions for both shapes:
use App\Jobs\ChargePayment;
use App\Jobs\ImportCustomerChunk;
use App\Jobs\ReserveStock;
use App\Jobs\SendReceipt;
use App\Models\Order;
use App\Services\CheckoutService;
use App\Services\CustomerImporter;
use Illuminate\Bus\PendingBatch;
use Illuminate\Support\Facades\Bus;
it('dispatches one batch job per chunk and tolerates failures', function () {
Bus::fake();
app(CustomerImporter::class)->start(importId: 42, rows: 2500, chunkSize: 1000);
Bus::assertBatched(function (PendingBatch $batch): bool {
return $batch->name === 'customer-import-42'
&& $batch->jobs->count() === 3
&& $batch->jobs->every(fn ($job) => $job instanceof ImportCustomerChunk)
&& $batch->allowsFailures();
});
});
it('runs checkout as an ordered chain', function () {
Bus::fake();
$order = Order::factory()->create();
app(CheckoutService::class)->checkout($order);
Bus::assertChained([
ReserveStock::class,
ChargePayment::class,
SendReceipt::class,
]);
});
These tests check the wiring: the right number of chunks, the right order, allowFailures switched on. The jobs themselves deserve separate unit tests that call handle() directly with fake dependencies. For the callbacks, extract their bodies into small invokable classes or service methods so you can test them without a queue.
Common Pitfalls
- Expecting
thenafter partial failure. WithallowFailures(), the batch keeps going, butthenstill only fires when everything succeeded. Put "import done" logic infinallyand read$batch->failedJobsthere. - Capturing
$thisor models in callbacks. Callbacks are serialized into thejob_batchesrow. Capture IDs and re-query inside the closure. - Forgetting the batch was cancelled. Without
SkipIfBatchCancelledor a manualif ($this->batch()?->cancelled()) return;, queued jobs keep doing real work after a failure cancelled the batch. - Retries vs failures. A job that throws but has tries left is not a batch failure yet.
catchonly fires after the final attempt. Set$triesandbackoff()deliberately. - Unbounded
job_batchestable. Every batch leaves a row. Schedule pruning:
use Illuminate\Support\Facades\Schedule;
// routes/console.php
Schedule::command('queue:prune-batches --hours=48 --unfinished=72 --cancelled=72')->daily();
- Dispatching inside a DB transaction. If you create the
CustomerImportrow and dispatch the batch in the same transaction, workers can start before the commit and not find the row. UseafterCommit()on the jobs or dispatch after the transaction.
When to Use Which, and When Not To
Use a chain when steps must run in order and later steps make no sense if an earlier one failed. Keep chains short; a 15-step chain is a workflow engine in disguise and is hard to resume halfway.
Use a batch when the work is a set of independent units and you need group-level answers: how far along, did anything fail, run something exactly once at the end.
Do not use either when one job is enough. If a task finishes in seconds, splitting it adds a database round trip per job for bookkeeping. And do not use batches as a long-lived state machine for business processes that span days; model that state in your own tables and let jobs be the small, retryable steps that move it forward.
Try the challenge
Practice what you just learned. Write your solution, reveal hints if you get stuck.
Instructions
Implement a BatchTracker class that models Laravel batch semantics without a queue. Constructor: BatchTracker(int $totalJobs, bool $allowFailures = false) (Python: BatchTracker(total_jobs, allow_failures=False)). then(callback), catch(callback) and finally(callback) (Python: then, catch, on_finally) store one callback each and return the tracker for chaining. recordSuccess() / record_success() counts a successful job. recordFailure(string $jobId) / record_failure(job_id) counts a failed job, appends the id to failedJobIds() (Python: the failed_job_ids list), cancels the batch unless failures are allowed (cancelled() / the cancelled attribute), and calls the catch callback with the job id only for the FIRST failure. When the last job is recorded (successes + failures == total): call then() only if there were zero failures, and then always call finally(). Recording a job after the batch finished must throw LogicException (Python: RuntimeError). progress() returns finished jobs (success or failure) * 100 / total rounded down, or 100 for an empty batch. finished() is true when every job has been recorded.
Starter code
final class BatchTracker
{
private int $succeeded = 0;
private int $failed = 0;
private bool $cancelled = false;
/** @var list<string> */
private array $failedJobIds = [];
private ?Closure $then = null;
private ?Closure $catch = null;
private ?Closure $finally = null;
public function __construct(
private int $totalJobs,
private bool $allowFailures = false,
) {}
public function then(Closure $callback): self
{
// TODO: store the callback and return $this
return $this;
}
public function catch(Closure $callback): self
{
// TODO: store the callback and return $this
return $this;
}
public function finally(Closure $callback): self
{
// TODO: store the callback and return $this
return $this;
}
public function recordSuccess(): void
{
// TODO: throw LogicException if the batch already finished,
// count the success, then run the end-of-batch callbacks if it is the last job
}
public function recordFailure(string $jobId): void
{
// TODO: throw LogicException if the batch already finished,
// count the failure, remember $jobId, cancel unless failures are allowed,
// call catch($jobId) only for the FIRST failure, then handle end-of-batch
}
public function progress(): int
{
// TODO: finished jobs (success + failure) * 100 / total, rounded down; 100 for an empty batch
return 0;
}
public function finished(): bool
{
// TODO
return false;
}
public function cancelled(): bool
{
return $this->cancelled;
}
/** @return list<string> */
public function failedJobIds(): array
{
return $this->failedJobIds;
}
}
from typing import Callable
class BatchTracker:
def __init__(self, total_jobs: int, allow_failures: bool = False) -> None:
self.total_jobs = total_jobs
self.allow_failures = allow_failures
self.succeeded = 0
self.failed = 0
self.cancelled = False
self.failed_job_ids: list[str] = []
self._then: Callable[[], None] | None = None
self._catch: Callable[[str], None] | None = None
self._finally: Callable[[], None] | None = None
def then(self, callback: Callable[[], None]) -> "BatchTracker":
# TODO: store the callback and return self
return self
def catch(self, callback: Callable[[str], None]) -> "BatchTracker":
# TODO: store the callback and return self
return self
def on_finally(self, callback: Callable[[], None]) -> "BatchTracker":
# TODO: store the callback and return self
return self
def record_success(self) -> None:
# TODO: raise RuntimeError if already finished, count it, handle end-of-batch
raise NotImplementedError
def record_failure(self, job_id: str) -> None:
# TODO: raise RuntimeError if already finished, count it, remember job_id,
# cancel unless failures are allowed, call catch(job_id) only for the first failure
raise NotImplementedError
def progress(self) -> int:
# TODO: finished jobs * 100 // total; 100 for an empty batch
raise NotImplementedError
def finished(self) -> bool:
# TODO
raise NotImplementedError
Your solution
Hints
All hints revealedRun the end-of-batch logic from one private helper called at the end of both record methods
catch fires on the first failure only: check that the failure counter equals 1 right after incrementing it
then and finally both run at the end, then first and only when the failure count is zero
final class BatchTracker
{
private int $succeeded = 0;
private int $failed = 0;
private bool $cancelled = false;
/** @var list<string> */
private array $failedJobIds = [];
private ?Closure $then = null;
private ?Closure $catch = null;
private ?Closure $finally = null;
public function __construct(
private int $totalJobs,
private bool $allowFailures = false,
) {}
public function then(Closure $callback): self
{
$this->then = $callback;
return $this;
}
public function catch(Closure $callback): self
{
$this->catch = $callback;
return $this;
}
public function finally(Closure $callback): self
{
$this->finally = $callback;
return $this;
}
public function recordSuccess(): void
{
$this->ensureNotFinished();
$this->succeeded++;
$this->afterJob();
}
public function recordFailure(string $jobId): void
{
$this->ensureNotFinished();
$this->failed++;
$this->failedJobIds[] = $jobId;
if (! $this->allowFailures) {
$this->cancelled = true;
}
if ($this->failed === 1 && $this->catch !== null) {
($this->catch)($jobId);
}
$this->afterJob();
}
public function progress(): int
{
if ($this->totalJobs === 0) {
return 100;
}
return intdiv(($this->succeeded + $this->failed) * 100, $this->totalJobs);
}
public function finished(): bool
{
return $this->succeeded + $this->failed === $this->totalJobs;
}
public function cancelled(): bool
{
return $this->cancelled;
}
/** @return list<string> */
public function failedJobIds(): array
{
return $this->failedJobIds;
}
private function afterJob(): void
{
if (! $this->finished()) {
return;
}
if ($this->failed === 0 && $this->then !== null) {
($this->then)();
}
if ($this->finally !== null) {
($this->finally)();
}
}
private function ensureNotFinished(): void
{
if ($this->finished()) {
throw new LogicException('Batch already finished.');
}
}
}
from typing import Callable
class BatchTracker:
def __init__(self, total_jobs: int, allow_failures: bool = False) -> None:
self.total_jobs = total_jobs
self.allow_failures = allow_failures
self.succeeded = 0
self.failed = 0
self.cancelled = False
self.failed_job_ids: list[str] = []
self._then: Callable[[], None] | None = None
self._catch: Callable[[str], None] | None = None
self._finally: Callable[[], None] | None = None
def then(self, callback: Callable[[], None]) -> "BatchTracker":
self._then = callback
return self
def catch(self, callback: Callable[[str], None]) -> "BatchTracker":
self._catch = callback
return self
def on_finally(self, callback: Callable[[], None]) -> "BatchTracker":
self._finally = callback
return self
def record_success(self) -> None:
self._ensure_not_finished()
self.succeeded += 1
self._after_job()
def record_failure(self, job_id: str) -> None:
self._ensure_not_finished()
self.failed += 1
self.failed_job_ids.append(job_id)
if not self.allow_failures:
self.cancelled = True
if self.failed == 1 and self._catch is not None:
self._catch(job_id)
self._after_job()
def progress(self) -> int:
if self.total_jobs == 0:
return 100
return (self.succeeded + self.failed) * 100 // self.total_jobs
def finished(self) -> bool:
return self.succeeded + self.failed == self.total_jobs
def _after_job(self) -> None:
if not self.finished():
return
if self.failed == 0 and self._then is not None:
self._then()
if self._finally is not None:
self._finally()
def _ensure_not_finished(self) -> None:
if self.finished():
raise RuntimeError("Batch already finished.")

