Worker Setup
Run consumer workers with supervisord — automatic restarts, multiple consumers, and graceful shutdown.
Worker Setup
Vortos consumers run as long-lived PHP CLI processes managed by supervisord. Each consumer pipeline gets its own process. Supervisord handles automatic restarts on crash and graceful shutdown on SIGTERM.
Use worker tooling for package relays
For package-provided relay processes, use php bin/console vortos:worker:list and php bin/console vortos:worker:install. The dedicated Workers section covers managed supervisor blocks for Messaging, AWS SES, Object Store, and future optional packages.
Architecture
Docker container (vortos-worker)
│
└── supervisord (PID 1)
├── consumer-user-events → php bin/console vortos:consume user.events
├── consumer-order-events → php bin/console vortos:consume order.events
├── consumer-payment-events → php bin/console vortos:consume payment.events
└── outbox-relay → php bin/console vortos:outbox:relayDocker Compose manages the container lifecycle. Supervisord manages the process lifecycle inside the container. You never need to touch docker-compose.yml to add a new consumer.
When Docker files are generated by php vortos setup, the worker container is included only when Kafka messaging is selected. If you choose in-memory messaging, setup removes both worker and kafka from the generated compose file.
Dockerfile
The worker container needs supervisord and the Kafka extension installed:
FROM php:8.4-fpm-alpine
RUN apk add --no-cache supervisor git unzip linux-headers $PHPIZE_DEPS \
postgresql-dev librdkafka-dev
RUN pecl install redis mongodb rdkafka \
&& docker-php-ext-enable redis mongodb rdkafka \
&& docker-php-ext-install pdo pdo_pgsql pcntl
RUN mkdir -p /var/log/supervisor
WORKDIR /var/www/html
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]supervisord.conf is not baked into the image — it is volume-mounted from docker/worker/supervisord.conf at runtime (see docker-compose.yml below). This means you can update consumer configuration without rebuilding the image.
pcntl Extension
The pcntl extension is required for signal handling. Without it, SIGTERM and SIGINT signals cannot be caught and the consumer will not shut down gracefully.
supervisord.conf
[supervisord]
nodaemon=true
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
[unix_http_server]
file=/var/run/supervisor.sock
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///var/run/supervisor.sock
; ─── Consumers ───────────────────────────────────────────────
[program:consumer-user-events]
command=php /var/www/html/bin/console vortos:consume user.events
autostart=true
autorestart=true
startsecs=3
stopwaitsecs=30
stdout_logfile=/var/log/supervisor/user-events.out.log
stderr_logfile=/var/log/supervisor/user-events.err.log
[program:consumer-order-events]
command=php /var/www/html/bin/console vortos:consume order.events
autostart=true
autorestart=true
startsecs=3
stopwaitsecs=30
stdout_logfile=/var/log/supervisor/order-events.out.log
stderr_logfile=/var/log/supervisor/order-events.err.log
; ─── Outbox Relay ────────────────────────────────────────────
[program:outbox-relay]
command=php /var/www/html/bin/console vortos:outbox:relay
autostart=true
autorestart=true
startsecs=3
stopwaitsecs=10
stdout_logfile=/var/log/supervisor/outbox-relay.out.log
stderr_logfile=/var/log/supervisor/outbox-relay.err.logCommand options
| Command | Option | Description |
|---|---|---|
vortos:consume <name> | --max-messages=N | Exit after processing N messages. Useful for cron-driven or batch runs. |
vortos:outbox:relay | --dry-run | List pending outbox rows without producing to Kafka. |
vortos:outbox:relay | --batch-size=N | Number of rows to fetch per poll cycle (default: 100). |
vortos:outbox:relay | --sleep-ms=N | Milliseconds to sleep between poll cycles when the queue is empty. |
Key Settings
| Setting | Value | Purpose |
|---|---|---|
autostart=true | — | Start process when supervisord boots |
autorestart=true | — | Restart if process exits for any reason |
startsecs=3 | 3 | Process must stay up 3 seconds to be considered started |
stopwaitsecs=30 | 30 | Wait up to 30 seconds for graceful shutdown before force-killing |
docker-compose.yml
worker:
build:
context: .
dockerfile: ./docker/worker/Dockerfile
restart: always
volumes:
- ./:/var/www/html
- ./docker/worker/supervisord.conf:/etc/supervisord.conf:ro
depends_on:
- kafka
- write_db
- redis
networks:
- vortos-netNo command: override — the Dockerfile CMD runs supervisord. restart: always ensures the container restarts if it ever exits (e.g. on server reboot).
The supervisord.conf volume mount (:ro) means consumer configuration is managed in your repository and applied without an image rebuild. After editing docker/worker/supervisord.conf, restart the worker container to pick up the changes:
docker compose restart workerPer-Environment Configuration
Supervisord does not have its own environment concept, but its [include] directive lets you split program blocks into separate files. Combined with Docker Compose overrides, this gives you environment-specific worker configurations without duplicating the base supervisord setup.
Structure
docker/worker/
supervisord.conf ← daemon config + [include] directive only
programs/
base/
outbox-relay.conf ← always runs in every environment
dev/
consumers.conf ← lighter setup for local development
prod/
consumers.conf ← full fleet, numprocs tuned for productiondocker/worker/supervisord.conf — stripped down to daemon config and the include:
[supervisord]
nodaemon=true
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
[unix_http_server]
file=/var/run/supervisor.sock
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///var/run/supervisor.sock
[include]
files = /etc/supervisor/conf.d/*.confdocker/worker/programs/base/outbox-relay.conf — shared across all environments:
[program:outbox-relay]
command=php /var/www/html/bin/console vortos:outbox:relay
autostart=true
autorestart=true
startsecs=3
stopwaitsecs=10
stdout_logfile=/var/log/supervisor/outbox-relay.out.log
stderr_logfile=/var/log/supervisor/outbox-relay.err.logdocker/worker/programs/dev/consumers.conf — single process per consumer for local dev:
[program:consumer-user-events]
command=php /var/www/html/bin/console vortos:consume user.events
autostart=true
autorestart=true
startsecs=3
stopwaitsecs=30
stdout_logfile=/var/log/supervisor/user-events.out.log
stderr_logfile=/var/log/supervisor/user-events.err.logdocker/worker/programs/prod/consumers.conf — scaled up for production:
[program:consumer-user-events]
command=php /var/www/html/bin/console vortos:consume user.events
autostart=true
autorestart=true
startsecs=3
stopwaitsecs=30
numprocs=3
process_name=%(program_name)s_%(process_num)02d
stdout_logfile=/var/log/supervisor/user-events.out.log
stderr_logfile=/var/log/supervisor/user-events.err.logDocker Compose wiring
Mount the base programs directory and the environment-specific one into /etc/supervisor/conf.d/:
worker:
volumes:
- ./:/var/www/html
- ./docker/worker/supervisord.conf:/etc/supervisord.conf:ro
- ./docker/worker/programs/base:/etc/supervisor/conf.d/base:ro
- ./docker/worker/programs/dev:/etc/supervisor/conf.d/env:roworker:
volumes:
- ./:/var/www/html
- ./docker/worker/supervisord.conf:/etc/supervisord.conf:ro
- ./docker/worker/programs/base:/etc/supervisor/conf.d/base:ro
- ./docker/worker/programs/prod:/etc/supervisor/conf.d/env:roSupervisord picks up all .conf files under /etc/supervisor/conf.d/ recursively. Swapping the env mount is the only difference between environments — the base conf and the supervisord daemon config are identical everywhere.
Adding a New Consumer
Register the consumer in your MessagingConfig
#[RegisterConsumer]
public function paymentConsumer(): KafkaConsumerDefinition
{
return KafkaConsumerDefinition::create('payment.events')
->groupId('payment-service')
->retry(RetryPolicy::exponential(attempts: 3, initialDelayMs: 500));
}Add a program block to supervisord.conf
[program:consumer-payment-events]
command=php /var/www/html/bin/console vortos:consume payment.events
autostart=true
autorestart=true
startsecs=3
stopwaitsecs=30
stdout_logfile=/var/log/supervisor/payment-events.out.log
stderr_logfile=/var/log/supervisor/payment-events.err.logRestart the worker container
Because supervisord.conf is volume-mounted, no image rebuild is needed — just restart:
docker compose restart workerVerify the new process is running
docker compose exec worker supervisorctl statusconsumer-user-events RUNNING pid 7, uptime 0:12:43
consumer-order-events RUNNING pid 8, uptime 0:12:43
consumer-payment-events RUNNING pid 9, uptime 0:00:05
outbox-relay RUNNING pid 10, uptime 0:12:43Graceful Shutdown
The vortos:consume command installs signal handlers for SIGTERM and SIGINT:
supervisord sends SIGTERM to consumer process
│
▼
ConsumeCommand signal handler calls ConsumerRunner::stop()
│
▼
KafkaConsumer::stop() sets $running = false
│
▼
Current message finishes processing (handler + middleware + commit/reject)
│
▼
Poll loop exits cleanly
│
▼
Process exits with code 0The stopwaitsecs=30 in supervisord gives the consumer up to 30 seconds to finish its current message before supervisord force-kills it. If your handlers take longer than 30 seconds, increase this value.
Monitoring Workers
# Check status of all managed processes
docker compose exec worker supervisorctl status
# Tail logs for a specific consumer
docker compose exec worker supervisorctl tail -f consumer-user-events
# Read full log file
docker compose exec worker cat /var/log/supervisor/user-events.out.log
# Restart a specific consumer (useful after config changes)
docker compose exec worker supervisorctl restart consumer-user-eventsConsumer Parallelism
To run multiple worker processes for the same consumer (for higher throughput), either:
Option A — Multiple supervisord programs:
[program:consumer-order-events-1]
command=php /var/www/html/bin/console vortos:consume order.events
[program:consumer-order-events-2]
command=php /var/www/html/bin/console vortos:consume order.events
[program:consumer-order-events-3]
command=php /var/www/html/bin/console vortos:consume order.eventsAll three processes use the same groupId — Kafka distributes partitions across them automatically.
Option B — numprocs in supervisord:
[program:consumer-order-events]
command=php /var/www/html/bin/console vortos:consume order.events
numprocs=3
process_name=%(program_name)s_%(process_num)02dBoth approaches achieve the same result. The maximum useful parallelism equals the number of partitions on the topic.