---
title: "Scale Laravel workers on Kubernetes"
description: "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."
---

> Queen MQ documentation, for AI agents
> Complete self-contained summary of Queen MQ: https://queenmq.com/llms-brief.txt
> Fetch that first when the question is about the product rather than about this page.
> Index of all pages: https://queenmq.com/llms.txt

# Scale Laravel workers on Kubernetes

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](/integrations/laravel/supervisors/#several-replicas) share one worker target | `QUEEN_SUPERVISOR_COORDINATION=true` |
| Worker memory decides how many workers fit in a pod | [Prefork](/integrations/laravel/supervisors/#prefork-workers): 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](/integrations/laravel/#fast) | `ack_async` and `pop_ahead` |
| Bursts must be served in seconds | [Event-driven scaling](/integrations/laravel/supervisors/#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](/integrations/laravel/monitoring/#prometheus-and-kubernetes-autoscaling), read by KEDA or an HPA | `QUEEN_METRICS_ENABLED=true` |
| A queue that waits too long must page someone | [Long-wait alerts](/integrations/laravel/monitoring/#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:

```php
// 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,
        ],
    ],
],
```

```dotenv
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](/manage/upgrades-monitoring/)
can read the same publications. This grouping does not change replica coordination
([status contract](/reference/supervisor-status/)). 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](/manage/security/) 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:

```dockerfile
# 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:

```dockerfile
# 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

```yaml
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`:

```yaml
          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
<?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:

```yaml
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](/integrations/laravel/supervisors/#prefork-workers)
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:

```bash
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](/integrations/laravel/supervisors/#two-fixed-workers-for-slow-jobs)
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](/benchmarks/laravel/#where-the-difference-comes-from) for the
measurement method and the prefork comparison.

## What to expect

The rows come from the [Laravel benchmark](/benchmarks/laravel/), on a 16-vCPU Linux server against
a single Raft node: prefork from the
[memory runs](/benchmarks/laravel/#where-the-difference-comes-from) of 2026-10-05, the other two
from the [feature lanes](/benchmarks/laravel/#the-worker-side-features-one-at-a-time) 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](/integrations/laravel/supervisors/) used here, and
[monitoring](/integrations/laravel/monitoring/) for job metrics, tags and long-wait alerts.

Source: https://queenmq.com/integrations/laravel/kubernetes/index.mdx
