Laravel Queues in Production: Horizon, Workers, and What Goes Wrong
Laravel's queue system is powerful and mature. But production deployments have edge cases that catch teams off guard. Here's everything you need to know to run queues reliably.
Laravel's queue system is one of the framework's best features. Combined with Horizon for Redis-backed queues, it gives you a powerful, observable async processing system out of the box.
But production queue deployments have a set of failure modes that aren't obvious from the documentation. After running queues in production on dozens of Laravel applications, here's what you need to know.
The Basics: What Belongs in a Queue
Any operation that:
- Takes more than 200ms
- Involves a third-party API call
- Sends an email or notification
- Processes a file or image
- Triggers a webhook
...belongs in a queue, not in a synchronous request.
The rule of thumb: if a user doesn't need the result immediately, queue it. Your response times will thank you.
Queue Configuration
A basic config/queue.php setup with Redis:
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => 'default',
'retry_after' => 90,
'block_for' => null,
],
],
retry_after is critical. It's how long (seconds) before a job that's been picked up but not completed is considered failed and re-queued. Set it longer than your longest expected job. If a job takes 60 seconds and retry_after is 30, jobs will be processed multiple times.
Multiple Queues by Priority
Don't put everything on default. Use named queues to control priority:
// Dispatching with specific queue
SendWelcomeEmail::dispatch($user)->onQueue('high');
GenerateReport::dispatch($report)->onQueue('low');
ProcessImport::dispatch($file)->onQueue('imports');
Run workers with priority order:
php artisan queue:work redis --queue=high,default,low,imports
This worker will always check high first, then default, then low, then imports. Critical notifications never wait behind a bulk import.
Laravel Horizon
Horizon is a dashboard and process manager for Redis queues. Install it and you get:
- Real-time queue metrics
- Job throughput and failure rates
- Failed job inspection and retry
- Worker process monitoring
- Auto-balancing workers based on queue volume
composer require laravel/horizon
php artisan horizon:install
php artisan migrate
Configure your workers in config/horizon.php:
'environments' => [
'production' => [
'supervisor-1' => [
'maxProcesses' => 10,
'balanceMaxShift' => 1,
'balanceCooldown' => 3,
'queue' => ['high', 'default'],
'balance' => 'auto',
],
'supervisor-2' => [
'maxProcesses' => 3,
'queue' => ['low', 'imports'],
'balance' => 'simple',
],
],
],
Keeping Horizon Running in Production
Use Supervisor to keep Horizon alive:
[program:laravel-horizon]
process_name=%(program_name)s
command=php /var/www/your-app/artisan horizon
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/horizon.log
stopwaitsecs=3600
stopwaitsecs=3600 gives Horizon up to an hour to finish processing current jobs before forcibly terminating. Never set this too low or you'll interrupt jobs mid-flight.
Zero-Downtime Deploys
When you deploy new code, your queue workers are still running the old code. Jobs dispatched after the deploy may be processed by workers running stale code.
The fix is horizon:terminate after deployment:
# In your deploy script
git pull
composer install --no-dev
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan horizon:terminate # gracefully stop Horizon
# Supervisor automatically restarts it with new code
horizon:terminate sends a signal to finish the current job and exit cleanly. Supervisor restarts the process, which now runs the new code. Zero interrupted jobs.
Handling Failed Jobs
Jobs fail. Network blips, third-party API downtime, unexpected exceptions. Design for it.
class SendInvoiceEmail implements ShouldQueue
{
public $tries = 3; // retry up to 3 times
public $backoff = [60, 300]; // wait 1min, then 5min between retries
public $timeout = 120; // kill the job if it runs longer than 2 min
public function failed(Throwable $exception): void
{
// Notify the team, log to a dedicated channel, update DB record
Log::channel('slack')->error('Invoice email failed', [
'invoice_id' => $this->invoice->id,
'error' => $exception->getMessage(),
]);
}
}
Set up a failed_jobs table:
php artisan queue:failed-table
php artisan migrate
And regularly review and retry or clean up failed jobs through Horizon's dashboard.
Memory Leaks
Long-running queue workers accumulate memory over time. This is mostly managed by Horizon's worker restart threshold, but you should also:
- Set
--max-jobsto restart workers after N jobs processed - Set
--max-timeto restart workers after N seconds - Monitor memory usage in Horizon dashboard
php artisan queue:work --max-jobs=1000 --max-time=3600
This restarts the worker after 1000 jobs or 1 hour, whichever comes first.
Common Pitfalls
Serializing Eloquent models incorrectly: When a job is serialized, model data is captured at dispatch time. If the model changes before the job runs, you'll process stale data. Use SerializesModels and re-fetch the model inside handle() if freshness matters.
Missing queue worker restart after deploy: The most common production bug. Workers run old code until restarted.
Jobs that are too large: Avoid passing large objects (full file contents, huge arrays) to jobs. Store the file, pass the ID. Keep job payloads under 64KB.
Blocking calls in jobs: Don't use sleep() in jobs for waiting on external conditions. Use delayed dispatch, retries with backoff, or a polling mechanism.
Summary
Laravel queues with Horizon are production-ready when configured properly. The key disciplines:
- Named queues with priority ordering
- Supervisor keeping Horizon alive
horizon:terminateon every deploy- Explicit
$tries,$backoff, and$timeouton every job - Regular monitoring of failed jobs queue
Running into queue issues on a production Laravel application? We can help.