Laravel Job Batching &amp; Chaining Deep Dive | Mohamed Said       [Skip to content](#main)  [ ![](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png) Mohamed SaidLaravel Backend Engineer ](https://msaied.com/public) - [Home](https://msaied.com/public)
- [Projects](https://msaied.com/public/projects)
- [Articles](https://msaied.com/public/articles)
- [Certificates](https://msaied.com/public/certificates)
- [About](https://msaied.com/public#about)

           [  Contact](https://msaied.com/public#contact) Menu 

Menu
----

Close 

 - [HomeStart here](https://msaied.com/public)
- [ProjectsCase studies](https://msaied.com/public/projects)
- [ArticlesEngineering notes](https://msaied.com/public/articles)
- [CertificatesCredentials](https://msaied.com/public/certificates)
- [AboutHow I work](https://msaied.com/public#about)
- [ContactGet in touch](https://msaied.com/public#contact)

  [Start a conversation](https://msaied.com/public#contact) [WhatsApp](https://wa.me/201094619204) [Email](mailto:hello@msaied.com) 

 1. [Home](https://msaied.com/public)
2. /
3. [Articles](https://msaied.com/public/articles)
4. /
5. Job Batching, Chaining, and Catch Callbacks: Reliable Async Workflows in Laravel

 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.

 ![](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp) [Mohamed Said](https://msaied.com/public#person) Published 13 Sep 2026 · Updated 13 Sep 2026 · 4 min read

ShareCopy linkCopied

 ![Job Batching, Chaining, and Catch Callbacks: Reliable Async Workflows in Laravel](https://cdn.msaied.com/666/f8aaa3879dc7efd419291ffa3e0b15c1.png) 

  On this page +1. [The Problem with Naive Job Dispatch](#the-problem-with-naive-job-dispatch)
2. [Batches vs Chains: Know the Difference](#batches-vs-chains-know-the-difference)
3. [allowFailures() Is Not Optional in Real Systems](#codeallowfailurescode-is-not-optional-in-real-systems)
4. [Nesting Batches Inside Chains](#nesting-batches-inside-chains)
5. [Catch Callbacks and Error Observability](#catch-callbacks-and-error-observability)
6. [Pruning Stale Batch Records](#pruning-stale-batch-records)
7. [Inspecting Batch Progress in Real Time](#inspecting-batch-progress-in-real-time)
8. [Key Takeaways](#key-takeaways)

 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.

```php
// Chain: sequential dependency
bus()->chain([
    new ValidateImport($importId),
    new TransformRows($importId),
    new NotifyUser($importId),
])->dispatch();

```

```php
// 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:

```php
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:

```php
->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:

```php
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:

```php
// 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:

```php
$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.

- [laravel](https://msaied.com/public/articles?search=laravel)
- [queues](https://msaied.com/public/articles?search=queues)
- [async](https://msaied.com/public/articles?search=async)
- [jobs](https://msaied.com/public/articles?search=jobs)
- [batching](https://msaied.com/public/articles?search=batching)

 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)-&gt;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-&gt;failedJobs` in the `then` callback or the next chain step to decide whether to proceed or abort.

   ![Mohamed Said](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp)About the author
----------------

[Mohamed Said](https://msaied.com/public#person)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](https://msaied.com/public#about) [GitHub ↗](https://github.com/EG-Mohamed) [LinkedIn ↗](https://www.linkedin.com/in/msaiedm/) [WhatsApp ↗](https://wa.me/201094619204) [Email Address ↗](mailto:hello@msaied.com) [My CV ↗](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)  

   [Previous articlePartial Indexes and Covering Indexes in PostgreSQL: A Laravel Developer's Guide](https://msaied.com/public/articles/partial-indexes-and-covering-indexes-in-postgresql-a-laravel-developers-guide-1) [Next articleLaravel Concurrency Facade and Process Pools for Parallel Work](https://msaied.com/public/articles/laravel-concurrency-facade-and-process-pools-for-parallel-work-4)  

   On this page
-------------

1. [The Problem with Naive Job Dispatch](#the-problem-with-naive-job-dispatch)
2. [Batches vs Chains: Know the Difference](#batches-vs-chains-know-the-difference)
3. [allowFailures() Is Not Optional in Real Systems](#codeallowfailurescode-is-not-optional-in-real-systems)
4. [Nesting Batches Inside Chains](#nesting-batches-inside-chains)
5. [Catch Callbacks and Error Observability](#catch-callbacks-and-error-observability)
6. [Pruning Stale Batch Records](#pruning-stale-batch-records)
7. [Inspecting Batch Progress in Real Time](#inspecting-batch-progress-in-real-time)
8. [Key Takeaways](#key-takeaways)

 ###  Have a technical challenge?

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

[Start a conversation](https://msaied.com/public#contact) 

   Related articles
-----------------

 [ ![](https://cdn.msaied.com/745/8744e1be5136b430da52e9fca3ed3964.png)  · 3 min read### Service Container Deep Dive: Contextual Binding, Tagging, and Method Injection

6 Oct 2026 ](https://msaied.com/public/articles/service-container-deep-dive-contextual-binding-tagging-and-method-injection-1) [ ![](https://cdn.msaied.com/743/8998fac3a41451ab3fe1588194e17a43.png) Filament · 3 min read### Securing Filament Plugins with Plumb: Automated Security Scoring for PHP Packages

5 Oct 2026 ](https://msaied.com/public/articles/securing-filament-plugins-with-plumb-automated-security-scoring-for-php-packages) [ ![](https://cdn.msaied.com/742/2d02018669cdeedccb5de2efb898f0ee.png) Filament · 3 min read### Filament v3.3.56 Released: File Hash Names and Livewire Upload Fix

5 Oct 2026 ](https://msaied.com/public/articles/filament-v3356-released-file-hash-names-and-livewire-upload-fix) 

  Have a technical challenge?
----------------------------

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

 [Discuss your project ↗](https://msaied.com/public#contact) 

  © 2026 Mohamed Said · Built with Laravel, meant to last.Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

 - [Home](https://msaied.com/public)
- [Articles](https://msaied.com/public/articles)
- [Certificates](https://msaied.com/public/certificates)
- [GitHub](https://github.com/EG-Mohamed)
- [LinkedIn](https://www.linkedin.com/in/msaiedm/)
- [WhatsApp](https://wa.me/201094619204)
- [Email Address](mailto:hello@msaied.com)
- [My CV](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)
- [Sitemap](https://msaied.com/public/sitemap.xml)
