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

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

3 min read Mohamed Said Mohamed Said

Beyond dispatch(): Composing Reliable Async Workflows

Most Laravel queue tutorials stop at SomeJob::dispatch($payload). Production systems demand more: coordinated fan-out, sequential pipelines, and throttled integrations with third-party APIs. Laravel ships everything you need — the gap is knowing how the primitives compose.


Job Batching with Bus::batch()

Batching lets you dispatch a collection of jobs and react to their collective outcome.

use Illuminate\Support\Facades\Bus;

$batch = Bus::batch([
    new ProcessInvoice($invoice1),
    new ProcessInvoice($invoice2),
    new ProcessInvoice($invoice3),
])
->then(fn (Batch $batch) => SendBatchSummary::dispatch($batch->id))
->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed', [
    'batch' => $batch->id,
    'error' => $e->getMessage(),
]))
->finally(fn (Batch $batch) => $batch->cancelled() ? null : MarkBatchComplete::dispatch())
->name('invoice-processing')
->allowFailures()   // don't cancel the batch on a single failure
->dispatch();

return $batch->id; // store this; poll /api/batches/{id} for progress

allowFailures() is the key production toggle. Without it, one bad invoice cancels all remaining work. With it, you collect partial results and handle failures in catch().

Tracking Batch Progress

$batch = Bus::findBatch($batchId);

return [
    'total'     => $batch->totalJobs,
    'processed' => $batch->processedJobs(),
    'failed'    => $batch->failedJobs,
    'finished'  => $batch->finished(),
];

Horizon surfaces batch data in its UI automatically — no extra wiring needed.


Job Chains for Sequential Pipelines

When order matters, chains guarantee step-by-step execution. Each job only runs if the previous one succeeded.

Bus::chain([
    new ValidateOrder($order),
    new ChargePayment($order),
    new FulfillOrder($order),
    new SendConfirmationEmail($order),
])
->catch(fn (Throwable $e) => OrderFailed::dispatch($order, $e->getMessage()))
->dispatch();

Chains and batches compose. You can nest a batch inside a chain step:

Bus::chain([
    new PrepareExport($report),
    Bus::batch(array_map(
        fn ($chunk) => new ExportChunk($chunk),
        $report->chunks()
    ))->allowFailures(),
    new FinalizeExport($report),
])->dispatch();

The chain pauses at the batch step until all batch jobs finish (or the batch is cancelled), then continues to FinalizeExport.


Rate-Limited Job Middleware

Third-party APIs enforce rate limits. Pushing that logic into a job middleware keeps your job classes clean and makes the constraint reusable.

namespace App\Jobs\Middleware;

use Illuminate\Support\Facades\RateLimiter;

class ThrottleStripeRequests
{
    public function handle(object $job, callable $next): void
    {
        RateLimiter::attempt(
            key: 'stripe-api',
            maxAttempts: 100,          // 100 calls
            callback: fn () => $next($job),
            decaySeconds: 60           // per minute
        ) ?: $job->release(30);        // back off 30 s if limit hit
    }
}

Attach it per job:

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

Or use the built-in RateLimited middleware with a named limiter defined in AppServiceProvider:

// AppServiceProvider::boot()
RateLimiter::for('stripe', fn () =>
    Limit::perMinute(100)->by('stripe-api')
);

// Job class
use Illuminate\Queue\Middleware\RateLimited;

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

The built-in RateLimited middleware automatically re-queues the job with an exponential back-off. Set $tries and $maxExceptions on the job to bound total retries.


Combining All Three

A real-world pattern: fan out work with a batch, throttle each job against an external API, and chain a finalization step.

Bus::chain([
    new FetchLeads($campaign),
    Bus::batch(
        $campaign->leads->map(fn ($lead) => new EnrichLead($lead))
    )->allowFailures(),
    new ScoreCampaign($campaign),
])->dispatch();

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

Takeaways

  • Use Bus::batch() for fan-out work; allowFailures() is almost always correct in production.
  • Store the batch ID immediately — it's your only handle for progress polling.
  • Job chains guarantee sequential execution and short-circuit on failure; nest batches inside chains for hybrid workflows.
  • Rate-limited middleware belongs in a dedicated class, not inside handle() — it's a cross-cutting concern.
  • Combine $tries, $backoff, and RateLimited middleware to get safe, self-throttling jobs without custom retry logic.

Found this useful?

Frequently Asked Questions

3 questions
Q01 What happens to a batch when one job fails and `allowFailures()` is not set?
By default, a single job failure cancels the entire batch. All pending jobs are removed from the queue and the `catch()` callback fires. Setting `allowFailures()` lets remaining jobs continue; failed jobs are counted in `$batch->failedJobs` and you handle them in `catch()` without stopping the rest.
Q02 Can I add jobs to a batch after it has been dispatched?
Yes. From within a batched job you can call `$this->batch()->add([new AnotherJob()])`. This is useful for dynamic fan-out where the total work is not known upfront. The batch's `totalJobs` count updates accordingly.
Q03 How does the built-in `RateLimited` middleware differ from writing my own?
The built-in `RateLimited` middleware handles re-queuing with exponential back-off automatically and integrates with named limiters defined via `RateLimiter::for()`. A custom middleware gives you full control over back-off duration and release logic, which is useful when an API returns a `Retry-After` header you want to honour precisely.

Continue reading

More Articles

View all