Symfony#
This guide covers running Symfony long-running services on OORT — from project setup to production deployment across docker run, Docker Compose, Docker Swarm, and Kubernetes.
Throughout this page, my-symfony-app refers to the image you built with the Dockerfile on the Usage page.
Prerequisites#
Before deploying, ensure your Symfony project has:
PHP 8.5 compatibility (or your chosen OORT tag)
Redis for Messenger transport and Mercure scaling
A database for application data
Environment variables configured (
APP_ENV,DATABASE_URL,REDIS_URL, etc.)
Install Redis, Mercure hub, and database as separate containers or managed services.
Symfony Runtime#
What it does
The Symfony Runtime component with Swoole keeps your application bootstrapped in memory and handles HTTP requests through a high-performance async server — similar to Laravel Octane.
When to use it
Use Swoole Runtime as your primary HTTP entry point for web traffic and APIs.
Installation#
composer require runtime/swoole symfony/runtime
Add runtime configuration to composer.json:
{
"extra": {
"runtime": {
"swoole": {
"host": "0.0.0.0",
"port": 80,
"mode": "SWOOLE_PROCESS",
"options": {
"worker_num": 4
}
}
}
}
}
Configuration#
The Swoole runtime reads settings from composer.json extra.runtime.swoole. Override at runtime with environment variables if needed:
APP_RUNTIME=Runtime\Swoole\Runtime
APP_ENV=prod
Starting public/index.php boots the Swoole server when the runtime is configured.
The deployment examples below probe /health. Add a lightweight route that returns HTTP 200 — Symfony does not register one by default:
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class HealthController
{
#[Route('/health', name: 'health', methods: ['GET'])]
public function health(): Response
{
return new Response('OK');
}
}
Without this route, health checks will fail and orchestrators may restart the container.
Deployment#
docker run -d \
--name symfony-swoole \
-p 80:80 \
--env-file .env \
--health-cmd "curl -f http://localhost/health || exit 1" \
--health-interval=30s \
--health-timeout=10s \
--health-retries=3 \
my-symfony-app \
php public/index.php
services:
app:
image: my-symfony-app
ports:
- "80:80"
env_file: .env
command: php public/index.php
depends_on:
redis:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
services:
app:
image: my-symfony-app
deploy:
replicas: 2
restart_policy:
condition: on-failure
update_config:
parallelism: 1
delay: 10s
ports:
- target: 80
published: 80
protocol: tcp
mode: host
env_file: .env
command: php public/index.php
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
apiVersion: apps/v1
kind: Deployment
metadata:
name: symfony-app
spec:
replicas: 2
selector:
matchLabels:
app: symfony-app
template:
metadata:
labels:
app: symfony-app
spec:
containers:
- name: app
image: my-symfony-app
ports:
- containerPort: 80
command: ["php", "public/index.php"]
envFrom:
- secretRef:
name: symfony-env
livenessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 15
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: symfony-app
spec:
selector:
app: symfony-app
ports:
- port: 80
targetPort: 80
Symfony Messenger#
What it does
Messenger routes messages (commands, events, queries) to handlers synchronously or asynchronously via transport backends like Redis, AMQP, or Doctrine.
When to use it
Run a messenger:consume worker container for every async transport your application defines.
Installation#
composer require symfony/messenger
Configure transports in config/packages/messenger.yaml:
framework:
messenger:
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
'App\Message\AsyncMessage': async
Configuration#
MESSENGER_TRANSPORT_DSN=redis://redis:6379/messages
REDIS_URL=redis://redis:6379
List available transports:
php bin/console debug: messenger
Deployment#
docker run -d \
--name symfony-messenger \
--env-file .env \
my-symfony-app \
php bin/console messenger:consume async --time-limit=3600 --memory-limit=256M -vv
services:
messenger:
image: my-symfony-app
env_file: .env
command: php bin/console messenger:consume async --time-limit=3600 --memory-limit=256M
depends_on:
redis:
condition: service_started
restart: unless-stopped
services:
messenger:
image: my-symfony-app
deploy:
replicas: 2
restart_policy:
condition: on-failure
env_file: .env
command: php bin/console messenger:consume async --time-limit=3600 --memory-limit=256M
apiVersion: apps/v1
kind: Deployment
metadata:
name: symfony-messenger
spec:
replicas: 2
selector:
matchLabels:
app: symfony-messenger
template:
metadata:
labels:
app: symfony-messenger
spec:
containers:
- name: messenger
image: my-symfony-app
command:
- php
- bin/console
- messenger:consume
- async
- --time-limit=3600
- --memory-limit=256M
envFrom:
- secretRef:
name: symfony-env
Symfony Scheduler#
What it does
The Scheduler component defines recurring tasks as messages. A dedicated Messenger transport (scheduler_default) dispatches scheduled messages to their handlers.
When to use it
Run a messenger:consume scheduler_default worker to process scheduled tasks without relying on host cron.
Installation#
composer require symfony/scheduler
Define schedules as message classes with #[AsSchedule] or #[AsPeriodicTask] attributes:
use Symfony\Component\Scheduler\Attribute\AsPeriodicTask;
#[AsPeriodicTask(frequency: '1 hour')]
class CleanupOldRecords
{
public function __invoke(): void
{
// cleanup logic
}
}
Configuration#
The scheduler uses Messenger under the hood. Ensure framework.scheduler is enabled (auto-configured with the component).
Verify scheduled tasks:
php bin/console debug:scheduler
Deployment#
docker run -d \
--name symfony-scheduler \
--env-file .env \
my-symfony-app \
php bin/console messenger:consume scheduler_default --time-limit=3600
services:
scheduler:
image: my-symfony-app
env_file: .env
command: php bin/console messenger:consume scheduler_default --time-limit=3600
restart: unless-stopped
services:
scheduler:
image: my-symfony-app
deploy:
replicas: 1
restart_policy:
condition: on-failure
env_file: .env
command: php bin/console messenger:consume scheduler_default --time-limit=3600
apiVersion: apps/v1
kind: Deployment
metadata:
name: symfony-scheduler
spec:
replicas: 1
selector:
matchLabels:
app: symfony-scheduler
template:
metadata:
labels:
app: symfony-scheduler
spec:
containers:
- name: scheduler
image: my-symfony-app
command:
- php
- bin/console
- messenger:consume
- scheduler_default
- --time-limit=3600
envFrom:
- secretRef:
name: symfony-env
Symfony Mercure#
What it does
Mercure enables real-time updates via the Mercure protocol (Server-Sent Events). The Symfony Mercure bundle publishes updates from your application; the Mercure hub distributes them to subscribers.
When to use it
Use Mercure for live dashboards, notifications, and collaborative features. Run the hub as a separate OORT container using the same my-symfony-app image and Freddie — a PHP Mercure hub that fits the OORT stack without an external Caddy image.
Installation#
composer require symfony/mercure-bundle freddie/mercure-x
Configure the Mercure bundle in config/packages/mercure.yaml:
mercure:
hubs:
default:
url: '%env(MERCURE_URL)%'
public_url: '%env(MERCURE_PUBLIC_URL)%'
jwt:
secret: '%env(MERCURE_JWT_SECRET)%'
publish: ['*']
Configuration#
Symfony application
MERCURE_URL=http://mercure:3000/.well-known/mercure
MERCURE_PUBLIC_URL=http://localhost:3000/.well-known/mercure
MERCURE_JWT_SECRET=!ChangeThisMercureHubJWTSecretKey!
Mercure hub (OORT container running Freddie)
Use the same JWT_SECRET_KEY value as MERCURE_JWT_SECRET. For production, point Freddie at Redis so the hub can scale with multiple replicas:
X_LISTEN=0.0.0.0:3000
JWT_SECRET_KEY=!ChangeThisMercureHubJWTSecretKey!
TRANSPORT_DSN=redis://redis:6379
Start the hub with php bin/console freddie:serve. Freddie exposes /.well-known/mercure on the listen address above.
Health checks
Freddie does not ship the Caddy admin /mercure/health/* endpoints from the official Mercure hub. Probes should verify that the hub HTTP listener accepts connections on /.well-known/mercure. A 4xx response still indicates the process is running; only connection errors mean the hub is down.
Deployment#
Start the Mercure hub and Symfony app:
docker run -d \
--name mercure \
-p 3000:3000 \
-e X_LISTEN=0.0.0.0:3000 \
-e JWT_SECRET_KEY='!ChangeThisMercureHubJWTSecretKey!' \
-e TRANSPORT_DSN=redis://redis:6379 \
--env-file .env \
--health-cmd "curl -fsS --max-time 3 http://127.0.0.1:3000/.well-known/mercure -o /dev/null || test $? -eq 22" \
--health-interval=10s \
--health-timeout=5s \
--health-retries=5 \
--health-start-period=30s \
my-symfony-app \
php bin/console freddie:serve
docker run -d \
--name symfony-app \
-p 80:80 \
--env-file .env \
my-symfony-app \
php public/index.php
services:
app:
image: my-symfony-app
ports:
- "80:80"
env_file: .env
command: php public/index.php
depends_on:
mercure:
condition: service_healthy
restart: unless-stopped
mercure:
image: my-symfony-app
ports:
- "3000:3000"
env_file: .env
command: php bin/console freddie:serve
environment:
X_LISTEN: "0.0.0.0:3000"
JWT_SECRET_KEY: "!ChangeThisMercureHubJWTSecretKey!"
TRANSPORT_DSN: "redis://redis:6379"
depends_on:
redis:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "curl -fsS --max-time 3 http://127.0.0.1:3000/.well-known/mercure -o /dev/null || test $? -eq 22"]
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
services:
app:
image: my-symfony-app
deploy:
replicas: 2
restart_policy:
condition: on-failure
ports:
- target: 80
published: 80
protocol: tcp
mode: host
env_file: .env
command: php public/index.php
mercure:
image: my-symfony-app
deploy:
replicas: 1
restart_policy:
condition: on-failure
ports:
- target: 3000
published: 3000
protocol: tcp
mode: host
env_file: .env
command: php bin/console freddie:serve
environment:
X_LISTEN: "0.0.0.0:3000"
JWT_SECRET_KEY: "!ChangeThisMercureHubJWTSecretKey!"
TRANSPORT_DSN: "redis://redis:6379"
healthcheck:
test: ["CMD-SHELL", "curl -fsS --max-time 3 http://127.0.0.1:3000/.well-known/mercure -o /dev/null || test $? -eq 22"]
timeout: 5s
retries: 5
start_period: 30s
apiVersion: apps/v1
kind: Deployment
metadata:
name: symfony-app
spec:
replicas: 2
selector:
matchLabels:
app: symfony-app
template:
metadata:
labels:
app: symfony-app
spec:
containers:
- name: app
image: my-symfony-app
ports:
- containerPort: 80
command: ["php", "public/index.php"]
envFrom:
- secretRef:
name: symfony-env
---
apiVersion: v1
kind: Service
metadata:
name: symfony-app
spec:
selector:
app: symfony-app
ports:
- port: 80
targetPort: 80
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mercure
spec:
replicas: 1
selector:
matchLabels:
app: mercure
template:
metadata:
labels:
app: mercure
spec:
containers:
- name: mercure
image: my-symfony-app
ports:
- containerPort: 3000
command: ["php", "bin/console", "freddie:serve"]
env:
- name: X_LISTEN
value: "0.0.0.0:3000"
- name: JWT_SECRET_KEY
value: "!ChangeThisMercureHubJWTSecretKey!"
- name: TRANSPORT_DSN
value: "redis://redis:6379"
envFrom:
- secretRef:
name: symfony-env
readinessProbe:
exec:
command:
- sh
- -c
- curl -fsS --max-time 3 http://127.0.0.1:3000/.well-known/mercure -o /dev/null || test $? -eq 22
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
exec:
command:
- sh
- -c
- curl -fsS --max-time 3 http://127.0.0.1:3000/.well-known/mercure -o /dev/null || test $? -eq 22
initialDelaySeconds: 30
periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: mercure
spec:
selector:
app: mercure
ports:
- port: 3000
targetPort: 3000
Complete Symfony Stack#
This example runs every Symfony service on OORT in a single stack: Swoole Runtime (HTTP), Messenger worker, Scheduler worker, Mercure hub, plus Redis and PostgreSQL.
Stack Overview#
Service |
Role |
Port |
|---|---|---|
|
HTTP application server (Swoole) |
80 |
|
Async message consumer |
|
|
Scheduled task consumer |
|
|
Real-time event hub |
3000 |
|
Messenger transport, cache |
6379 |
|
Application database |
5432 |
docker network create symfony-net
docker run -d --name redis --network symfony-net redis:alpine
docker run -d --name postgres --network symfony-net \
-e POSTGRES_DB=symfony -e POSTGRES_PASSWORD=secret postgres:16-alpine
docker run -d --name mercure --network symfony-net -p 3000:3000 \
-e X_LISTEN=0.0.0.0:3000 \
-e JWT_SECRET_KEY='!ChangeThisMercureHubJWTSecretKey!' \
-e TRANSPORT_DSN=redis://redis:6379 \
--env-file .env \
--health-cmd "curl -fsS --max-time 3 http://127.0.0.1:3000/.well-known/mercure -o /dev/null || test $? -eq 22" \
--health-interval=10s --health-timeout=5s --health-retries=5 --health-start-period=30s \
my-symfony-app php bin/console freddie:serve
docker run -d --name symfony-app --network symfony-net -p 80:80 --env-file .env \
my-symfony-app php public/index.php
docker run -d --name symfony-messenger --network symfony-net --env-file .env \
my-symfony-app php bin/console messenger:consume async --time-limit=3600
docker run -d --name symfony-scheduler --network symfony-net --env-file .env \
my-symfony-app php bin/console messenger:consume scheduler_default --time-limit=3600
services:
app:
image: my-symfony-app
ports:
- "80:80"
env_file: .env
command: php public/index.php
depends_on:
redis:
condition: service_started
postgres:
condition: service_started
mercure:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
messenger:
image: my-symfony-app
env_file: .env
command: php bin/console messenger:consume async --time-limit=3600 --memory-limit=256M
depends_on:
redis:
condition: service_started
restart: unless-stopped
scheduler:
image: my-symfony-app
env_file: .env
command: php bin/console messenger:consume scheduler_default --time-limit=3600
restart: unless-stopped
mercure:
image: my-symfony-app
ports:
- "3000:3000"
env_file: .env
command: php bin/console freddie:serve
environment:
X_LISTEN: "0.0.0.0:3000"
JWT_SECRET_KEY: "!ChangeThisMercureHubJWTSecretKey!"
TRANSPORT_DSN: "redis://redis:6379"
depends_on:
redis:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "curl -fsS --max-time 3 http://127.0.0.1:3000/.well-known/mercure -o /dev/null || test $? -eq 22"]
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
redis:
image: redis:alpine
restart: unless-stopped
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: symfony
POSTGRES_USER: symfony
POSTGRES_PASSWORD: secret
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
volumes:
postgres_data:
services:
app:
image: my-symfony-app
deploy:
replicas: 2
restart_policy:
condition: on-failure
ports:
- target: 80
published: 80
protocol: tcp
mode: host
env_file: .env
command: php public/index.php
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
messenger:
image: my-symfony-app
deploy:
replicas: 2
restart_policy:
condition: on-failure
env_file: .env
command: php bin/console messenger:consume async --time-limit=3600 --memory-limit=256M
scheduler:
image: my-symfony-app
deploy:
replicas: 1
restart_policy:
condition: on-failure
env_file: .env
command: php bin/console messenger:consume scheduler_default --time-limit=3600
mercure:
image: my-symfony-app
deploy:
replicas: 1
restart_policy:
condition: on-failure
ports:
- target: 3000
published: 3000
protocol: tcp
mode: host
env_file: .env
command: php bin/console freddie:serve
environment:
X_LISTEN: "0.0.0.0:3000"
JWT_SECRET_KEY: "!ChangeThisMercureHubJWTSecretKey!"
TRANSPORT_DSN: "redis://redis:6379"
healthcheck:
test: ["CMD-SHELL", "curl -fsS --max-time 3 http://127.0.0.1:3000/.well-known/mercure -o /dev/null || test $? -eq 22"]
timeout: 5s
retries: 5
start_period: 30s
redis:
image: redis:alpine
deploy:
replicas: 1
restart_policy:
condition: on-failure
postgres:
image: postgres:16-alpine
deploy:
replicas: 1
restart_policy:
condition: on-failure
environment:
POSTGRES_DB: symfony
POSTGRES_USER: symfony
POSTGRES_PASSWORD: secret
apiVersion: v1
kind: Secret
metadata:
name: symfony-env
type: Opaque
stringData:
APP_ENV: prod
DATABASE_URL: "postgresql://symfony:secret@postgres:5432/symfony?serverVersion=16"
MESSENGER_TRANSPORT_DSN: "redis://redis:6379/messages"
REDIS_URL: "redis://redis:6379"
MERCURE_URL: "http://mercure:3000/.well-known/mercure"
MERCURE_PUBLIC_URL: "http://localhost:3000/.well-known/mercure"
MERCURE_JWT_SECRET: "!ChangeThisMercureHubJWTSecretKey!"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: symfony-app
spec:
replicas: 2
selector:
matchLabels:
app: symfony-app
template:
metadata:
labels:
app: symfony-app
spec:
containers:
- name: app
image: my-symfony-app
ports:
- containerPort: 80
command: ["php", "public/index.php"]
envFrom:
- secretRef:
name: symfony-env
livenessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 15
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: symfony-app
spec:
selector:
app: symfony-app
ports:
- port: 80
targetPort: 80
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: symfony-messenger
spec:
replicas: 2
selector:
matchLabels:
app: symfony-messenger
template:
metadata:
labels:
app: symfony-messenger
spec:
containers:
- name: messenger
image: my-symfony-app
command:
- php
- bin/console
- messenger:consume
- async
- --time-limit=3600
- --memory-limit=256M
envFrom:
- secretRef:
name: symfony-env
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: symfony-scheduler
spec:
replicas: 1
selector:
matchLabels:
app: symfony-scheduler
template:
metadata:
labels:
app: symfony-scheduler
spec:
containers:
- name: scheduler
image: my-symfony-app
command:
- php
- bin/console
- messenger:consume
- scheduler_default
- --time-limit=3600
envFrom:
- secretRef:
name: symfony-env
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mercure
spec:
replicas: 1
selector:
matchLabels:
app: mercure
template:
metadata:
labels:
app: mercure
spec:
containers:
- name: mercure
image: my-symfony-app
ports:
- containerPort: 3000
command: ["php", "bin/console", "freddie:serve"]
env:
- name: X_LISTEN
value: "0.0.0.0:3000"
- name: JWT_SECRET_KEY
value: "!ChangeThisMercureHubJWTSecretKey!"
- name: TRANSPORT_DSN
value: "redis://redis:6379"
envFrom:
- secretRef:
name: symfony-env
readinessProbe:
exec:
command:
- sh
- -c
- curl -fsS --max-time 3 http://127.0.0.1:3000/.well-known/mercure -o /dev/null || test $? -eq 22
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
exec:
command:
- sh
- -c
- curl -fsS --max-time 3 http://127.0.0.1:3000/.well-known/mercure -o /dev/null || test $? -eq 22
initialDelaySeconds: 30
periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: mercure
spec:
selector:
app: mercure
ports:
- port: 3000
targetPort: 3000
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:alpine
ports:
- containerPort: 6379
---
apiVersion: v1
kind: Service
metadata:
name: redis
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
env:
- name: POSTGRES_DB
value: symfony
- name: POSTGRES_USER
value: symfony
- name: POSTGRES_PASSWORD
value: secret
---
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432