A Laravel queue that works fine in staging with one worker and breaks down in production isn't usually a code problem — it's a capacity planning problem nobody did. Scaling queues past a single worker means making explicit decisions about driver, queue segmentation, worker count per queue, and what happens when a job fails, instead of leaving all of it at Laravel's defaults.
Pick a driver that matches your failure tolerance
The database driver is fine for low volume and genuinely convenient — no extra infrastructure, jobs visible in a table you can query — but it doesn't scale past a few hundred jobs a minute before row locking on the jobs table becomes the bottleneck, since every worker polling for the next job takes a row lock to claim it. Redis removes that bottleneck (BRPOP-style blocking pop instead of polling with locks) and is the right default once you're past toy volume. SQS adds at-least-once delivery guarantees and horizontal scaling with no server to manage, at the cost of losing Laravel Horizon's dashboard, which only works with the Redis driver.
// Don't run everything through the "default" queue — segment by workload
// so a burst of slow jobs can't starve fast, latency-sensitive ones.
ProcessPayment::dispatch($order)->onQueue('payments'); // fast, latency-sensitive
GenerateReport::dispatch($report)->onQueue('reports'); // slow, can wait
SendWelcomeEmail::dispatch($user)->onQueue('emails'); // fast, high volume
// horizon.php — separate worker pools per queue, sized independently
'environments' => [
'production' => [
'supervisor-payments' => [
'connection' => 'redis', 'queue' => ['payments'],
'minProcesses' => 2, 'maxProcesses' => 10, 'balance' => 'auto',
],
'supervisor-reports' => [
'connection' => 'redis', 'queue' => ['reports'],
'minProcesses' => 1, 'maxProcesses' => 3, 'timeout' => 600,
],
],
],
One slow queue should not starve a fast one
The most common scaling mistake is running every job type through a single `default` queue with a fixed pool of workers. A burst of slow report-generation jobs fills every worker slot, and password-reset emails — which should complete in milliseconds — sit queued behind them for minutes. Segmenting queues by latency sensitivity and workload, then sizing worker pools per queue independently (Horizon's `supervisor` config, or separate `queue:work` processes with `--queue=` flags), is the fix. Fast queues get a small, cheap pool that's rarely saturated; slow queues get a pool sized for their actual concurrency needs and a longer `--timeout`.
Horizon's balance => 'auto' shifts workers between supervisors based on queue backlog, but it can only redistribute workers you've actually provisioned. If every supervisor is already maxed out, auto-balancing has nothing to redistribute — it's a load-balancing feature, not a substitute for capacity planning.
Failed jobs and retry storms
The default retry behavior — retry a fixed number of times with no backoff — is dangerous at scale. If a downstream dependency (a payment gateway, a third-party API) goes down, every queued job hitting it fails and retries near-instantly, and the retry volume itself can be enough to keep the downstream service from recovering. Exponential backoff via `$job->backoff()` returning an increasing array of delays, combined with a sane `$tries` limit and a `failed()` method that actually does something (alert, log to a dead-letter queue), turns a retry storm into a controlled degradation instead of a cascading outage.
class ChargeCustomer implements ShouldQueue
{
public $tries = 5;
public function backoff(): array
{
return [10, 30, 60, 300, 900]; // seconds, increasing
}
public function failed(Throwable $exception): void
{
// After all retries exhausted — this is not "log and forget"
Notification::route('slack', config('services.slack.ops_webhook'))
->notify(new PaymentJobFailed($this->order, $exception));
}
}
Worker count is a database connection problem too
Every queue worker process holds its own database connection pool. Scaling from 4 workers to 40 because Horizon's autoscaler decided the backlog justified it can silently exhaust your database's max_connections if nobody accounted for it — the queue scales, the database doesn't, and you trade a queue backlog for a "too many connections" outage instead. Worker count needs to be planned against database connection limits, not just against queue throughput targets.
A queue:work process that runs for days without restarting can accumulate memory from framework-level caching and third-party SDKs that weren't designed for long-lived processes. Set --max-jobs or rely on Horizon's automatic worker recycling so processes restart periodically — treat a worker that's been alive for a week with rising RSS as a bug, not a feature.
Wrapping up
Scaling Laravel queues is capacity planning wearing a queue driver's clothes: pick Redis or SQS once volume outgrows the database driver, segment queues by latency sensitivity so slow jobs can't starve fast ones, add backoff to retries so a downstream outage doesn't become a retry storm, and size worker pools against your database's connection limit, not just against queue backlog.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.