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

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

3 min read Mohamed Said Mohamed Said

Beyond dispatch(): Composing Work in Laravel Queues

Most Laravel applications outgrow simple fire-and-forget dispatches quickly. When you need to process a CSV of 50,000 rows, sync data to three external APIs, then send a summary email — you need batching, chaining, and throttling working together.


Job Batching with Bus::batch()

Batches let you dispatch a collection of jobs and react to their collective outcome.

use Illuminate\Support\Facades\Bus;
use App\Jobs\ProcessOrderRow;

$batch = Bus::batch(
    $orders->map(fn ($order) => new ProcessOrderRow($order))
)->then(function (\Illuminate\Bus\Batch $batch) {
    // All jobs succeeded
    SummaryNotification::dispatch($batch->id);
})->catch(function (\Illuminate\Bus\Batch $batch, \Throwable $e) {
    // First failure — batch continues unless you call $batch->cancel()
    Log::error('Batch failure', ['batch' => $batch->id, 'error' => $e->getMessage()]);
})->finally(function (\Illuminate\Bus\Batch $batch) {
    // Always runs — success or failure
    BatchAudit::record($batch->id, $batch->failedJobs);
})->allowFailures()->dispatch();

Key batch options

  • ->allowFailures() — the batch continues even when individual jobs fail; omit it to cancel on first failure.
  • ->onQueue('imports') — route the entire batch to a specific queue.
  • ->name('order-import') — label it for Horizon's UI.

Store the $batch->id on your model so you can poll progress via Bus::findBatch($id).


Safe Job Chaining

Chaining is sequential: each job only runs if the previous one succeeded.

use App\Jobs\{FetchRemoteData, TransformData, PersistData};

FetchRemoteData::withChain([
    new TransformData(),
    new PersistData(),
])->dispatch($importId);

Passing state between chained jobs

Chained jobs share no memory. Use a shared model or cache key:

class FetchRemoteData implements ShouldQueue
{
    public function handle(): void
    {
        $raw = Http::get($this->url)->json();
        Cache::put("import:{$this->importId}:raw", $raw, now()->addHour());
    }
}

class TransformData implements ShouldQueue
{
    public function handle(): void
    {
        $raw = Cache::get("import:{$this->importId}:raw");
        // transform and store transformed result
    }
}

Avoid injecting large payloads into the job constructor — the serialised payload is stored in your queue backend.


Rate-Limited Middleware

When you're calling a third-party API with a 100 req/min cap, you need to throttle at the job level, not the HTTP client level.

use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Support\Facades\RateLimiter;

// AppServiceProvider::boot()
RateLimiter::for('stripe-api', function () {
    return Limit::perMinute(80); // leave headroom
});

// Job class
public function middleware(): array
{
    return [new RateLimited('stripe-api')];
}

When the limit is hit, the job is released back onto the queue automatically — no manual $this->release() needed.

Combining with WithoutOverlapping

For jobs that must not run concurrently per-tenant:

use Illuminate\Queue\Middleware\{RateLimited, WithoutOverlapping};

public function middleware(): array
{
    return [
        new WithoutOverlapping($this->tenantId),
        new RateLimited('stripe-api'),
    ];
}

WithoutOverlapping uses an atomic cache lock; set ->expireAfter(120) to avoid dead locks if a worker crashes mid-job.


Mixing Batches and Chains

You can nest chains inside a batch — each array element can itself be a chain:

Bus::batch([
    [new ValidateRow($row1), new ImportRow($row1)],
    [new ValidateRow($row2), new ImportRow($row2)],
])->allowFailures()->dispatch();

Each inner array is treated as an ordered chain; the batch tracks them as a unit.


Takeaways

  • Use ->allowFailures() on batches when partial success is acceptable; omit it when atomicity matters.
  • Never pass large objects in job constructors — store state in cache or DB and reference by ID.
  • RateLimited middleware releases jobs back to the queue automatically; pair it with a generous tries value.
  • WithoutOverlapping prevents concurrent execution per key — always set expireAfter to handle worker crashes.
  • Nest chains inside Bus::batch() arrays for parallel-but-ordered workflows.
  • Monitor batch progress via Bus::findBatch($id)->progress() for real-time UI feedback.

Found this useful?

Frequently Asked Questions

3 questions
Q01 What happens to a batch when one job fails and `allowFailures()` is not set?
Without `allowFailures()`, the batch is cancelled on the first failure. Pending jobs are not dispatched, but already-running jobs complete. The `catch` callback fires, and `finally` runs after everything settles.
Q02 How many times will a rate-limited job retry before it is marked failed?
A job released by `RateLimited` middleware does not consume a retry attempt by default. It keeps releasing until the rate limit window clears. Set `$tries` and `$maxExceptions` on the job to cap total attempts if you also want a hard failure ceiling.
Q03 Can I add more jobs to an existing batch after it has been dispatched?
Yes. Call `Bus::findBatch($id)->add([new AnotherJob()])` from within a job that belongs to the same batch. This is useful for dynamically discovered work, such as paginated API responses.

Continue reading

More Articles

View all