Skip to content

Scale Laravel workers on Kubernetes

Laravel worker pods that share one worker target, fork cheap workers from one booted Laravel, wake when jobs arrive and autoscale with the backlog through KEDA or an HPA.

Updated View as Markdown

This page puts the supervisor features together for the setup most teams end up with: Laravel workers in a Kubernetes Deployment, several pods sharing the backlog of the same queues, and a replica count that follows that backlog. Inside each pod a supervisor fills its share of the target within seconds of a burst; across pods, KEDA or an autoscaler adds and removes pods over minutes. Every feature is optional and off by default, so start from the table and turn on what your workload needs. The switches in capitals are environment variables; ack_async, pop_ahead,

Why
pods scale over minutes and a burst lasts seconds. A supervisor inside each pod fills the gap at once, and the pod count follows the backlog over time; together they serve a burst without paying for idle pods at night.
supervisor.event_driven and the pool keys go in config/queen.php.

Situation Feature Switch
Several pods work the same queues and consumer group Coordinated replicas share one worker target QUEEN_SUPERVISOR_COORDINATION=true
Worker memory decides how many workers fit in a pod Prefork: Laravel boots once per pod and every worker is forked from it QUEEN_SUPERVISOR_PREFORK=true and opcache.enable_cli=1
Short jobs, where the broker round trip dominates Asynchronous ACKs and popping ahead ack_async and pop_ahead
Bursts must be served in seconds Event-driven scaling with fast scale-up supervisor.event_driven and fast_scale_up
A quiet queue must not wait for a cold start A per-queue minimum min_processes_per_queue
Some queues need workers of their own A simple pool beside an auto pool two entries in supervisors
The number of pods follows the backlog The Prometheus endpoint, read by KEDA or an HPA QUEEN_METRICS_ENABLED=true
A queue that waits too long must page someone Long-wait alerts queen:check-waits every minute

Coordination, prefork, the per-queue minimum, fast scale-up and the Prometheus endpoint need PHP client 1.7.0; event-driven scaling 1.8.0; asynchronous ACKs, popping ahead and lease renewal in the master 1.9.0. PHP client 2.0.0 has all of them.

Configure the pools

One configuration serves every pod. This one keeps two workers on payments in every pod and moves between 1 and 10 workers per pod across three other queues, by backlog:

// config/queen.php
'supervisor' => [
    'shutdown_grace' => 75,
    'process_limit' => 16,
    'event_driven' => true,
    'supervisors' => [
        'payments' => [
            'queues' => ['payments'],
            'balance' => 'simple',
            'processes' => 2,
        ],
        'shared' => [
            'queues' => ['emails', 'default', 'reports'],
            'balance' => 'auto',
            'strategy' => 'size',
            'min_processes' => 1,
            'max_processes' => 10,
            'min_processes_per_queue' => 1,
            'target_jobs_per_process' => 10,
            'fast_scale_up' => true,
            'timeout' => 60,
            'retry_after' => 90,
        ],
    ],
],
QUEEN_SUPERVISOR_COORDINATION=true
QUEEN_SUPERVISOR_PREFORK=true
QUEEN_SUPERVISOR_STATE_DIRECTORY=/var/lib/queen-supervisor
QUEEN_SUPERVISOR_INSTALL_PATH=/opt/queen-supervisor-bin
QUEEN_SUPERVISOR_REMOTE_STATUS=true

With coordination, min_processes and max_processes apply to each pod: three pods run the shared pool between 3 and 30 workers together, while payments runs 2 workers in each. If you also turn on lease renewal (the faster profile needs it), every worker reserves two process slots, one for its renewal helper, so these two pools need a process_limit of 24. Remote status lets the dashboard and the Prometheus endpoint on the web pods see every worker pod, so set QUEEN_SUPERVISOR_REMOTE_STATUS on the web pods too. The status goes under a key that defaults to a slug of APP_NAME and APP_ENV, such as orders-production, so web and worker pods that share both values agree on it. The key is a publication group: use one segment of letters, digits, dots, underscores or hyphens, starting with a letter or digit; coordination is reserved. Every pod writes to its own instance slot. The broker’s Supervisors page can read the same publications. This grouping does not change replica coordination (status contract). Every 2.0 broker serves the long poll of event_driven. The supervisor holds it with the token it reads depth with, and through the broker’s embedded proxy that poll carries the authority of a pop: if the pods reach the broker through the proxy with a read-only key, the supervisor logs it once and keeps polling.

Build the image

Install the native supervisor while the image builds, and create its state directory there. The state directory must be private to the supervisor’s user, under parents only root can write to. A Kubernetes emptyDir is world-writable without the sticky bit, so it can be neither the state directory nor one of its parents.

The launcher starts the binary only when the user it runs as owns the installation, each of its directories and files. An image that installs as root and runs the supervisor as www-data fails at start with exit code 70, and the message names the installation’s owner and the launcher’s user. Since PHP client 2.4.2, --owner=www-data lets root install and verify as itself and then give the installation to www-data, with the same modes:

# The command-line opcache is shared by the forked workers.
RUN echo "opcache.enable_cli=1" > "$PHP_INI_DIR/conf.d/opcache-cli.ini"
RUN QUEEN_SUPERVISOR_INSTALL_PATH=/opt/queen-supervisor-bin \
    php artisan queen:supervisor-install --owner=www-data \
 && install -d -o www-data -g www-data -m 0700 /var/lib/queen-supervisor
USER www-data

--owner needs root, a user that exists where the installer runs (or a numeric uid), and a non-thread-safe PHP command line; FrankenPHP’s PHP is thread-safe and refuses it. In a multi-stage build, the copy into the runtime stage decides the owner, so give it --chown, with or without --owner in the build stage. Without --chown, the directory that COPY creates belongs to root, even where the files in it keep their owner, and the launcher refuses it:

# Build stage, as root.
RUN php artisan queen:supervisor-install --install-path=/opt/queen-supervisor-bin

# Runtime stage.
COPY --from=build --chown=www-data:www-data /opt/queen-supervisor-bin /opt/queen-supervisor-bin
ENV QUEEN_SUPERVISOR_INSTALL_PATH=/opt/queen-supervisor-bin

You can also run the installer as www-data, in a directory that already belongs to it.

vendor/bin/queen-supervisor verifies the installed binary and then replaces itself with it, so the Rust supervisor is the container’s main process and receives Kubernetes’ SIGTERM directly.

Deploy the worker pods

apiVersion: apps/v1
kind: Deployment
metadata:
  name: queen-workers
spec:
  replicas: 3
  strategy:
    type: RollingUpdate      # safe with coordination: old and new pods share the target
  selector:
    matchLabels: { app: queen-workers }
  template:
    metadata:
      labels: { app: queen-workers }
    spec:
      terminationGracePeriodSeconds: 90   # longer than shutdown_grace (75)
      containers:
        - name: supervisor
          image: registry.example.com/orders:1.42.0
          workingDir: /var/www/html
          command: ["vendor/bin/queen-supervisor", "--php", "php", "--artisan", "artisan"]
          envFrom:
            - secretRef: { name: orders-env }
          livenessProbe:
            exec:
              command: ["php", "artisan", "queen:supervisor", "status", "--check-liveness"]
            periodSeconds: 30
            timeoutSeconds: 10
            failureThreshold: 3
          readinessProbe:
            exec:
              command: ["php", "artisan", "queen:supervisor", "status", "--check"]
            periodSeconds: 30
            timeoutSeconds: 10
            failureThreshold: 3

On SIGTERM the supervisor stops starting workers and sends SIGTERM to each of them, so they finish their current job. While they drain, for up to shutdown_grace seconds, it leaves the coordination so that the other pods take its share at their next poll, and then it forces the rest down. Since PHP client 1.9.0 the workers get their SIGTERM before any broker call, so a slow broker cannot delay it. A SIGTERM that arrives during a broker call reaches the workers at once with supervisor 0.8.2, and with the PHP engine of PHP client 2.4.4 once that one call returns, at most http_timeout later; before, both masters finished the pass first. A SIGTERM while the Rust master starts, as it exports the configuration, ends it before any worker starts (supervisor 0.8.2); before, as PID 1 it lost that signal and started workers the deadline then killed. Keep terminationGracePeriodSeconds above shutdown_grace, or Kubernetes kills jobs that were still finishing. A pod that crashes still counts in the coordination until its key expires, so capacity can be short by its share for up to one control-loop bound.

Each probe boots Laravel once, so keep its period in tens of seconds. status --check-liveness asks only whether the master is alive. status --check also requires every pool to be ready: as the readiness probe it holds a rolling update until the new pod’s pools serve, and it fits anything that routes traffic to the pod.

Run the probes as the supervisor’s user. The state directory is private to that user, and the status command refuses any other, root included. An exec probe runs as the container’s user, so with USER www-data in the image, as above, or with securityContext.runAsUser set to the supervisor’s uid, nothing else is needed. If the container starts as root instead, because an entrypoint prepares the state directory and then starts the supervisor as www-data, the probes run as root and fail with must be owned by root or the current user, and the failing liveness probe restarts the pod. Wrap each probe’s command in su:

          livenessProbe:
            exec:
              command: ["su", "-s", "/bin/sh", "www-data", "-c", "php artisan queen:supervisor status --check-liveness"]
            periodSeconds: 30
            timeoutSeconds: 10
            failureThreshold: 3
          readinessProbe:
            exec:
              command: ["su", "-s", "/bin/sh", "www-data", "-c", "php artisan queen:supervisor status --check"]
            periodSeconds: 30
            timeoutSeconds: 10
            failureThreshold: 3

su keeps the working directory, so artisan resolves as before. Do the same for pause, continue and terminate run through kubectl exec. Since PHP client 2.4.2 the refusal goes on to name the state’s owner and the user asking, and shows this su command and the securityContext.runAsUser value to use.

Readiness depends on the broker: queue_depth_unavailable when the master cannot read it, and, since PHP client 2.4.2, pool_not_consuming when every worker of a pool has failed every pop for a minute. A readiness or startup probe that includes them takes the pods out of a rollout for as long as the broker is unreachable, and a rollout that waits for ready pods, such as one with maxUnavailable: 0 or helm upgrade --atomic, stops or is rolled back. If broker outages should raise an alert rather than stop a deploy, keep --check-liveness for liveness and read readiness yourself, without those codes, from a script that needs only Composer’s autoloader:

<?php // scripts/queen-ready.php: exits 1 unless the supervisor is ready, broker aside

use Queen\Laravel\Supervisor\SupervisorState;

require __DIR__ . '/../vendor/autoload.php';

$state = new SupervisorState('/run/queen-supervisor'); // queen.supervisor.state_directory
$status = $state->status();
if ($status === null) {
    exit(1);
}
$issues = array_filter(
    $state->readiness($status)['issues'],
    fn (array $issue): bool => !in_array($issue['code'], ['queue_depth_unavailable', 'pool_not_consuming'], true),
);
exit($issues === [] ? 0 : 1);

A worker that works the wrong connection, or whose pops fail on a LogicException, still fails readiness this way: it exits, its pool’s restarts back off, and its circuit opens.

Without coordination the rule is one master per application and consumer group: one replica, no autoscaler, and strategy: { type: Recreate }.

Scale the pods on the backlog

The web pods serve the Prometheus endpoint, GET /queen/metrics, with a bearer token of at least 32 characters. Point Prometheus at them, then let KEDA scale the worker Deployment on the depth the supervisors report:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: queen-workers
spec:
  scaleTargetRef:
    name: queen-workers
  minReplicaCount: 2
  maxReplicaCount: 10
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring:9090
        query: max(queen_queue_depth{consumer_group="laravel",queue=~"emails|default|reports"})
        threshold: "100"   # max_processes (10) x target_jobs_per_process (10)

A new pod joins the coordination at its first poll and takes its share; a pod removed by a scale-down leaves at once and drains. Inside the pods, event_driven and fast_scale_up fill each pod’s share within seconds, while KEDA changes the number of pods over minutes.

Check that it works

  1. kubectl exec deploy/queen-workers -- php artisan queen:supervisor status --json: in pool_status, each autoscaling pool reports replicas, the number of pods sharing its target.
  2. The dashboard’s Supervisors page lists one card per pod, and each shared pool shows its share. A warning about masters that autoscale one queue means a pod runs without coordination.
  3. In Prometheus, sum(queen_workers) stays within the fleet limits and queen_pool_replicas matches the replica count.
  4. The pod log says event-driven: watching N partition(s). If watching is off, the line says why, such as a token that may not consume.

Read worker count and memory

A worker pod contains the Rust master and ordinary PHP workers. The master starts and monitors them; each worker runs one job at a time. With prefork on, a PHP fork server also holds the booted application. Use the local status to read pool_status[].running and pool_status[].pids, and container metrics to measure the pod:

kubectl exec deploy/queen-workers -- php artisan queen:supervisor status --json
kubectl top pods -l app=queen-workers --containers

process_limit is a child-process budget, including reserved renewal-helper slots, rather than a count of PHP workers currently running. A fixed two-worker pool runs two jobs in parallel when their partitions are available; prefetch adds leased jobs waiting inside those processes.

Measure memory with your own application and job classes. The Rust master’s benchmark memory covers the master alone; every PHP worker also holds its booted application and the allocations of the job it is running. Kubernetes’ container working set includes active file cache and kernel memory, while PHP’s allocation counter covers PHP-managed memory. Process RSS also includes mapped libraries and shared pages, so summing worker RSS can count the same pages twice, especially with prefork. File copies in the entrypoint can warm the file cache, and exec probes that run Artisan start temporary PHP processes. Account for those when choosing memory requests and limits; use the memory benchmark for the measurement method and the prefork comparison.

What to expect

The rows come from the Laravel benchmark, on a 16-vCPU Linux server against a single Raft node: prefork from the memory runs of 2026-10-05, the other two from the feature lanes of 2026-10-01, with 50,000 jobs of 10 ms. Read them as the size of each effect, not a promise for your cluster.

Feature Without With
Prefork and the opcache, application memory of 64 workers idle after 10 ms jobs 2,062 MiB 225 MiB
The same, after jobs that each held 8 MiB 2,590 MiB 755 MiB
Lease renewal in the master, application memory of 32 workers 417 MiB 116 MiB
ack_async and pop_ahead, 32 workers 1,879 jobs/s 2,794 jobs/s

Next: the supervisor settings used here, and monitoring for job metrics, tags and long-wait alerts.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close