Zenpixl
Blog/Laravel
LaravelJune 27, 2026·5 min read

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 Queues in Production: Horizon, Workers, and What Goes Wrong

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:

  1. Set --max-jobs to restart workers after N jobs processed
  2. Set --max-time to restart workers after N seconds
  3. 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:terminate on every deploy
  • Explicit $tries, $backoff, and $timeout on every job
  • Regular monitoring of failed jobs queue

Running into queue issues on a production Laravel application? We can help.

Z
Md. Mostafizur Rahman
Founder & Lead Engineer at Zenpixl · Top Rated on Upwork
About →

Building something similar?

We help founders and businesses ship SaaS products, AI integrations, and enterprise software.

Start a conversation