Skip to content

Job Batching, Chaining, and Catch Callbacks: Reliable Async Workflows in Laravel

Go beyond fire-and-forget jobs. Learn how to compose Laravel job batches, chains, and catch callbacks into reliable, observable async workflows that handle partial failures gracefully.

Published · Updated · 4 min read

Job Batching, Chaining, and Catch Callbacks: Reliable Async Workflows in Laravel

The Problem with Naive Job Dispatch

Most Laravel applications start with simple dispatch(new SomeJob($id)) calls scattered through controllers. That works until you need to process 50,000 records, coordinate dependent steps, or know when a multi-step workflow has truly finished. Laravel's batch and chain APIs solve this — but only if you understand their failure semantics.

Batches vs Chains: Know the Difference

A chain is a sequential pipeline: each job runs only after the previous one succeeds. A batch is a parallel fan-out: all jobs run concurrently, and you get callbacks when they collectively finish or partially fail.

Use chains when step B depends on step A's output. Use batches when you can process N items independently and want to react to completion.

// Chain: sequential dependency
bus()->chain([
    new ValidateImport($importId),
    new TransformRows($importId),
    new NotifyUser($importId),
])->dispatch();
// Batch: parallel fan-out
$batch = Bus::batch(
    $rowIds->map(fn ($id) => new ProcessRow($id))->all()
)
->then(fn (Batch $batch) => ImportCompleted::dispatch($batch->id))
->catch(fn (Batch $batch, Throwable $e) => ImportFailed::dispatch($batch->id, $e->getMessage()))
->finally(fn (Batch $batch) => Cache::forget("import:{$batch->id}:lock"))
->name('row-import')
->allowFailures() // don't cancel remaining jobs on first failure
->dispatch();

allowFailures() Is Not Optional in Real Systems

By default, a single failed job cancels the entire batch. For bulk imports or notification fans, that's almost never what you want. Call ->allowFailures() and inspect $batch->failedJobs in your then callback to decide what to do with partial success.

Nesting Batches Inside Chains

You can add a batch as a step inside a chain using Bus::chain with a Bus::batch call embedded:

Bus::chain([
    new PrepareImport($importId),
    Bus::batch(
        $chunks->map(fn ($chunk) => new ProcessChunk($importId, $chunk))->all()
    )->allowFailures(),
    new FinaliseImport($importId),
])->dispatch();

The chain pauses at the batch step and only advances to FinaliseImport once every batch job has settled (succeeded or failed, depending on allowFailures).

Catch Callbacks and Error Observability

The catch callback fires on the first job failure in a batch. It receives the Batch model and the Throwable. Use it to record structured failure context rather than relying on the generic failed-jobs table alone:

->catch(function (Batch $batch, Throwable $e) use ($importId) {
    ImportAttempt::where('batch_id', $batch->id)->update([
        'status' => 'partial_failure',
        'error' => $e->getMessage(),
        'failed_count' => $batch->failedJobs,
    ]);
})

For chains, attach a catch directly on the chain dispatch:

Bus::chain([...])
    ->catch(fn (Throwable $e) => Log::critical('Import chain failed', ['error' => $e->getMessage()]))
    ->dispatch();

Pruning Stale Batch Records

Every dispatched batch writes a row to job_batches. In high-throughput systems this table grows fast. Schedule the built-in prune command:

// routes/console.php or Kernel.php
Schedule::command('queue:prune-batches --hours=48 --unfinished=72')->daily();

Inspecting Batch Progress in Real Time

The Batch model exposes totalJobs, processedJobs(), failedJobs, and progress() (0–100). Poll it from a Livewire component or a simple API endpoint to build a progress bar without any additional infrastructure:

$batch = Bus::findBatch($batchId);
return [
    'progress' => $batch->progress(),
    'finished' => $batch->finished(),
    'failed' => $batch->failedJobs,
];

Key Takeaways

  • Use chains for sequential dependencies; use batches for parallel fan-out with collective callbacks.
  • Always call ->allowFailures() on batches that process bulk data — partial success is usually acceptable.
  • The catch callback fires once per batch failure event; use it for structured observability, not just logging.
  • Nest a Bus::batch() inside a Bus::chain() to combine parallel processing with sequential orchestration.
  • Schedule queue:prune-batches to prevent the job_batches table from becoming a performance liability.
  • Expose $batch->progress() directly from the database model — no Redis counters needed for simple UIs.

Frequently asked questions

Does the `catch` callback in a batch fire for every failed job or just the first?

It fires on the first failure within the batch. If you need per-job failure handling, implement the `failed(Throwable $e)` method directly on the job class alongside the batch-level catch callback.

Can I add more jobs to a batch after it has been dispatched?

Yes. Call `Bus::findBatch($batchId)->add([new AnotherJob()])` at any point before the batch finishes. This is useful when a job discovers additional work that should belong to the same batch.

What happens to the chain if a nested batch has `allowFailures()` and some jobs fail?

The chain still advances to the next step because `allowFailures()` prevents the batch from being marked as cancelled. Inspect `$batch->failedJobs` in the `then` callback or the next chain step to decide whether to proceed or abort.

Mohamed Said

About the author

Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

About GitHub LinkedIn WhatsApp Email Address My CV

Have a technical challenge?

Tell me what you’re building. I reply within two working days.

Discuss your project