The Illusion of the Comma-Separated Queue Argument
Running php artisan queue:work --queue=high,default,low seems clean. The documentation tells you Laravel will check the high queue first, then default, then low. That's true, but it's also a trap under load.
Here's what happens in production. You launch a feature that queues 20,000 notification jobs onto high. Because your worker processes inspect high first, every available worker grabs a high job. While those 20,000 jobs execute over the next three hours, your app queues 50 invoice generation jobs onto low and 200 account confirmation emails onto default. None of them run. Your users don't receive emails, reports don't render, and your support queue fills up with tickets.
This is classic queue starvation. Sequential queue processing only works when high-priority queues occasionally empty out. When high-priority volume remains steady or spikes suddenly, lower queues stall entirely.
Worker Process Partitioning with Supervisor
The simplest fix doesn't require complex code. It requires separating your worker pool at the process level. Instead of running identical workers that check every queue in order, dedicate distinct worker groups to specific queues using Supervisor.
Here's a battle-tested Supervisor configuration for a Laravel 12 application running on PHP 8.3:
[program:laravel-worker-high]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --queue=high --sleep=3 --tries=3 --max-time=3600 --max-jobs=1000
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=6
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker-high.log
stopwaitsecs=3600
[program:laravel-worker-default]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --queue=default,high --sleep=3 --tries=3 --max-time=3600 --max-jobs=1000
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=3
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker-default.log
stopwaitsecs=3600
[program:laravel-worker-low]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --queue=low,default --sleep=3 --tries=3 --max-time=3600 --max-jobs=1000
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker-low.log
stopwaitsecs=3600Notice the order in the worker command definitions. Six dedicated processes work exclusively on high. They never touch lower queues. Three processes focus on default, but fall back to high if default is completely clear. Two processes handle low, falling back to default when empty.
This layout guarantees that even if 100,000 jobs hit high, you still have two processes constantly churning through low jobs. The processing speed for low-priority tasks drops, but execution time never hits infinity.
Tuning Horizon Dynamic Balancing
If you use Redis and Horizon, configuring process counts manually in Supervisor feels like going backward. Horizon gives you auto-scaling worker pools out of the box. But default Horizon configurations fall victim to the exact same starvation bug if you aren't careful.
Here is how to structure config/horizon.php in Laravel 12 to prevent starvation under the auto balance strategy:
<?php
declare(strict_types=1);
return [
'domain' => env('HORIZON_DOMAIN'),
'path' => env('HORIZON_PATH', 'horizon'),
'use' => 'default',
'defaults' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['high', 'default', 'low'],
'balance' => 'auto',
'autoScalingStrategy' => 'time',
'minProcesses' => 1,
'maxProcesses' => 10,
'balanceMaxShift' => 2,
'balanceCooldown' => 3,
'tries' => 3,
'timeout' => 60,
'memory' => 128,
],
],
'environments' => [
'production' => [
'supervisor-1' => [
'minProcesses' => 2,
'maxProcesses' => 20,
'queue' => ['high', 'default', 'low'],
'balance' => 'auto',
],
'supervisor-low-priority' => [
'connection' => 'redis',
'queue' => ['low'],
'balance' => 'false',
'processes' => 2,
'tries' => 1,
'timeout' => 300,
],
],
],
];The setting that changes everything here is minProcesses inside the balanced supervisor block, combined with a separate un-balanced worker pool for low-priority jobs.
Why Auto-Balancing Can Break Priority Queues
Horizon's auto balance strategy redistributes worker processes based on queue throughput and wait times. When autoScalingStrategy is set to time, Horizon scales up worker allocation for queues with long wait times.
If you set minProcesses => 0 in Horizon, its algorithm will reassign every worker process away from low to clear high during a spike. The low queue process count drops to zero. That brings back total starvation.
Always set minProcesses to at least 1 or 2 per supervisor block. Or better yet, define a completely separate supervisor entry like supervisor-low-priority with balance => 'false'. That guarantees a fixed baseline of worker processes that no automated scaling algorithm can steal.
Real-World Gotchas That Break Queue Isolation
1. Redis Connection Exhaustion
Every PHP process running queue:work holds a persistent connection to Redis. If you configure 15 Supervisor worker instances each spawning 10 processes, you'll open 150 persistent connections just for background processing. Add web application requests, and you can easily exceed Redis's default maxclients limit.
Check your Redis client connection pool settings in config/database.php and ensure your server kernel settings allow enough open file descriptors for the PHP-CLI environment.
2. Unbounded Job Memory Leaks
PHP CLI scripts were not historically written to run endlessly in memory. Even in PHP 8.3, third-party packages, static variables, and unclosed database handles leak memory over thousands of job iterations.
Never run queue:work without --max-jobs or --max-time. Adding --max-jobs=1000 forces the worker process to exit gracefully after processing 1,000 jobs. Supervisor or Horizon will immediately spin up a fresh child process with clean memory allocations. It drops baseline memory usage per worker from 350MB down to 45MB in heavy production workloads.
3. Queue Name Conventions and Dispatch Guards
Developers often hardcode queue names inside job classes using public properties like public string $queue = 'low';. This pattern makes testing difficult and prevents dynamic dispatching based on payload size or user tier.
Instead, pass the target queue at the dispatch site or wrap dispatching in a dedicated action class. Here is an example of dynamic queue routing based on tenant tier in Laravel 12:
<?php
declare(strict_types=1);
namespace App
ootServices;
use AppieldJobsieldGenerateReportJob;
use AppieldModelsieldUser;
final readonly class ReportDispatcher
{
public function dispatch(User $user, array $reportData): void
{
$queueName = match ($user->subscription_tier) {
'enterprise' => 'high',
'pro' => 'default',
default => 'low',
};
GenerateReportJob::dispatch($reportData)
->onQueue($queueName);
}
}By shifting routing logic into explicit dispatchers, you keep job classes reusable while maintaining explicit control over queue allocations across subscription tiers.
Recommendation: Which Strategy Should You Pick?
For smaller applications on single servers, use Supervisor with process partitioning. Define separate Supervisor config files for high, default, and low worker groups with dedicated process limits. It requires zero extra dependencies and guarantees hard resource boundaries.
For high-throughput applications running Redis clusters, use Laravel Horizon with a hybrid balance model: balanced pools for high and default queues with minProcesses configured, plus a fixed-process supervisor pool dedicated exclusively to low-priority work. This setup gives you emergency bursts for critical traffic while preventing low-priority jobs from stalling out.













