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

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

Menu
----

Close 

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

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

 1. [Home](https://www.msaied.com/public)
2. /
3. [Articles](https://www.msaied.com/public/articles)
4. /
5. Job Batching, Chaining, and Rate-Limited Middleware in Laravel Queues

 Job Batching, Chaining, and Rate-Limited Middleware in Laravel Queues
======================================================================

 Go beyond basic dispatch: learn how to compose Laravel job batches with callbacks, chain dependent jobs safely, and throttle throughput with rate-limited middleware — all with production-ready patterns.

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

ShareCopy linkCopied

 ![Job Batching, Chaining, and Rate-Limited Middleware in Laravel Queues](https://cdn.msaied.com/489/89d47dc6b618d5435f9d7f333b75e922.png) 

  On this page +1. [Beyond dispatch(): Composing Complex Queue Workflows](#beyond-codedispatchcode-composing-complex-queue-workflows)
2. [Job Batching](#job-batching)
3. [Adding Jobs to a Running Batch](#adding-jobs-to-a-running-batch)
4. [Job Chaining](#job-chaining)
5. [Mixing Batches Inside Chains](#mixing-batches-inside-chains)
6. [Rate-Limited Job Middleware](#rate-limited-job-middleware)
7. [Takeaways](#takeaways)

 Beyond `dispatch()`: Composing Complex Queue Workflows
------------------------------------------------------

Most Laravel queue tutorials stop at `dispatch(MyJob::class)`. Production systems need more: fan-out work across hundreds of records, guarantee sequential steps, and respect third-party API rate limits — all without hand-rolling a state machine. Laravel's batch, chain, and middleware primitives cover every one of those cases cleanly.

---

Job Batching
------------

Batches let you dispatch a collection of jobs and react when the whole set finishes, partially fails, or is cancelled.

```php
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;

$batch = Bus::batch([
    new ProcessInvoice($invoice1),
    new ProcessInvoice($invoice2),
    new ProcessInvoice($invoice3),
])
->then(fn (Batch $batch) => Report::markComplete($batch->id))
->catch(fn (Batch $batch, Throwable $e) => Report::markFailed($batch->id, $e->getMessage()))
->finally(fn (Batch $batch) => Cache::forget("batch:{$batch->id}"))
->allowFailures()   // don't cancel remaining jobs on first failure
->dispatch();

session(['batch_id' => $batch->id]);

```

`allowFailures()` is critical for bulk-import scenarios: one bad row should not abort 10,000 others. Poll `Bus::findBatch($id)` to expose progress to the UI.

### Adding Jobs to a Running Batch

Inside a batched job you can append more work — useful for tree-shaped processing:

```php
public function handle(): void
{
    $children = $this->node->children;

    if ($children->isNotEmpty()) {
        $this->batch()->add(
            $children->map(fn ($child) => new ProcessNode($child))
        );
    }

    $this->node->markProcessed();
}

```

The batch's `totalJobs` counter updates atomically, so `then()` only fires once every added job has also completed.

---

Job Chaining
------------

Chains enforce strict ordering: job N+1 only runs if job N succeeds.

```php
Bus::chain([
    new ValidateOrder($order),
    new ChargePayment($order),
    new FulfillOrder($order),
    new SendConfirmationEmail($order),
])
->catch(function (Throwable $e) use ($order) {
    $order->markFailed($e->getMessage());
    Notification::send($order->owner, new OrderFailedNotification($order));
})
->dispatch();

```

**Key distinction:** a chain's `catch` fires on the *first* failure and the remaining jobs are discarded. If you need partial completion semantics, use a batch instead.

### Mixing Batches Inside Chains

You can nest a batch as one step in a chain:

```php
Bus::chain([
    new PrepareExport($report),
    Bus::batch([
        new ExportChunk($report, 0),
        new ExportChunk($report, 1),
        new ExportChunk($report, 2),
    ]),
    new FinaliseExport($report),
])->dispatch();

```

The chain pauses at the batch step and only advances to `FinaliseExport` once every chunk job completes successfully.

---

Rate-Limited Job Middleware
---------------------------

Third-party APIs impose rate limits. The cleanest solution is a per-job middleware that uses Redis to throttle throughput without blocking a worker thread.

```php
namespace App\Jobs\Middleware;

use Closure;
use Illuminate\Support\Facades\Redis;

class ThrottleWithRedis
{
    public function __construct(
        private readonly string $key,
        private readonly int $maxAttempts,
        private readonly int $decaySeconds,
    ) {}

    public function handle(object $job, Closure $next): void
    {
        Redis::throttle($this->key)
            ->allow($this->maxAttempts)
            ->every($this->decaySeconds)
            ->then(
                fn () => $next($job),
                function () use ($job) {
                    $job->release(10); // re-queue after 10 s
                }
            );
    }
}

```

Attach it in the job class:

```php
public function middleware(): array
{
    return [
        new ThrottleWithRedis('stripe-api', 80, 60),
    ];
}

```

Laravel ships `Illuminate\Queue\Middleware\RateLimited` and `RateLimitedWithRedis` out of the box, backed by the same `RateLimiter` facade used for HTTP routes — so you can share a named limiter:

```php
// AppServiceProvider
RateLimiter::for('stripe', fn () => Limit::perMinute(80));

// Job
public function middleware(): array
{
    return [new RateLimited('stripe')];
}

```

---

Takeaways
---------

- Use **batches** when jobs are independent and you need aggregate callbacks; use **chains** when order and dependency matter.
- `allowFailures()` on a batch prevents one bad job from cancelling the rest — essential for bulk operations.
- Nest a batch inside a chain to get fan-out parallelism with a sequential before/after step.
- Rate-limited middleware keeps workers non-blocking: `release()` re-queues the job rather than sleeping the process.
- Share `RateLimiter` definitions between HTTP and queue layers to enforce a single source of truth for API quotas.

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

 Frequently asked questions 
---------------------------

  What happens to a batch if one job throws an exception?By default the batch is cancelled and remaining pending jobs are not executed. Call `allowFailures()` when dispatching to let other jobs continue; the `catch` callback still fires for each failed job, and `failedJobs` is incremented on the batch record.

   Does `RateLimited` middleware count against the job's `tries` limit?No — releasing a job via `$job-&gt;release()` inside middleware does not increment the attempt counter. Only a thrown exception or an explicit `$job-&gt;fail()` counts as an attempt, so throttled jobs can be re-queued many times without being discarded.

   Can I track batch progress in a Filament or Livewire UI?Yes. Store the batch ID in the database or session after dispatch, then poll `Bus::findBatch($id)` on a Livewire component using a `wire:poll` directive. The returned `Batch` object exposes `progress()`, `totalJobs`, `processedJobs`, and `failedJobs` properties.

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

[Mohamed Said](https://www.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://www.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 articleLaravel Observers vs. Model Events: Choosing the Right Hook for Domain Side-Effects](https://www.msaied.com/public/articles/laravel-observers-vs-model-events-choosing-the-right-hook-for-domain-side-effects) [Next articlePest 5 Released: Test Impact Analysis, Agent Verification, and Evals](https://www.msaied.com/public/articles/pest-5-released-test-impact-analysis-agent-verification-and-evals)  

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

1. [Beyond dispatch(): Composing Complex Queue Workflows](#beyond-codedispatchcode-composing-complex-queue-workflows)
2. [Job Batching](#job-batching)
3. [Adding Jobs to a Running Batch](#adding-jobs-to-a-running-batch)
4. [Job Chaining](#job-chaining)
5. [Mixing Batches Inside Chains](#mixing-batches-inside-chains)
6. [Rate-Limited Job Middleware](#rate-limited-job-middleware)
7. [Takeaways](#takeaways)

 ###  Have a technical challenge?

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

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

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

 [ ![](https://cdn.msaied.com/740/cce86edc21eddcbdd2f2454fadaf9c70.png)  · 3 min read### The Pipeline Pattern in Laravel: Custom Pipelines Beyond Middleware

5 Oct 2026 ](https://www.msaied.com/public/articles/the-pipeline-pattern-in-laravel-custom-pipelines-beyond-middleware-1) [ ![](https://cdn.msaied.com/739/2d6897fdcdcf090613f96f72a64b8a78.png)  · 4 min read### MySQL Full-Text Search in Laravel: Indexes, Relevance Scoring, and Boolean Mode

4 Oct 2026 ](https://www.msaied.com/public/articles/mysql-full-text-search-in-laravel-indexes-relevance-scoring-and-boolean-mode) [ ![](https://cdn.msaied.com/738/073696a3fefe18bec825beec5ac658f5.png)  · 4 min read### Laravel Queue Rate-Limited Middleware: Throttling Jobs Without Losing Work

4 Oct 2026 ](https://www.msaied.com/public/articles/laravel-queue-rate-limited-middleware-throttling-jobs-without-losing-work) 

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

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

 [Discuss your project ↗](https://www.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://www.msaied.com/public)
- [Articles](https://www.msaied.com/public/articles)
- [Certificates](https://www.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://www.msaied.com/public/sitemap.xml)
