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

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

4 min read Mohamed Said Mohamed Said

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.

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:

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.

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:

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.

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:

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:

// 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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 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.
Q02 Does `RateLimited` middleware count against the job's `tries` limit?
No — releasing a job via `$job->release()` inside middleware does not increment the attempt counter. Only a thrown exception or an explicit `$job->fail()` counts as an attempt, so throttled jobs can be re-queued many times without being discarded.
Q03 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.

Continue reading

More Articles

View all