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, andRateLimitedmiddleware to get safe, self-throttling jobs without custom retry logic.