Sending an email, generating a PDF, calling a slow third-party API, resizing an uploaded image — none of these belong in the request-response cycle. A user shouldn’t wait three seconds for a page to load just because your app also emailed them a receipt in the background. Laravel’s queue system lets you push that work onto a background worker so requests stay fast, and it does so with a consistent API regardless of which queue backend you use.
Why Queues Matter
Every synchronous side effect in a controller is a tax on your response time and a liability on your uptime. If the mail server is slow, your checkout page is slow. If a third-party API is down, your registration form throws a 500. Queueing that work means the request finishes as soon as the job is dispatched, and the actual work happens asynchronously, with retries, backoff, and failure handling built in.
Choosing a Queue Driver
Laravel supports several drivers out of the box, configured in config/queue.php via the QUEUE_CONNECTION environment variable. For anything beyond local development, Redis is the pragmatic default — fast, supports delayed jobs natively, and pairs directly with Horizon for monitoring.
composer require predis/predis
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
Avoid the database driver in production once you have any meaningful throughput — polling a table for pending jobs adds load that Redis’s blocking pop operations avoid entirely.
Creating and Dispatching Jobs
Generate a job class with Artisan:
php artisan make:job ProcessPodcast
class ProcessPodcast implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public Podcast $podcast,
) {}
public function handle(AudioProcessor $processor): void
{
$processor->transcode($this->podcast);
$processor->generateWaveform($this->podcast);
$this->podcast->update(['status' => 'ready']);
}
}
Dispatch it from anywhere in your application:
ProcessPodcast::dispatch($podcast);
// Delay execution
ProcessPodcast::dispatch($podcast)->delay(now()->addMinutes(10));
// Target a specific queue
ProcessPodcast::dispatch($podcast)->onQueue('media');
Because $podcast is an Eloquent model, Laravel serializes only its identifier and re-fetches a fresh instance when the job runs — you’re never shipping stale, serialized model state to the queue.
Retries, Backoff, and Failed Jobs
Transient failures — a timeout calling an external API, a momentary lock on a database row — shouldn’t kill a job on the first attempt. Configure retry behavior directly on the job:
class ProcessPodcast implements ShouldQueue
{
public int $tries = 5;
public function backoff(): array
{
return [10, 30, 60, 300, 900];
}
public function failed(Throwable $exception): void
{
$this->podcast->update(['status' => 'failed']);
Log::error('Podcast processing failed permanently', [
'podcast_id' => $this->podcast->id,
'error' => $exception->getMessage(),
]);
}
}
The backoff() array gives each retry a longer delay than the last, so a struggling downstream service gets breathing room instead of being hammered five times a second. failed() runs only after every retry attempt is exhausted — use it to notify someone or flag the record, never to retry manually.
Inspect and manage failed jobs from the CLI:
php artisan queue:failed
php artisan queue:retry 5
php artisan queue:retry all
php artisan queue:forget 5
Job Middleware and Rate Limiting
Job middleware wraps handle() the same way HTTP middleware wraps a request — useful for rate limiting calls to a third-party API across every job that touches it:
public function middleware(): array
{
return [new RateLimited('podcast-transcoding-api')];
}
RateLimiter::for('podcast-transcoding-api', function () {
return Limit::perMinute(50);
});
Jobs that exceed the limit are automatically released back onto the queue to run later, instead of failing outright.
Running the Queue Worker
php artisan queue:work starts a long-running process that pulls and processes jobs. Never use queue:listen in production — it reboots the framework on every job, which is far slower.
php artisan queue:work redis --queue=media,default --tries=3 --timeout=90
A worker process will eventually die — a deploy, a server restart, an out-of-memory kill. Keep it alive with Supervisor:
[program:podcast-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --queue=media,default --sleep=3 --tries=3
autostart=true
autorestart=true
numprocs=3
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker.log
numprocs=3 runs three worker processes concurrently — tune this to your queue’s throughput needs, not just CPU count, since most job time is spent waiting on I/O.
Workers cache your application’s code in memory when they boot. After deploying new code, always run
php artisan queue:restart— it signals every worker to finish its current job and exit gracefully, so Supervisor spins up fresh processes running the new code.
Job Batching
When you need to run a group of jobs and react once they’re all finished — say, processing a bulk CSV import — batches give you that without hand-rolled counters:
$batch = Bus::batch([
new ImportRow($row1),
new ImportRow($row2),
new ImportRow($row3),
])->then(function (Batch $batch) {
// All jobs completed successfully
})->catch(function (Batch $batch, Throwable $e) {
// First failure detected
})->finally(function (Batch $batch) {
// Runs regardless of success or failure
})->dispatch();
Check progress at any time via $batch->progress(), $batch->processedJobs(), or $batch->finished() — handy for a progress bar backed by polling.
Monitoring with Horizon
If you’re on Redis, Horizon gives you a dashboard for queue throughput, job runtime, failed jobs, and worker load — visibility you’d otherwise have to build yourself.
composer require laravel/horizon
php artisan horizon:install
php artisan horizon
Define worker pools declaratively in config/horizon.php instead of hand-writing Supervisor configs:
'environments' => [
'production' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['media', 'default'],
'balance' => 'auto',
'maxProcesses' => 10,
'tries' => 3,
],
],
],
balance => auto lets Horizon shift worker processes between queues based on load — the media queue gets more workers during a traffic spike without you touching a config file.
Checklist Before Going to Production
-
QUEUE_CONNECTIONset toredis(or another production-appropriate driver), neversyncordatabase - Every queued job defines
$triesandbackoff()explicitly — don’t rely on framework defaults for anything that calls an external service -
failed()handlers log or alert — a silently failed job is a silently broken feature - Supervisor (or Horizon) keeps workers alive across restarts and deploys
-
php artisan queue:restartruns as part of every deploy script - Long-running jobs set an explicit
--timeoutshorter than your process manager’s kill timeout
Queues turn “slow and fragile” into “fast and resilient” for anything that doesn’t need to block the response. The upfront cost — a worker process, a bit of monitoring — pays for itself the first time a third-party API has a bad day and your users never notice.

