Job Batching, Chaining, and Rate-Limited Middleware for Laravel Queues
#laravel #queues #jobs #async

Job Batching, Chaining, and Rate-Limited Middleware for Laravel Queues

3 min read Mohamed Said Mohamed Said

Job Batching, Chaining, and Rate-Limited Middleware

Laravel's queue system is deceptively powerful once you move past dispatch(). Three features — batching, chaining, and rate-limited middleware — compose into workflows that are resilient, observable, and polite to external APIs. This article shows how to wire them together correctly.


Batching: Fan-Out With a Finish Line

A batch dispatches many jobs in parallel and lets you react when they all finish (or any one fails).

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}"))
->onQueue('invoices')
->dispatch();

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

The then callback fires only when all jobs succeed. catch fires on the first failure but the batch continues processing remaining jobs by default. Call $batch->cancel() inside catch to halt everything.

Adding Jobs to a Running Batch

Jobs can push siblings into their own batch — useful for recursive fan-out:

public function handle(): void
{
    $children = $this->fetchChildIds();

    if ($children->isNotEmpty()) {
        $this->batch()->add(
            $children->map(fn ($id) => new ProcessChild($id))->all()
        );
    }
}

This keeps the batch open until every dynamically added job also completes.


Chaining: Sequential Pipelines With Error Isolation

Chaining runs jobs one after another, stopping on failure.

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

Gotcha: the catch closure is serialized. Avoid injecting large objects — use IDs and re-query inside the closure.

You can mix batches inside chains for hybrid workflows:

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

The chain pauses at the batch step until all chunks complete, then continues to FinalizeExport.


Rate-Limited Job Middleware

When jobs call external APIs you need to throttle throughput without blocking workers. Job middleware is the right tool — not sleep().

namespace App\Jobs\Middleware;

use Illuminate\Support\Facades\RateLimiter;

class ThrottleStripeApi
{
    public function handle(object $job, callable $next): void
    {
        RateLimiter::attempt(
            key: 'stripe-api',
            maxAttempts: 100,
            callback: fn () => $next($job),
            decaySeconds: 60,
        ) || $job->release(10); // re-queue after 10 s if limit hit
    }
}

Attach it to any job:

public function middleware(): array
{
    return [new ThrottleStripeApi()];
}

Laravel ships Illuminate\Queue\Middleware\RateLimited for Redis-backed limiting with the named limiter API:

use Illuminate\Queue\Middleware\RateLimited;

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

Define the limiter in AppServiceProvider:

RateLimiter::for('stripe', fn () =>
    Limit::perMinute(100)->by('stripe-global')
);

The middleware automatically releases the job back to the queue with a calculated delay, so workers stay busy processing other jobs instead of sleeping.


Takeaways

  • Use batches for parallel fan-out; use then/catch/finally for lifecycle hooks.
  • Use chains for sequential workflows; keep catch closures lightweight and ID-based.
  • Embed a batch inside a chain to get parallel middle steps with sequential bookends.
  • Use rate-limited middleware — not sleep() — to throttle external API calls; workers stay productive.
  • Named RateLimiter definitions keep throttle logic centralized and testable.
  • Always test batch/chain behavior with Queue::fake() and assert on Bus::assertBatched() / Bus::assertChained().

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can a batched job add more jobs to the same batch at runtime?
Yes. Inside a job's handle method, call `$this->batch()->add([...])` to push sibling jobs into the running batch. The batch stays open until all dynamically added jobs also complete.
Q02 What happens to remaining batch jobs when one fails?
By default the batch continues processing remaining jobs and the `catch` callback fires once. Call `$batch->cancel()` inside `catch` if you want to stop all pending jobs immediately.
Q03 Why use rate-limited middleware instead of sleeping inside the job?
Sleeping blocks the worker process for the entire sleep duration, wasting capacity. Rate-limited middleware releases the job back to the queue with a delay, freeing the worker to process other jobs in the meantime.

Continue reading

More Articles

View all