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
catchclosure 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/finallyfor lifecycle hooks. - Use chains for sequential workflows; keep
catchclosures 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
RateLimiterdefinitions keep throttle logic centralized and testable. - Always test batch/chain behavior with
Queue::fake()and assert onBus::assertBatched()/Bus::assertChained().