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.
RateLimitedmiddleware releases jobs back to the queue automatically; pair it with a generoustriesvalue.WithoutOverlappingprevents concurrent execution per key — always setexpireAfterto 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.