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
RateLimiterdefinitions between HTTP and queue layers to enforce a single source of truth for API quotas.