September 20, 2026 7 min read Laravel

Laravel Queue Architecture: Scaling Redis, Horizon, and Job Workers for High-Throughput Processing

Laravel Queue Architecture: Scaling Redis, Horizon, and Job Workers for High-Throughput Processing

Introduction: The Hidden Bottlenecks of Asynchronous Workloads

As web applications scale from handling thousands of requests to processing millions of asynchronous background tasks, queue management becomes the central backbone of system reliability. Whether handling order confirmation emails, webhooks, video encoding, or financial transaction processing, improperly configured background workers will cause silent job failures, memory bloat, database lock escalation, and critical system downtime.

Laravel provides one of the most powerful background job systems in modern PHP, powered by Redis and monitored through Laravel Horizon. However, out-of-the-box configurations are tuned for low-traffic development environments. Pushing high throughput through Laravel queue workers requires intentional infrastructure architecture, worker thread balancing, memory footprint management, and database transaction handling. In this guide, we dive deep into production-grade queue architectures engineered for enterprise software ecosystems built by Laravel Custom Web Applications teams.

Understanding the Laravel Queue Lifecycle and Memory Pitfalls

When a queue worker executes via php artisan queue:work, it bootstraps the entire framework once and keeps the application instance in memory across multiple job executions. This long-running process architecture delivers massive performance boosts compared to traditional short-lived HTTP requests, but it introduces three major runtime risks:

  • Memory Leaks: Statics, singletons, unbounded collection caches, and open database connections accumulate memory over time until the worker crashes.
  • Stale State & Cached Singletons: Variables saved in memory during Job A may inadvertently leak state into Job B if singletons are not reset between runs.
  • Dead Database Connections: Inactive long-running workers can lose connection to MySQL or PostgreSQL due to timeout settings like wait_timeout, throwing MySQL server has gone away exceptions.

Architectural Note: Never use php artisan queue:listen in production. queue:listen re-boots the entire PHP framework on every single job execution, which drastically degrades throughput. Always utilize queue:work or horizon supervised by process control daemons like Systemd or Supervisor.

Configuring Redis and Horizon for Maximum Throughput

Redis is the de facto queue driver for high-throughput Laravel processing due to its in-memory data structures, atomic operational guarantees, and lightweight non-blocking network I/O. To scale worker throughput, modify your config/database.php Redis configuration to leverage dedicated queue connection pools separate from key-value caching.

Snippet
// config/database.php
'redis' => [
    'client' => env('REDIS_CLIENT', 'phpredis'), // Use phpredis C-extension for max performance

    'default' => [
        'url' => env('REDIS_URL'),
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'username' => env('REDIS_USER'),
        'password' => env('REDIS_PASSWORD'),
        'port' => env('REDIS_PORT', '6379'),
        'database' => env('REDIS_DB', '0'),
    ],

    'queues' => [
        'url' => env('REDIS_QUEUE_URL'),
        'host' => env('REDIS_QUEUE_HOST', '127.0.0.1'),
        'username' => env('REDIS_QUEUE_USER'),
        'password' => env('REDIS_QUEUE_PASSWORD'),
        'port' => env('REDIS_QUEUE_PORT', '6379'),
        'database' => env('REDIS_QUEUE_DB', '1'), // Dedicated Redis DB index
        'read_timeout' => 60,
    ],
],

Horizon Supervisor Balancing Strategies

Laravel Horizon introduces dynamic process management for Redis queues. Configuring the right process balancing strategy in config/horizon.php prevents low-priority jobs (e.g., newsletter dispatches) from starving high-priority workloads (e.g., payment gateways).

Balancing Strategy How It Works Ideal Production Use Case
simple Distributes worker processes evenly across queues defined in configuration without reallocation. Predictable background workloads with strict task priority separation.
auto Dynamically reallocates worker processes based on time-to-clear queue metrics. Spiky traffic workloads where queue processing times vary wildly.
false Spawns fixed process counts per queue strictly assigned in configuration. Strict CPU isolation setups where critical queues must never share resource limits.

Here is an optimized production configuration using the auto auto-scaling balancing strategy:

Snippet
// config/horizon.php
'environments' => [
    'production' => [
        'supervisor-high-throughput' => [
            'connection' => 'redis',
            'queue' => ['high', 'default', 'low'],
            'balance' => 'auto',
            'autoScalingStrategy' => 'time',
            'minProcesses' => 5,
            'maxProcesses' => 30,
            'balanceMaxShift' => 3,
            'balanceCooldown' => 3,
            'memory' => 128,
            'tries' => 3,
            'timeout' => 90,
            'nice' => 0,
        ],
    ],
],

Preventing Memory Leaks and Zombie Workers

Even with Horizon dynamically scaling workers, uncollected garbage will exhaust worker RAM over time. You must design queue jobs defensively to ensure long-term stability.

1. Enforce Memory and Execution Bounds

Always set clear memory limit caps using the --memory directive or configuration settings. If a job worker exceeds 128MB, Laravel gracefully terminates the worker process after the current job finishes, allowing process managers (Supervisor/Systemd) to spawn a fresh, clean instance.

2. Chunking and Streaming Large Datasets

Never fetch complete Eloquent models into memory when iterating over thousands of database records inside a job context. Utilize LazyCollection, cursor queries, or explicit chunking to maintain a low flat memory footprint.

Snippet
namespace App\Jobs;

use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class BatchProcessUsersJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function handle(): void
    {
        // Process millions of records using cursors to prevent high RAM allocation
        User::query()
            ->where('is_active', true)
            ->cursor()
            ->each(function (User $user) {
                $this->processUserRecord($user);
                
                // Prevent memory leak by removing hydrated models from Eloquent memory
                unset($user);
            });
    }

    private function processUserRecord(User $user): void
    {
        // Perform specialized user synchronization or auditing
    }
}

Database Deadlocks and Transactional Integrity in Asynchronous Queues

A frequent error pattern in high-concurrency Laravel systems is dispatching jobs within database transactions prior to commit completion. If a worker processes the job before the database transaction commits, the record will not exist yet in the secondary read connection or primary database context, triggering a ModelNotFoundException.

Pro-Tip: Enable 'after_commit' => true in config/queue.php or chain the afterCommit() method on your dispatches to enforce that jobs are pushed to Redis only when the enclosing DB transaction successfully commits.

Snippet
use App\Jobs\GenerateInvoicePdf;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

DB::transaction(function () use ($orderData) {
    $order = Order::create($orderData);

    // Dispatch job ONLY after database transaction commits
    GenerateInvoicePdf::dispatch($order)->afterCommit();
});

Idempotency and Atomic Lock Enforcement

Because background networks are subject to timeouts and retries, queue jobs must be engineered to be idempotent—meaning executing the same job multiple times produces the exact same outcome without duplicate side effects (e.g., double billing a customer).

Use Laravel's Cache::lock() to implement atomic locks within high-concurrency background jobs:

Snippet
namespace App\Jobs;

use App\Models\Payment;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Cache;

class ProcessPaymentJob implements ShouldQueue
{
    use InteractsWithQueue, Queueable;

    public function __construct(public Payment $payment) {}

    public function handle(): void
    {
        // Acquire an atomic lock valid for 10 seconds
        $lock = Cache::lock('process-payment-' . $this->payment->id, 10);

        if (! $lock->get()) {
            // Job is already running on another worker thread; release back to queue
            $this->release(5);
            return;
        }

        try {
            if ($this->payment->isAlreadyProcessed()) {
                return;
            }

            $this->payment->executeCharge();
        } finally {
            $lock->release();
        }
    }
}

Production Deployment Pipeline and Zero-Downtime Worker Restarts

When deploying fresh code to application servers, worker memory holds pre-compiled legacy code. If your application code changes while queue workers are running, execution bugs will immediately surface due to class signature mismatches. Optimizing this pipeline requires seamless integration between continuous integration systems and web engineering practices like our Website Speed & Core Web Vitals and backend application architectures.

Incorporate the following zero-downtime deployment script sequence in your CI/CD pipelines (e.g., GitHub Actions, Forge, Envoyer):

Snippet
#!/usr/bin/env bash
set -e

echo "Deploying code updates..."

Put application into maintenance mode

php artisan down --retry=60

Pull code, install production composer dependencies, and run optimizations

composer install --no-dev --optimize-autoloader php artisan config:cache php artisan route:cache php artisan view:cache php artisan migrate --force

Gracefully restart Laravel Horizon processes

Horizon will finish active jobs before safely shutting down and re-spawning

php artisan horizon:terminate

Bring application online

php artisan up echo "Deployment complete! Queue workers refreshed successfully."

Summary and Architectural Checklist

Scaling Laravel queues to sustain thousands of jobs per second requires moving past default development settings. By setting up dedicated Redis infrastructure, using Horizon's dynamic auto-scaling strategy, guarding against memory leaks with database cursors, and writing strictly idempotent job logic with transactional safety, your backend system can scale effortlessly under extreme burst traffic.

If your enterprise web application requires custom infrastructure scaling, queue optimization, or performance auditing, explore our specialized Web Engineering Services or Contact Tech Aditya to consult directly with senior backend engineering experts.

Frequently Asked Questions

Quick, authoritative answers to common questions on this topic

Q1 What is the difference between queue:work and queue:listen in Laravel?
`php artisan queue:work` bootstraps the framework once and keeps it in memory across job iterations, providing high performance. `php artisan queue:listen` reboots the entire PHP framework on every single job execution, resulting in high overhead and slow throughput. Always use `queue:work` or Horizon in production.
Q2 How do I prevent double-processing of jobs in a high-concurrency queue?
Ensure job idempotency by using atomic locks (`Cache::lock()`) before executing side-effects. Additionally, ensure database modifications use database transactions with `afterCommit()` job dispatches to prevent execution race conditions.
Q3 Why do Laravel queue workers throw 'MySQL server has gone away' errors?
Long-running worker processes can remain idle longer than the database server's connection timeout limits (`wait_timeout`). Ensure your application handles database connection reconnects properly or configure appropriate worker maximum lifetimes using the `--max-time` or `--max-jobs` flags.
Q4 How does Horizon auto-scaling balance worker processes?
Laravel Horizon's auto-balancing algorithm monitors the estimated time required to clear each queue. It automatically shifts background worker processes from idle queues to congested queues in real-time based on the defined `minProcesses` and `maxProcesses` limits.
Aditya Kumar

Aditya Kumar

Full-Stack Engineer

Chat on WhatsApp