Table of Contents
Modern users expect instant responses. Any page that waits on PDFs to render, emails to send, images to process, or APIs to sync will feel sluggish and brittle. Laravel’s queues move that heavy lifting into background jobs so your app stays snappy, reliable, and scalable. This guide walks you end-to-end—from when to queue, to drivers, monitoring, workflow design (chains & batches), and testing.
Why queue long tasks? (what to push off the main thread)
Rule of thumb: if a task isn’t needed to render the current response, queue it.
Typical candidates:
- Email & notifications (especially with attachments)
- Imports/exports, report generation (PDF/CSV)
- Image/video processing, third-party API calls
- Cache warmups, search indexing, webhooks
Basic flow
- Controller quickly validates & stores data.
- Dispatch a job to do the heavy work.
- Return response immediately.
// app/Http/Controllers/OrderController.php
public function store(StoreOrderRequest $request)
{
$order = Order::create($request->validated());
// Offload heavy post-order work
SendInvoiceEmail::dispatch($order)->afterCommit();
SyncCrmContact::dispatch($order->customer_id)->delay(now()->addSeconds(5));
return response()->json(['ok' => true]);
}
Tip: Use
->afterCommit()when dispatching inside a DB transaction to avoid jobs running before data is persisted.
Choose the right queue driver (scale, ops, visibility)
Laravel supports multiple drivers. Pick based on your scale and operational needs:
| Driver | Best For | Pros | Cons |
|---|---|---|---|
| sync | Local/dev quick checks | Zero setup | Not a queue (runs inline) |
| database | Small/medium apps, shared hosting | Simple, portable | Heavy DB write churn at scale; no dashboard |
| redis | Most production apps | Fast, robust; Horizon dashboard; prioritization | Requires Redis |
| SQS | AWS-native, massive scale | Managed & durable | Cloud dependency, separate dashboard |
| Beanstalkd | Niche/legacy | Simple & fast | Extra service to run; fewer tools |
Practical defaults
- Start with database (zero infra) if you’re small.
- Move to Redis + Horizon as soon as background work grows or you need real monitoring and prioritization.
- On AWS-heavy stacks, SQS is also excellent.
# .env
QUEUE_CONNECTION=redis
Running workers safely in production
Use long-lived workers, not queue:listen. Keep them healthy and restart periodically.
# Start workers
php artisan queue:work redis --queue=high,default,low \
--tries=3 --backoff=5 --timeout=60 \
--memory=256 --max-jobs=1000 --max-time=3600
- tries/backoff: retry logic and delay between retries. You can also set these on the job class.
- timeout: must be less than the connection’s
retry_after(e.g., Redis defaultretry_after=90). - priority: pass comma-separated queues to prefer
highbeforedefaultbeforelow. - rotate: use
php artisan queue:restartin your deploy to gracefully reload fresh code.
Supervisor (example)
[program:laravel-queue]
command=php /var/www/app/artisan queue:work redis --queue=high,default,low --tries=3 --timeout=60
numprocs=4
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
stdout_logfile=/var/log/supervisor/laravel-queue.log
stderr_logfile=/var/log/supervisor/laravel-queue-error.log
Monitor job failures (catch issues fast)
1) Failed jobs table & CLI
Enable failed jobs and keep an eye on them:
php artisan queue:failed-table
php artisan migrate
php artisan queue:failed # list
php artisan queue:retry all # or by ID
2) Exceptions & alerting
Hook into queue events to notify your ops channel (email, Slack, Sentry/Bugsnag):
// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Queue;
use Illuminate\Queue\Events\JobFailed;
public function boot(): void
{
Queue::failing(function (JobFailed $event) {
\Log::error('Job failed', [
'job' => $event->job->resolveName(),
'queue' => $event->job->getQueue(),
'exception' => $event->exception->getMessage(),
]);
// e.g. notify Slack/Sentry here…
});
}
3) Horizon (for Redis)
Horizon gives you:
- Real-time dashboard (throughput, runtime, failures)
- Per-queue process balancing & scaling
- Notifications on job failures or long wait times
composer require laravel/horizon
php artisan horizon
Configure notifications (mail/Slack) in your Horizon service provider. Use tags on jobs (public function tags()) to group metrics by tenant, customer, or feature.
Keep complex workflows organized with chained jobs & batches
Chained jobs (sequential and stop-on-failure)
Ideal for linear flows where step 2 requires step 1’s output.
use Illuminate\Support\Facades\Bus;
Bus::chain([
new ResizeImage($imageId),
new OptimizeImage($imageId),
new UploadToS3($imageId),
])->onConnection('redis')->onQueue('images')->dispatch();
If any job fails, the chain stops—no partial, out-of-order work.
Batches (parallel fan-out with lifecycle hooks)
For large imports/exports or many independent tasks run in parallel.
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
use Throwable;
$jobs = collect($rows)->map(fn ($r) => new ImportRow($r))->all();
Bus::batch($jobs)
->name('Customer Import')
->then(fn (Batch $batch) => activity()->log('Import finished')) // all successful
->catch(fn (Batch $batch, Throwable $e) => report($e)) // any job failed
->finally(fn (Batch $batch) => cache()->forget('import:lock')) // always
->dispatch();
Inside a batchable job, use the Batchable trait and check for cancellation:
use Illuminate\Bus\Batchable;
class ImportRow implements ShouldQueue {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, Batchable;
public function handle()
{
if ($this->batch()?->cancelled()) return;
// process…
}
}
Write robust job classes (timeouts, retries, idempotency)
A production-ready job typically includes:
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class SendInvoiceEmail implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 60; // keep < retry_after
public int $tries = 3;
public array|int $backoff = [5, 30, 300];
public int $uniqueFor = 3600; // de-dupe for an hour
public function __construct(public int $orderId) {}
public function uniqueId(): string
{
return (string) $this->orderId; // deduplicate per order
}
public function middleware(): array
{
return [
new RateLimited('emails'), // throttle via RateLimiter
];
}
public function handle(InvoiceMailer $mailer): void
{
// Idempotency: guard against double sends
if (SentEmail::forOrder($this->orderId)->exists()) {
return;
}
$mailer->sendForOrder($this->orderId);
SentEmail::record($this->orderId); // mark as done
}
}
Best practices
- Timeout vs retry_after: job
$timeoutmust be less than the driver’sretry_after. - Idempotency: design jobs to safely re-run (check flags, hashes, or unique constraints).
- De-duplication:
ShouldBeUnique(orShouldBeUniqueUntilProcessing) prevents duplicate dispatches. - Throttling:
RateLimited,ThrottlesExceptionsmiddlewares help with flaky APIs. - Queue targeting:
->onQueue('emails')and->onConnection('redis')for organization.
Testing job logic separately (no hidden errors)
You want two kinds of tests:
1) Does my code dispatch the right jobs?
Use Bus::fake() to intercept dispatches.
use Illuminate\Support\Facades\Bus;
public function test_places_order_and_dispatches_jobs()
{
Bus::fake();
$resp = $this->postJson('/orders', [/* ... */])->assertOk();
Bus::assertDispatched(SendInvoiceEmail::class, function ($job) {
return $job->orderId === 42;
});
}
2) Does the job itself do the right work?
Call handle() in isolation with faked dependencies, or run a real dispatch with fakes for external services:
public function test_send_invoice_job_handles_success()
{
Mail::fake(); // or fake your InvoiceMailer
$job = new SendInvoiceEmail(orderId: 42);
$job->handle(app(InvoiceMailer::class));
Mail::assertSent(InvoiceMailable::class, 1);
}
Tip: Keep job logic small and delegate to services you can unit-test thoroughly.
Observability & operations checklist
- Horizon for Redis queues (metrics, trends, balancing, notifications).
- Tags on jobs for per-tenant/feature visibility:
public function tags(): array { return ['tenant:'.$this->tenantId, 'feature:import']; } - Alerting: on failure spikes, long wait times, or timeouts.
- Graceful deploys:
php artisan queue:restartand rolling restarts in your process manager. - Backpressure: scale worker counts by queue depth & average runtime; separate heavy queues (
reports,images) from critical ones (high).
Common pitfalls to avoid
- Running long work inline: page hangs, timeouts, and poor UX.
- Timeout ≥ retry_after: leads to duplicate processing; always keep timeout lower.
- No idempotency: retries cause double-sends or duplicate rows.
- Single queue for everything: critical jobs starve behind slow ones; split queues and prioritize.
- No monitoring: failures go unnoticed; always wire alerts.
- Dispatching inside transactions (without afterCommit): jobs may see half-written data; use
->afterCommit().
Quick start: minimal, production-ready setup
- Pick driver:
redis(recommended) →composer require predis/predisor use phpredis. - Install Horizon:
composer require laravel/horizon && php artisan horizon. - Configure queues: separate
high,default,low; set saneretry_after,tries,timeout. - Run workers under Supervisor/systemd; add
queue:restartto your deploy. - Add failure alerts via
Queue::failingand Horizon notifications. - Make jobs idempotent + add middlewares (
RateLimited,ThrottlesExceptions). - Test with
Bus::fake()and unit-test jobhandle()logic.
Wrap-up:
Move non-essential work off the request cycle, choose the right driver for your stage, monitor relentlessly, and compose workflows with chains/batches. With strong testing and ops hygiene, Laravel queues give you smooth UX today and scalable headroom for tomorrow.

