Vortos
Messaging

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:relay

Docker 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:

docker/worker/Dockerfile
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

docker/worker/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.log

Command options

CommandOptionDescription
vortos:consume <name>--max-messages=NExit after processing N messages. Useful for cron-driven or batch runs.
vortos:outbox:relay--dry-runList pending outbox rows without producing to Kafka.
vortos:outbox:relay--batch-size=NNumber of rows to fetch per poll cycle (default: 100).
vortos:outbox:relay--sleep-ms=NMilliseconds to sleep between poll cycles when the queue is empty.

Key Settings

SettingValuePurpose
autostart=trueStart process when supervisord boots
autorestart=trueRestart if process exits for any reason
startsecs=33Process must stay up 3 seconds to be considered started
stopwaitsecs=3030Wait up to 30 seconds for graceful shutdown before force-killing

docker-compose.yml

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-net

No 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 worker

Per-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 production

docker/worker/supervisord.conf — stripped down to daemon config and the include:

docker/worker/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

[include]
files = /etc/supervisor/conf.d/*.conf

docker/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.log

docker/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.log

docker/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.log

Docker Compose wiring

Mount the base programs directory and the environment-specific one into /etc/supervisor/conf.d/:

docker-compose.yml (dev)
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:ro
docker-compose.prod.yml
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/prod:/etc/supervisor/conf.d/env:ro

Supervisord 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.log

Restart the worker container

Because supervisord.conf is volume-mounted, no image rebuild is needed — just restart:

docker compose restart worker

Verify the new process is running

docker compose exec worker supervisorctl status
consumer-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:43

Graceful 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 0

The 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-events

Consumer 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.events

All 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)02d

Both approaches achieve the same result. The maximum useful parallelism equals the number of partitions on the topic.

On this page