Why Queues Exist
An HTTP request should do one job: work out what to say, and say it. Anything slow that the user does not need to wait for — email, SMS, thumbnail generation, PDF rendering, third-party API calls, report exports, webhooks — is a candidate for a queue.
// Synchronous — the user waits for the SMTP handshake
public function store(Request $request)
{
$order = Order::create($request->validated());
Mail::to($order->email)->send(new OrderConfirmation($order)); // ~800ms
$this->generateInvoicePdf($order); // ~2s
$this->notifyWarehouseApi($order); // ~1.2s
return redirect()->route('orders.show', $order); // 4+ seconds later
}
// Queued — the response returns in ~50ms
public function store(Request $request)
{
$order = Order::create($request->validated());
SendOrderConfirmation::dispatch($order);
GenerateInvoicePdf::dispatch($order);
NotifyWarehouse::dispatch($order);
return redirect()->route('orders.show', $order);
}Beyond speed, queues give you retries. If the warehouse API is down, a synchronous call throws a 500 at your customer. A queued job retries three times over the next few minutes and probably succeeds without anyone noticing.
Choosing a Driver
The driver is set by QUEUE_CONNECTION in .env and configured in config/queue.php.
| Driver | Good for | Trade-off |
|---|---|---|
sync | Local development, tests | Runs immediately in-process — not a queue at all |
database | Small to medium apps, no extra infrastructure | Polls a table; contention under heavy load |
redis | The default choice for production | Needs Redis; pairs with Horizon |
sqs | AWS-hosted, very high volume | Costs per request; 15-minute visibility cap |
beanstalkd | Lightweight dedicated queue server | Another service to run and monitor |
The database driver needs a table:
php artisan make:queue-table
php artisan migrateStart on database. It is genuinely fine up to a few thousand jobs an hour and needs zero extra infrastructure. Move to Redis when you actually feel the contention — the job code does not change.
Creating a Job
php artisan make:job SendOrderConfirmation<?php
namespace App\Jobs;
use App\Models\Order;
use App\Mail\OrderConfirmation;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
class SendOrderConfirmation implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 60;
public function __construct(public Order $order) {}
public function handle(): void
{
Mail::to($this->order->email)
->send(new OrderConfirmation($this->order));
}
public function failed(\Throwable $e): void
{
Log::error('Order confirmation failed', [
'order_id' => $this->order->id,
'error' => $e->getMessage(),
]);
}
}Two details do most of the work here:
ShouldQueueis what makes it asynchronous. Remove that interface and the job runs immediately when dispatched.SerializesModelsstores only the model’s primary key in the payload and re-fetches it fresh when the worker picks the job up. That keeps payloads tiny and guarantees the worker sees current data.
Because SerializesModels re-fetches by ID, a job whose model was deleted before the worker ran will throw ModelNotFoundException. Set public bool $deleteWhenMissingModels = true; on the job to quietly discard it instead.
Anything you pass to the constructor is serialised into the queue payload, so pass IDs and models — not closures, open file handles, or 5 MB of raw data.
Dispatching Jobs
// Basic
SendOrderConfirmation::dispatch($order);
// Delay it
SendOrderConfirmation::dispatch($order)->delay(now()->addMinutes(10));
// Pick a queue (workers can be told which queues to consume)
SendOrderConfirmation::dispatch($order)->onQueue('emails');
// Pick a connection
SendOrderConfirmation::dispatch($order)->onConnection('redis');
// Only if a condition holds
SendOrderConfirmation::dispatchIf($order->email, $order);
SendOrderConfirmation::dispatchUnless($order->is_guest, $order);
// Wait until the surrounding DB transaction commits
SendOrderConfirmation::dispatch($order)->afterCommit();
// Fire it synchronously, ignoring the queue
SendOrderConfirmation::dispatchSync($order);The transaction race. If you dispatch inside a DB::transaction(), a fast worker can pick the job up before the transaction commits — and then fail to find the row. Either call ->afterCommit(), or set 'after_commit' => true on the connection in config/queue.php so it is the default everywhere.
Queue names let you prioritise. Give password resets their own queue and tell the worker to drain it first:
php artisan queue:work --queue=high,default,lowRunning the Worker
# Long-running process — the normal way
php artisan queue:work
# Process the queue then exit — useful in cron-only environments
php artisan queue:work --stop-when-empty
# Restart workers after a deploy (they load code once at boot)
php artisan queue:restart
# See what is waiting
php artisan queue:monitor redis:default --max=100| Command | Behaviour |
|---|---|
queue:work | Boots the framework once and stays alive. Fast. |
queue:listen | Reboots the framework per job. Slow, but picks up code changes. |
queue:work holds your application code in memory. Deploying new code does nothing until you restart the workers. Add php artisan queue:restart to your deploy script — forgetting this is the single most common queue bug in production.
Retries, Timeouts and Backoff
class NotifyWarehouse implements ShouldQueue
{
// Attempt at most 5 times
public int $tries = 5;
// Kill the job if a single attempt exceeds 120 seconds
public int $timeout = 120;
// Stop retrying after 10 minutes regardless of attempt count
public function retryUntil(): \DateTime
{
return now()->addMinutes(10);
}
// Exponential-ish backoff between attempts, in seconds
public function backoff(): array
{
return [10, 30, 60, 300];
}
// Fail immediately on errors that will never succeed
public function handle(): void
{
try {
Http::timeout(30)->post($this->endpoint, $this->payload)->throw();
} catch (RequestException $e) {
if ($e->response->status() === 422) {
$this->fail($e); // bad payload — retrying is pointless
}
throw $e; // anything else — let it retry
}
}
}You can set these globally on the command line too:
php artisan queue:work --tries=3 --timeout=90 --backoff=30 --max-jobs=1000 --max-time=3600Keep --timeout lower than the driver’s visibility timeout (retry_after in config/queue.php). If retry_after fires first, the job gets handed to a second worker while the first is still running it — and you send the email twice.
Handling Failed Jobs
A job that exhausts its attempts lands in the failed_jobs table with its full payload and exception trace.
php artisan make:queue-failed-table
php artisan migrate
php artisan queue:failed # list them
php artisan queue:retry 5 # retry job with id 5
php artisan queue:retry all # retry everything
php artisan queue:forget 5 # delete one
php artisan queue:flush # clear the tableHook the global failure event to alert yourself rather than discovering it a week later:
// app/Providers/AppServiceProvider.php — boot()
use Illuminate\Support\Facades\Queue;
use Illuminate\Queue\Events\JobFailed;
Queue::failing(function (JobFailed $event) {
Log::critical('Queue job failed', [
'connection' => $event->connectionName,
'job' => $event->job->resolveName(),
'exception' => $event->exception->getMessage(),
]);
});Batches and Chains
A chain runs jobs strictly in order, and stops if any link fails:
use Illuminate\Support\Facades\Bus;
Bus::chain([
new ProcessPayment($order),
new GenerateInvoicePdf($order),
new SendOrderConfirmation($order),
])->catch(function (\Throwable $e) {
Log::error('Order pipeline broke', ['error' => $e->getMessage()]);
})->dispatch();A batch runs jobs in parallel and gives you a completion callback and live progress. Perfect for bulk imports:
$batch = Bus::batch(
$rows->chunk(500)->map(fn ($chunk) => new ImportCustomers($chunk))
)->name('Customer import')
->allowFailures()
->then(fn (Batch $b) => Notification::send($user, new ImportFinished($b)))
->catch(fn (Batch $b, $e) => Log::error('Import failed', ['id' => $b->id]))
->finally(fn (Batch $b) => Log::info('Batch done', ['id' => $b->id]))
->dispatch();
// Poll progress from a controller
$batch = Bus::findBatch($batchId);
$batch->progress(); // 0-100
$batch->processedJobs();
$batch->failedJobs;Batches need their own table: php artisan make:queue-batches-table.
Production Setup With Supervisor
A worker started by hand dies with your SSH session. Supervisor keeps it alive and restarts it if it crashes.
; /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker.log
stopwaitsecs=3600sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start laravel-worker:*
sudo supervisorctl status--max-time=3600 makes each worker exit hourly so Supervisor restarts it — a cheap guard against slow memory leaks in long-running PHP processes.
On Redis, install Laravel Horizon (composer require laravel/horizon). You get a dashboard with throughput, wait times, failed jobs and per-queue metrics, plus auto-balancing workers — and you configure everything in config/horizon.php instead of Supervisor config.
Pitfalls Worth Knowing
- Jobs must be idempotent. A worker can crash after doing the work but before marking the job complete, so it runs again. Guard side effects with a check or a unique constraint.
- Use
ShouldBeUniqueto stop duplicate jobs queueing for the same record — useful for “rebuild this report” triggered by rapid edits. - No request context. There is no session, no
auth()->user()and no current URL inside a job. Pass what you need through the constructor. - Queue the mail, not the job.
Mail::to(...)->queue(...)andShouldQueueon a Notification already queue themselves — wrapping them in another job queues twice. - Test with the fake.
Queue::fake()plusQueue::assertPushed(SendOrderConfirmation::class)tests dispatch without running the job.
use Illuminate\Contracts\Queue\ShouldBeUnique;
class RebuildReport implements ShouldQueue, ShouldBeUnique
{
public int $uniqueFor = 300; // lock expires after 5 minutes
public function uniqueId(): string
{
return 'report:' . $this->report->id;
}
}Get the driver, the restart step and idempotency right, and queues will quietly absorb most of your slow work for years without attention.