Backend development

Laravel Queues in 2026: Horizontal Worker Scaling with Redis and Kubernetes

Ruslan Ismailov Published 14 min read
L

Introduction: When a Monolith Can No Longer Keep Up

Job queues are one of the first things to break under increased load. While traffic is moderate, a single php artisan queue:work process on the server handles the job. But when the number of queued jobs starts growing faster than workers can process them, classic problems emerge: delayed email delivery, stuck exports, piling up notifications, and overflowing queues.

In a monolithic architecture, horizontal worker scaling is painful. You have to manually spin up new processes, monitor their state, and balance the load. In 2026, the Laravel + Redis + Kubernetes stack solves this problem elegantly: you declare the desired state, and the infrastructure adjusts to the load automatically.

This article is a practical guide for mid/senior PHP developers who want to build a reliable, scalable background job processing system based on Laravel Queues, Redis Cluster, and Kubernetes with HPA.

Laravel Queues + Redis Architecture in 2026

Named Queues and Priorities

Laravel lets you organize multiple named queues with different priorities. Workers process queues in the order specified at startup:

php artisan queue:work redis --queue=critical,high,default,low --sleep=3 --tries=3 --max-time=3600

In the job class, priority is set via the $queue property or at dispatch time:

<?php

// Dispatching to a specific queue
SendInvoice::dispatch($order)->onQueue('critical');

// Or via the class property
class ProcessPayment implements ShouldQueue
{
    public string $queue = 'critical';
    public int $tries = 5;
    public int $timeout = 120;
    public int $backoff = 30;
}

Job Batching

Job Batching, introduced in Laravel 8, has become the standard in 2026 for parallel processing of large data volumes. A batch lets you dispatch thousands of jobs and receive a callback upon completion:

<?php

use Illuminate\\Bus\\Batch;
use Illuminate\\Support\\Facades\\Bus;

$batch = Bus::batch([
    new ImportChunk($chunk1),
    new ImportChunk($chunk2),
    new ImportChunk($chunk3),
])->then(function (Batch $batch) {
    // All jobs completed successfully
    ImportCompleted::dispatch($batch->id);
})->catch(function (Batch $batch, Throwable $e) {
    // First failure in the batch
    Log::error('Batch failed', ['batch_id' => $batch->id, 'error' => $e->getMessage()]);
})->finally(function (Batch $batch) {
    // Always runs
    Cache::forget('import_lock');
})->onQueue('high')
  ->dispatch();

queue.php Configuration for Redis

<?php
// config/queue.php

return [
    'default' => env('QUEUE_CONNECTION', 'redis'),

    'connections' => [
        'redis' => [
            'driver'      => 'redis',
            'connection'  => 'queue', // dedicated Redis connection
            'queue'       => env('REDIS_QUEUE', 'default'),
            'retry_after' => 90,
            'block_for'   => 5,   // blocking BLPOP instead of polling
            'after_commit' => true, // dispatch only after transaction commit
        ],
    ],
];

The after_commit parameter is critical in production: it ensures that a job is only added to the queue after the database transaction has been successfully committed.

Setting Up Redis Cluster as a Queue Backend

For high-load systems, a single Redis instance becomes a bottleneck. Redis Cluster distributes data across multiple nodes and provides fault tolerance.

Configure a dedicated Redis connection for queues in config/database.php:

<?php
// config/database.php

'redis' => [
    'client' => env('REDIS_CLIENT', 'phpredis'),

    'options' => [
        'cluster' => env('REDIS_CLUSTER', 'redis'),
        'prefix'  => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
    ],

    'queue' => [
        'url'      => env('REDIS_URL'),
        'host'     => env('REDIS_HOST', '127.0.0.1'),
        'username' => env('REDIS_USERNAME'),
        'password' => env('REDIS_PASSWORD'),
        'port'     => env('REDIS_PORT', '6379'),
        'database' => env('REDIS_QUEUE_DB', '1'),
        // For Redis Cluster
        'cluster'  => [
            ['host' => 'redis-node-1', 'port' => 6379, 'password' => env('REDIS_PASSWORD')],
            ['host' => 'redis-node-2', 'port' => 6379, 'password' => env('REDIS_PASSWORD')],
            ['host' => 'redis-node-3', 'port' => 6379, 'password' => env('REDIS_PASSWORD')],
        ],
        'options'  => [
            'cluster' => 'redis',
        ],
    ],
],

Important note: when using Redis Cluster, all keys for a given queue must land in the same slot. Laravel automatically uses hash tags ({queue_name}) for this, but make sure your version of predis or the phpredis extension supports cluster mode.

Containerizing Workers: Dockerfile and Supervisor

Dockerfile for the Worker

FROM php:8.3-cli-alpine

# System dependencies
RUN apk add --no-cache \
    supervisor \
    git \
    curl \
    libpng-dev \
    oniguruma-dev \
    libxml2-dev

# PHP extensions
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath

# phpredis for better performance
RUN pecl install redis && docker-php-ext-enable redis

# Opcache for CLI (speeds up worker startup)
RUN docker-php-ext-install opcache

# Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /var/www

COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-interaction

COPY . .

# Supervisor config
COPY docker/supervisor/worker.conf /etc/supervisor/conf.d/worker.conf

# Permissions
RUN chown -R www-data:www-data /var/www/storage /var/www/bootstrap/cache

USER www-data

CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]

Supervisor Configuration

; docker/supervisor/worker.conf
[supervisord]
nodaemon=true
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid

[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/artisan queue:work redis \
    --queue=critical,high,default,low \
    --sleep=3 \
    --tries=3 \
    --max-time=3600 \
    --memory=256 \
    --timeout=90
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
numprocs=%(ENV_WORKER_NUMPROCS)s
redirect_stderr=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stopwaitsecs=3600

The WORKER_NUMPROCS environment variable lets you control the number of processes inside a container. Combined with Kubernetes, this provides two-level scaling: number of pods × number of processes per pod.

Deploying Workers as a Kubernetes Deployment with HPA

Kubernetes Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: laravel-queue-worker
  namespace: production
  labels:
    app: laravel-queue-worker
spec:
  replicas: 3
  selector:
    matchLabels:
      app: laravel-queue-worker
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 2
      maxUnavailable: 0  # zero-downtime during updates
  template:
    metadata:
      labels:
        app: laravel-queue-worker
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "9253"
    spec:
      terminationGracePeriodSeconds: 3600  # wait for long-running jobs to finish
      containers:
        - name: worker
          image: your-registry/laravel-app:latest
          imagePullPolicy: Always
          env:
            - name: APP_ENV
              value: production
            - name: QUEUE_CONNECTION
              value: redis
            - name: WORKER_NUMPROCS
              value: "2"
          envFrom:
            - secretRef:
                name: laravel-secrets
          resources:
            requests:
              memory: "256Mi"
              cpu: "200m"
            limits:
              memory: "512Mi"
              cpu: "500m"
          lifecycle:
            preStop:
              exec:
                # Graceful shutdown: wait for the current job to finish
                command: ["/bin/sh", "-c", "php artisan queue:restart && sleep 30"]
      restartPolicy: Always

Horizontal Pod Autoscaler (HPA)

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: laravel-queue-worker-hpa
  namespace: production
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: laravel-queue-worker
  minReplicas: 2
  maxReplicas: 20
  metrics:
    # Scale by CPU
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
    # Scale by custom metric (queue size from Prometheus)
    - type: External
      external:
        metric:
          name: laravel_queue_size
          selector:
            matchLabels:
              queue: default
        target:
          type: AverageValue
          averageValue: "100"  # 1 pod per 100 jobs in the queue
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 30
      policies:
        - type: Pods
          value: 4
          periodSeconds: 60
    scaleDown:
      stabilizationWindowSeconds: 300  # scale down slowly to avoid flapping
      policies:
        - type: Pods
          value: 1
          periodSeconds: 120

Scaling based on a custom metric (Redis queue size) is the key advantage of this approach. When the queue grows, Kubernetes automatically adds workers; when it empties, it reduces their count, saving resources.

Queue Monitoring: Laravel Horizon and Prometheus

Setting Up Laravel Horizon

Laravel Horizon provides a beautiful dashboard and detailed queue statistics. Installation:

composer require laravel/horizon
php artisan horizon:install
php artisan migrate

config/horizon.php configuration for a Kubernetes environment:

<?php
// config/horizon.php

return [
    'use'     => 'default',
    'prefix'  => env('HORIZON_PREFIX', 'horizon:'),
    'middleware' => ['web', 'auth'],

    'waits' => [
        'redis:critical' => 3,   // alert if a job waits >3 sec
        'redis:default'  => 60,
        'redis:low'      => 300,
    ],

    'trim' => [
        'recent'        => 60,
        'pending'       => 60,
        'completed'     => 60,
        'recent_failed' => 10080, // 7 days
        'failed'        => 10080,
        'monitored'     => 10080,
    ],

    'silenced' => [],

    'metrics' => [
        'trim_snapshots' => [
            'job'   => 24,
            'queue' => 24,
        ],
    ],

    'fast_termination' => false,
    'memory_limit'     => 256,

    'environments' => [
        'production' => [
            'supervisor-critical' => [
                'connection' => 'redis',
                'queue'      => ['critical'],
                'balance'    => 'auto',
                'autoScalingStrategy' => 'time',
                'minProcesses' => 2,
                'maxProcesses' => 10,
                'tries'      => 3,
                'timeout'    => 90,
            ],
            'supervisor-default' => [
                'connection' => 'redis',
                'queue'      => ['high', 'default', 'low'],
                'balance'    => 'auto',
                'autoScalingStrategy' => 'size',
                'minProcesses' => 1,
                'maxProcesses' => 20,
                'balanceMaxShift'  => 3,
                'balanceCooldown' => 3,
                'tries'      => 3,
                'timeout'    => 120,
            ],
        ],
    ],
];

Prometheus Metrics for Kubernetes HPA

To export Laravel queue metrics to Prometheus, use the spatie/laravel-prometheus package or write your own endpoint:

<?php
// app/Http/Controllers/MetricsController.php

namespace App\\Http\\Controllers;

use Illuminate\\Support\\Facades\\Redis;

class MetricsController extends Controller
{
    public function index(): string
    {
        $queues = ['critical', 'high', 'default', 'low'];
        $output = '';

        foreach ($queues as $queue) {
            $size = Redis::connection('queue')->llen('queues:' . $queue);
            $output .= "laravel_queue_size{queue=\"$queue\"} $size\n";
        }

        // Failed jobs count
        $failedCount = Redis::connection('queue')->zcard('failed_jobs');
        $output .= "laravel_queue_failed_jobs_total $failedCount\n";

        return response($output, 200, ['Content-Type' => 'text/plain; version=0.0.4']);
    }
}

Register the route without middleware in routes/api.php:

Route::get('/metrics', [MetricsController::class, 'index'])
    ->middleware('throttle:60,1');

Handling Failed Jobs and Dead Letter Queue

Retry Configuration

In the job class, configure a retry strategy with exponential backoff:

<?php

class ProcessWebhook implements ShouldQueue
{
    public int $tries = 5;
    public int $timeout = 60;
    public int $maxExceptions = 3; // fail after 3 exceptions, even if tries are not exhausted

    // Exponential backoff: 10, 60, 180, 420 seconds
    public function backoff(): array
    {
        return [10, 60, 180, 420];
    }

    public function handle(): void
    {
        // process the webhook
    }

    public function failed(Throwable $exception): void
    {
        // Notify Slack/PagerDuty on final failure
        Log::error('Webhook processing failed', [
            'job_id'    => $this->job->getJobId(),
            'error'     => $exception->getMessage(),
            'payload'   => $this->webhook->toArray(),
        ]);

        // Push to dead letter queue for manual analysis
        DeadLetterQueue::push($this->webhook, $exception);
    }
}

Dead Letter Queue via a Dedicated Queue

Implement the Dead Letter Queue pattern using a named dead-letter queue and a separate monitoring worker:

<?php

class DeadLetterQueue
{
    public static function push(mixed $payload, Throwable $exception): void
    {
        Redis::connection('queue')->rpush('queues:dead-letter', json_encode([
            'payload'    => serialize($payload),
            'exception'  => $exception->getMessage(),
            'trace'      => $exception->getTraceAsString(),
            'failed_at'  => now()->toISOString(),
        ]));
    }

    public static function requeue(string $jobId): void
    {
        // Logic to re-dispatch from the DLQ
        Artisan::call('queue:retry', ['id' => [$jobId]]);
    }
}

Artisan Commands for Managing Failed Jobs

# List failed jobs
php artisan queue:failed

# Retry a specific job
php artisan queue:retry 5d5a4fb3-5e82-4af3-bedf-6df64b4ee59b

# Retry all failed jobs
php artisan queue:retry all

# Prune failed jobs older than 7 days
php artisan queue:prune-failed --hours=168

# Check Horizon status
php artisan horizon:status

Production Configuration Tips and Pitfalls

Graceful Shutdown in Kubernetes

One of the main pitfalls is the forced killing of a pod during scale-down. Set terminationGracePeriodSeconds equal to the maximum job timeout, and use a preStop hook to send the queue:restart signal. Workers will finish their current jobs and shut down cleanly.

Memory Leaks in Long-Running Workers

PHP does not free memory as efficiently as Go or Java. Use the --max-time and --memory flags so that workers restart before reaching critical thresholds. Supervisor will automatically start a new process.

Job Idempotency

Always design jobs to be idempotent: running the same job multiple times should not cause duplicate effects. Use Redis locks for critical sections:

<?php

public function handle(): void
{
    $lock = Cache::lock('process-order-' . $this->orderId, 120);

    if (! $lock->get()) {
        Log::warning('Job already running, skipping', ['order_id' => $this->orderId]);
        return;
    }

    try {
        $this->processOrder();
    } finally {
        $lock->release();
    }
}

Separating Queues by Criticality

Do not mix payment jobs and newsletter dispatches in the same queue. Allocate separate Deployments for critical queues with guaranteed resources and separate ones for background tasks with resource limits.

Monitoring Queue Lag

Set up alerts for queue wait time (queue lag). If a job in the critical queue has been waiting more than 5 seconds, that is already an incident. Laravel Horizon displays this metric out of the box on its dashboard.

Redis Persistence

Make sure Redis is configured with AOF persistence (appendonly yes) or use Redis Sentinel/Cluster. Losing queued data when Redis crashes without persistence is a real production risk.

Conclusion

The Laravel Queues + Redis Cluster + Kubernetes HPA stack in 2026 is a mature, production-ready approach to horizontal scaling of background jobs. You get automatic worker scaling based on actual queue size, graceful shutdown without job loss, detailed monitoring via Laravel Horizon and Prometheus, and reliable failure handling through a dead letter queue.

Key principles to remember: jobs should be idempotent, workers should be stateless and easily restartable, and monitoring queue metrics should be the foundation for autoscaling decisions. Start with the base configuration from this article and adapt it to the specifics of your workload.

Technologies

Tags

Ruslan Ismailov

Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →