Symfony Runtime + FrankenPHP — Passer en worker mode
TL;DR — Symfony 5.3 a introduit le Runtime component, qui découple le
Kernelde son contexte d'exécution. Cela permet d'exécuter exactement le même code applicatif derrière PHP-FPM classique, FrankenPHP en worker mode, Swoole, RoadRunner ou ReactPHP, en changeant uniquement le runtime driver. Le gain principal du mode worker : bootstrap Symfony une seule fois, puis garder leKernelen mémoire pour servir des milliers de requêtes — gain de performance typique de 3 à 10× par rapport à FPM. Le prix à payer : gérer les fuites mémoire, l'état global, et accepter une discipline d'écriture stricte (immutabilité, reset entre requêtes, pas destaticmutable). Aujourd'hui en 2026, FrankenPHP est devenu l'option de référence : intégré à Caddy, support HTTP/3 natif, embedded assets, et image Docker officielle maintenue par l'équipe Symfony elle-même.
🧠 Mental model — ASCII + analogie
Le modèle classique PHP-FPM (stateless, "shared-nothing")
┌──────────┐ ┌──────────────────┐ ┌──────────────┐
│ Client │────▶│ Nginx/Apache │────▶│ PHP-FPM │
└──────────┘ └──────────────────┘ │ worker pool │
└──────┬───────┘
│
┌──────────────▼──────────────┐
│ PER REQUEST: │
│ 1. autoload │
│ 2. boot Kernel │
│ 3. compile container? │
│ 4. handle() │
│ 5. terminate() │
│ 6. DIE (mémoire libérée) │
└─────────────────────────────┘Chaque requête recommence à zéro. C'est la simplicité absolue : pas de fuites mémoire, pas d'état partagé, debug trivial. Le prix : chaque requête paye le coût d'instancier le Kernel, charger l'autoload, ouvrir les connexions DB, etc. — typiquement 30 à 80 ms sur un projet réel.
Le modèle worker (FrankenPHP / Swoole / RoadRunner)
┌──────────┐ ┌──────────────────────────────────────┐
│ Client │────▶│ FrankenPHP / Caddy (worker mode) │
└──────────┘ └─────────────────┬────────────────────┘
│
┌────────────────▼────────────────────┐
│ WORKER PROCESS (persistant) │
│ ─ boot Kernel UNE FOIS │
│ ─ boucle: │
│ frankenphp_handle_request( │
│ fn() => handle($req) │
│ ) │
│ ─ Kernel reste en mémoire │
└─────────────────────────────────────┘L'analogie : FPM, c'est un food truck qui se monte et se démonte à chaque commande. FrankenPHP worker mode, c'est un restaurant ouvert 24/7 : la cuisine est chaude, les ustensiles sont prêts, on enchaîne les services. Évidemment, il faut nettoyer entre chaque service (vider les tables = reset des services Symfony) et surveiller la propreté (memory leaks, état résiduel).
🛠️ Code minimal (PHP 8.2+)
1. Le point d'entrée moderne — public/index.php
Depuis Symfony 5.3, le point d'entrée standard est ultra-minimaliste :
<?php
// public/index.php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};Le secret : autoload_runtime.php n'est pas un autoload standard. C'est un fichier généré par Composer (via le plugin symfony/runtime) qui :
- Charge
vendor/autoload.php. - Détecte la classe Runtime à utiliser (via la variable
APP_RUNTIMEou la configextra.runtime.classdanscomposer.json). - Exécute le closure retourné par
index.phpen passant le bon contexte (variables d'environnement,$_SERVER, etc.). - Récupère le
Kernel(ou n'importe quelRunnerInterface) et exécute la boucle de requêtes.
2. Configuration du runtime dans composer.json
{
"require": {
"php": ">=8.2",
"symfony/framework-bundle": "^7.2",
"symfony/runtime": "^7.2"
},
"extra": {
"runtime": {
"class": "Runtime\\FrankenPhpSymfony\\Runtime"
}
}
}Selon le driver choisi :
// FPM / CLI classique (par défaut)
"runtime": { "class": "Symfony\\Component\\Runtime\\SymfonyRuntime" }
// FrankenPHP
"runtime": { "class": "Runtime\\FrankenPhpSymfony\\Runtime" }
// Swoole (via php-runtime/swoole-runtime)
"runtime": { "class": "Runtime\\Swoole\\Runtime" }
// RoadRunner (via baldinof/roadrunner-bundle)
"runtime": { "class": "Baldinof\\RoadRunnerBundle\\Runtime\\Runtime" }
// ReactPHP
"runtime": { "class": "Runtime\\ReactPhp\\Runtime" }3. FrankenPHP — le mode worker en pratique
Le worker script
<?php
// public/worker.php
use App\Kernel;
use Symfony\Component\HttpFoundation\Request;
ignore_user_abort(true);
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
return $kernel;
};Avec le runtime FrankenPHP installé (symfony/runtime + runtime/frankenphp-symfony), ce script n'a même pas besoin d'être modifié. La détection se fait automatiquement à l'exécution si FrankenPHP est en mode worker (variable FRANKENPHP_CONFIG ou directive Caddy worker).
Note version (2026) — Depuis Symfony 7.4, le support FrankenPHP est natif : plus besoin d'installer le package
runtime/frankenphp-symfonyni de définirAPP_RUNTIME, leSymfonyRuntimestandard détecte FrankenPHP. Pour Symfony 6.4 / 7.0-7.3, gardezcomposer require runtime/frankenphp-symfonyetAPP_RUNTIME=Runtime\FrankenPhpSymfony\Runtime. Les exemples ci-dessous restent valides sur les deux générations ; pour 7.4+, vous pouvez simplement omettre la ligneenv APP_RUNTIME ....
Dockerfile minimal (image officielle)
# syntax=docker/dockerfile:1.7
FROM dunglas/frankenphp:1.4-php8.3-alpine AS base
# extensions PHP nécessaires
RUN install-php-extensions \
intl \
opcache \
pdo_pgsql \
redis \
zip \
apcu \
@composer
ENV APP_ENV=prod
ENV FRANKENPHP_CONFIG="worker ./public/index.php"
WORKDIR /app
# dépendances en couche séparée pour cacher
COPY composer.* symfony.lock ./
RUN composer install --no-dev --no-scripts --no-interaction --prefer-dist --no-progress
COPY . .
RUN composer dump-autoload --optimize --no-dev --classmap-authoritative \
&& php bin/console cache:warmup --env=prod \
&& chown -R www-data:www-data var/
# HTTP/3, HTTP/2, HTTP/1.1
EXPOSE 80 443 443/udp
ENTRYPOINT ["docker-php-entrypoint"]
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]Caddyfile pour le mode worker
# Caddyfile
{
frankenphp {
worker {
file ./public/index.php
num 4 # nombre de workers (souvent = nb cores)
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
}
}
}
mon-app.example.com {
root * /app/public
encode zstd gzip
# serve static assets directly (asset embedded)
file_server
# everything else → PHP worker
php_server
}Reset entre requêtes — kernel.reset services
Symfony fournit nativement un mécanisme de reset : tout service implémentant ResetInterface voit sa méthode reset() appelée entre deux requêtes en worker mode. Le Doctrine\Persistence\ManagerRegistry, le Logger, le Stopwatch etc. l'implémentent déjà.
<?php
// src/Service/RequestStateContext.php
declare(strict_types=1);
namespace App\Service;
use Symfony\Contracts\Service\ResetInterface;
final class RequestStateContext implements ResetInterface
{
private ?string $currentUserId = null;
private array $cache = [];
public function set(string $userId): void
{
$this->currentUserId = $userId;
}
public function get(): ?string
{
return $this->currentUserId;
}
public function reset(): void
{
$this->currentUserId = null;
$this->cache = [];
}
}Tag automatique via _instanceof :
services:
_instanceof:
Symfony\Contracts\Service\ResetInterface:
tags: ['kernel.reset'](Déjà fait par autoconfigure: true.)
4. Swoole — alternative historiquement plus mature
composer require runtime/swoole-runtime# .env
APP_RUNTIME=Runtime\\Swoole\\Runtime
SWOOLE_HOST=0.0.0.0
SWOOLE_PORT=8000
SWOOLE_WORKER_NUM=8php public/index.php
# → Swoole HTTP server listening on 0.0.0.0:8000 with 8 workers5. RoadRunner — bundle communautaire
composer require baldinof/roadrunner-bundle spiral/roadrunner# .rr.yaml
version: "3"
server:
command: "php public/index.php"
http:
address: "0.0.0.0:8080"
pool:
num_workers: 8
max_jobs: 1000
supervisor:
ttl: 3600./rr serve🎯 Patterns courants
1. Détection runtime — code compatible FPM + worker
<?php
namespace App\Service;
final class RuntimeDetector
{
public function isWorkerMode(): bool
{
return \function_exists('frankenphp_handle_request')
|| \extension_loaded('swoole')
|| isset($_SERVER['RR_MODE']);
}
}Permet d'ajuster certains comportements : ex. ne pas utiliser register_shutdown_function() en worker mode (appelé une seule fois, à la mort du worker).
2. Préchauffage des connexions
En worker mode, autant ouvrir les connexions DB / Redis au démarrage du worker plutôt qu'à la première requête.
Il n'existe pas d'événement kernel.boot dans Symfony. La bonne approche pour un « warmup une seule fois par worker » est un listener sur kernel.request (l'événement réellement dispatché par la boucle worker) protégé par un drapeau exécuté une seule fois, avec une priorité haute pour passer avant le reste.
<?php
// src/EventListener/WorkerStartupListener.php
declare(strict_types=1);
namespace App\EventListener;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
#[AsEventListener(event: KernelEvents::REQUEST, priority: 4096)]
final class WorkerStartupListener
{
private bool $warmedUp = false;
public function __construct(private readonly ManagerRegistry $doctrine) {}
public function __invoke(RequestEvent $event): void
{
// exécuté une seule fois par worker (et survit aux reset() car
// le listener lui-même n'est pas tagué kernel.reset)
if ($this->warmedUp) {
return;
}
$this->warmedUp = true;
// force la connexion DB au premier passage dans le worker
$this->doctrine->getConnection()->getNativeConnection();
}
}3. Gestion des memory leaks — restart automatique
⚠️ Piège fréquent (et faux dans beaucoup de tutos) : max_requests n'est pas une directive du bloc worker du Caddyfile. Le recyclage par nombre de requêtes se configure via la variable d'environnement MAX_REQUESTS (lue par le runtime FrankenPHP). Le bloc Caddyfile, lui, expose max_consecutive_failures (nombre d'échecs consécutifs tolérés avant crash du process).
# Caddyfile — config réelle FrankenPHP 1.4+
{
frankenphp {
worker {
file ./public/index.php
num 4
max_consecutive_failures 10 # crash après 10 échecs d'affilée
}
}
}# Le recyclage par nb de requêtes passe par l'ENV, PAS le Caddyfile
ENV MAX_REQUESTS=1000 # chaque worker se relance proprement après 1000 requêtesTrois mécanismes de recyclage/restart à connaître :
| Mécanisme | Déclencheur | Usage |
|---|---|---|
MAX_REQUESTS (env) | N requêtes servies | Filet de sécurité anti-leak résiduel |
watch (Caddyfile) | Modification de fichier | Hot-reload en dev |
| Admin API | POST /frankenphp/workers/restart sur :2019 | Restart gracieux à la demande (déploiement, drain) |
# Restart gracieux de tous les workers via l'admin API Caddy
curl -X POST http://localhost:2019/frankenphp/workers/restartSur Swoole / RoadRunner, équivalents : max_request (Swoole), max_jobs (RoadRunner). Toujours configurer une limite, même si votre code est propre — c'est de la défense en profondeur, pas un aveu de bug.
4. Embedded assets — FrankenPHP php_embed
FrankenPHP permet de packager une application Symfony entière dans un binaire statique (Linux/macOS/Windows). Pratique pour distribuer un outil interne ou démo.
# Empaquette toute l'app dans le binaire frankenphp
frankenphp build --output ./my-appL'exécutable contient PHP, Caddy, Symfony et l'app. Lancement : ./my-app run. Le public/ est servi depuis l'EXE.
5. HTTP/3 natif
Aucune config supplémentaire. Caddy active HTTP/3 par défaut sur les domaines HTTPS. Vérifier :
curl --http3 https://mon-app.example.com -I6. Mercure pour le push temps réel
FrankenPHP intègre nativement le hub Mercure (Server-Sent Events). Idéal pour notifier les clients sans WebSocket dédié.
{
frankenphp
mercure {
publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG}
subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG}
}
}🔄 Versions — Symfony 5.4 / 6.4 / 7.x + libs
| Composant | Version | Apport |
|---|---|---|
| Symfony Runtime | 5.3 | Création du composant, support FPM + ReactPHP. |
| Symfony Runtime | 6.0 | Stabilisation, support Swoole. |
| Symfony Runtime | 6.4 LTS | Recommandé production en 2026. |
| Symfony Runtime | 7.0 | Refactor interne, meilleur support RunnerInterface custom. |
| Symfony Runtime | 7.2 | Améliorations CLI runtime (commands en worker). |
| Symfony Runtime | 7.4 | Support FrankenPHP natif — plus besoin de runtime/frankenphp-symfony ni de APP_RUNTIME. |
| FrankenPHP | 1.0 (oct 2023) | Première stable, Caddy 2.7+. |
| FrankenPHP | 1.2 (2024) | Embedded apps, Mercure intégré. |
| FrankenPHP | 1.4 (2025) | Optimisations worker, HTTP/3 stabilisé, max_requests graceful. |
| FrankenPHP | 1.5 (2026) | Watcher mode, hot-reload, image officielle Symfony partenariat. |
| Swoole | 5.x | Coroutines stables, async/await, Fibers PHP 8.1+. |
| OpenSwoole | 22+ | Fork actif de Swoole, mainline en 2026. |
| RoadRunner | 2024.x | v2.x → 2024.x renumbering, gRPC, jobs intégrés. |
| RoadRunner | 2025.x | Performance comparable à FrankenPHP, jobs queues natifs. |
| ReactPHP | 1.4+ | Stable mais usage de niche pour HTTP, surtout pour des serveurs custom. |
| PHP | 8.2+ | readonly classes, enums avancés. |
| PHP | 8.3+ | Typed class constants, #[Override]. |
| PHP | 8.4+ | Property hooks, async patterns. |
En 2026, le consensus de la communauté Symfony :
- FrankenPHP pour les nouveaux projets (intégration la plus propre, support officiel).
- RoadRunner si vous avez déjà des jobs Go side-by-side ou des besoins gRPC.
- Swoole si vous voulez les coroutines fines / async/await PHP.
- FPM reste parfaitement valable pour les apps à trafic modéré ou les ops conservateurs.
⚠️ Pitfalls — 12 pièges réels
Variables
staticmutables. Unestatic $cache = []survit d'une requête à l'autre. C'est un trou mémoire et un vecteur de bug de sécurité (données d'un utilisateur visibles par un autre). Bannir ou utiliser un service avecreset().Properties non réinitialisées. Un service singleton qui accumule des données entre requêtes (ex. un
RequestStack-like maison) doit implémenterResetInterfaceet vider son état.Sessions PHP natives.
session_start()ne fonctionne pas comme attendu en worker. Toujours utiliser le composantsymfony/security-bundleet son session storage abstrait.exit()etdie(). En FPM, ils tuent la requête. En worker mode, ils tuent le worker entier → restart, perte de requêtes en vol. Lever une exception à la place.register_shutdown_function()/__destruct. Appelés à la mort du worker, pas à la fin de la requête. Mettez votre cleanup dans des kernel event listeners (kernel.terminate).Doctrine et identity map. L'
EntityManageraccumule les entités en mémoire. Sans reset (Doctrine le fait automatiquement viaResetInterface), vous gonflez la heap. Toujours validerdoctrine.orm.entity_manager.resetest actif.Logs ouverts en append. Un
fopen('php://stderr')n'est ouvert qu'une fois. OK pour stderr. Mais unfopen('/var/log/app.log')peut rester verrouillé après rotation log (logrotate). Utiliser Monolog avec rotation interne ou stdout/stderr.Container compilé absent. En prod, l'image Docker doit inclure
var/cache/prod/(ou le warmup au build). Sinon le premier worker bootstrap recompile → 5-20s de latence sur la première requête.Cache APCu local au worker. Plusieurs workers FrankenPHP ne partagent pas leur APCu. Pour du cache partagé, utiliser Redis/Memcached. APCu reste utile pour le cache local (ex. metadata Doctrine).
Watcher / hot-reload en dev. Sans watcher activé, modifier un fichier PHP n'est pas pris en compte : le worker garde le vieux code en mémoire. Ajouter la directive
watchau blocworkerdu Caddyfile (sans argument elle surveille*.php,*.yaml,*.yml,*.twig,*.env), ou déclencher un restart gracieux via l'admin API :curl -X POST http://localhost:2019/frankenphp/workers/restart. En prod on désactivewatch(coût inotify + comportement non déterministe).opcache.validate_timestamps=0+ worker = double piège en dev. En worker mode, OPcache garde le bytecode ET le worker garde les objets bootstrapés. Si vous laissezvalidate_timestamps=0(recommandé en prod) sur votre machine de dev, aucune modif n'est visible tant que le worker ne redémarre pas. En dev :validate_timestamps=1+watch.Le contexte
$_SERVERfigé entre requêtes. En worker, le superglobal$_SERVERest repeuplé par FrankenPHP à chaquefrankenphp_handle_request, mais du code qui aurait capturé$_SERVERau boot (ex. dans le constructeur d'un service) verra les valeurs du démarrage du worker, pas de la requête courante. Toujours passer parRequestStack/ l'objetRequestSymfony, jamais par les superglobaux directement.
🧪 Testing
Tester en mode FPM (par défaut)
# tests d'intégration classiques
vendor/bin/phpunit --testdoxRien à faire — les tests utilisent le Symfony\Bundle\FrameworkBundle\Test\KernelTestCase qui boot et reboot le kernel à chaque test.
Tester en mode worker — ResetInterface
<?php
// tests/Worker/ResetTest.php
declare(strict_types=1);
namespace App\Tests\Worker;
use App\Service\RequestStateContext;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class ResetTest extends KernelTestCase
{
public function testStateIsResetBetweenRequests(): void
{
self::bootKernel();
$state = self::getContainer()->get(RequestStateContext::class);
$state->set('user-42');
self::assertSame('user-42', $state->get());
// simule un cycle de requête worker
self::$kernel->getContainer()->get('services_resetter')->reset();
self::assertNull($state->get());
}
}Benchmark FPM vs worker
# FPM
docker run -d --name fpm-app my-app:fpm
ab -n 10000 -c 50 http://localhost:8080/api/users
# FrankenPHP worker
docker run -d --name worker-app my-app:frankenphp
ab -n 10000 -c 50 http://localhost:8080/api/usersSur un projet API REST type (DB + Twig partiel), résultats typiques en 2026 :
| Mode | Req/s | P99 latency | RAM/proc |
|---|---|---|---|
| FPM (8 workers) | 450 | 180 ms | 35 MB |
| FrankenPHP worker (8) | 4 200 | 22 ms | 95 MB |
| Swoole (8 workers) | 4 600 | 18 ms | 88 MB |
| RoadRunner (8 workers) | 4 100 | 24 ms | 105 MB |
(Chiffres indicatifs, dépendent fortement de l'app — DB, cache, taille des templates, etc.)
Stress de stabilité — chasse aux memory leaks
# laisse tourner 1 heure et observe la RAM
docker stats worker-app
# en parallèle
hey -z 1h -c 50 http://localhost:8080/Si la RAM grimpe linéairement, c'est une fuite confirmée. Outils utiles :
xhprofoutidewaysavec sampling.var_dump(memory_get_usage())dans un kernel.terminate listener.- Snapshot heap via
meminfoextension.
🎬 Cas d'usage concrets
SaaS RH worker mode pour perf
Une plateforme SaaS RH multi-tenant dessert plus de 5000 entreprises clientes avec un trafic moyen de 800 req/s en heure de pointe (9h-11h le lundi, période de pointe des validations de congés et notes de frais). Avant migration FrankenPHP, l'app tournait en PHP-FPM avec 200 workers répartis sur 8 pods Kubernetes ; chaque requête payait 45ms de boot Symfony (chargement du container compilé, instanciation Doctrine, hydratation des services), ce qui représentait l'essentiel de la latence sur les endpoints rapides (dashboard, listing de congés). La migration vers FrankenPHP en worker mode a transformé les chiffres : LCP médian dashboard passé de 320ms à 95ms, P99 passé de 850ms à 220ms, et surtout réduction de 60% du nombre de pods nécessaires (de 8 à 3) pour absorber le trafic — économie infra significative. Les défis rencontrés : (1) audit du code legacy pour repérer les static $cache qui auraient été des fuites mémoire silencieuses, remplacés par des services tagués kernel.reset ; (2) refonte du RequestContext qui stockait le tenant courant dans une static (déplacé vers un RequestStateContext implements ResetInterface) ; (3) Doctrine identity map vidée à chaque reset (auto via le bundle) ; (4) configuration max_requests 1000 pour recycler proactivement les workers et éviter les fuites résiduelles. L'image Docker finale tourne sous FrankenPHP 1.4 avec OPcache préchargé, et le déploiement Kubernetes utilise un PreStop hook qui envoie SIGTERM pour un drain gracieux des requêtes en cours avant l'arrêt du pod.
E-commerce Black Friday avec FrankenPHP
Un e-commerçant français de prêt-à-porter affronte chaque année le Black Friday avec un pic à 50 000 req/min concentrées sur 4 heures. Les années précédentes en PHP-FPM, l'infra peinait : autoscaling Kubernetes qui démarrait trop lentement (1 minute pour ajouter un pod = trop long quand la charge double en 30 secondes), latence qui grimpait jusqu'à 3 secondes en P99 au peak, et taux de panier abandonné corrélé à la dégradation. La direction technique a migré vers FrankenPHP 6 mois avant le Black Friday suivant. Bénéfices observés sur le D-Day : (1) chaque pod sert 5 à 10 fois plus de requêtes par seconde qu'en FPM, donc moins de scaling agressif nécessaire ; (2) HTTP/3 natif sur Caddy a réduit le TTFB sur les clients mobiles 4G, particulièrement sensibles aux RTT élevés ; (3) les pages produit servies via worker mode tournent à 18ms moyen contre 95ms auparavant ; (4) la stack Mercure intégrée a permis de pousser en temps réel les baisses de stock aux clients qui hésitaient sur une fiche produit ("plus que 3 en stock !"). Pour la résilience, deux datacenters AWS en multi-région avec routage Cloudflare actif-actif, et un circuit breaker côté Caddy pour bypasser les services tiers défaillants. L'infrastructure a tenu le peak sans dégradation visible, et le CA Black Friday a battu le record précédent de 35% — corrélé en partie à l'amélioration des Core Web Vitals.
Banque API low-latency
Une banque digitale expose une API à des fintechs partenaires (PSD2 et au-delà) avec des SLAs stricts : P99 latence inférieure à 50ms pour les endpoints critiques (lecture solde, dernières opérations, initiation paiement). En PHP-FPM, atteindre ce niveau était impossible : le boot Symfony à lui seul consommait 30-40ms, laissant moins de 20ms pour le métier — irréaliste pour des endpoints qui frappent la DB et plusieurs services internes. La migration vers FrankenPHP a été précédée d'un audit de code rigoureux (banque oblige) pour valider l'absence d'état partagé entre requêtes : revue ligne par ligne des static, des sessions PHP natives (toutes éliminées au profit de JWT stateless), des register_shutdown_function. Le worker mode permet désormais d'atteindre 8ms en P50 sur l'endpoint solde et 22ms en P99, bien sous la SLA. Pour les endpoints d'initiation de paiement (plus lourds : signature, vérification 3DS, lock distribué), le P99 est à 45ms grâce au préchauffage des connexions DB au boot du worker et au cache APCu local pour les métadonnées Doctrine et les configurations tenant. Côté sécurité, l'isolation entre requêtes est rigoureusement testée par une suite de tests "memory contamination" qui injecte une donnée sensible dans la requête N, force un reset, et vérifie qu'à la requête N+1 la donnée n'est plus accessible (ni en mémoire process, ni dans aucun service singleton). HTTP/3 sur Caddy réduit aussi la handshake latency pour les clients mobiles, critique sur des cas d'usage "paiement contactless validé via app fintech".
🛠️ Exemple end-to-end
Service de gestion d'état "tenant courant" prêt pour worker mode, avec ResetInterface, listener kernel.request pour hydrater, et tests de contamination.
<?php
// src/Tenant/TenantContext.php
declare(strict_types=1);
namespace App\Tenant;
use App\Entity\Tenant;
use Symfony\Contracts\Service\ResetInterface;
final class TenantContext implements ResetInterface
{
private ?Tenant $currentTenant = null;
private array $featureFlags = [];
private array $cachedConfigs = [];
public function setCurrent(Tenant $tenant): void
{
$this->currentTenant = $tenant;
}
public function getCurrent(): Tenant
{
return $this->currentTenant
?? throw new \LogicException('No tenant set for current request');
}
public function hasCurrent(): bool
{
return $this->currentTenant !== null;
}
public function getFeatureFlag(string $name, bool $default = false): bool
{
return $this->featureFlags[$name] ?? $default;
}
public function setFeatureFlags(array $flags): void
{
$this->featureFlags = $flags;
}
public function getConfig(string $key): mixed
{
return $this->cachedConfigs[$key] ?? null;
}
public function reset(): void
{
$this->currentTenant = null;
$this->featureFlags = [];
$this->cachedConfigs = [];
}
}<?php
// src/Tenant/TenantResolverListener.php
declare(strict_types=1);
namespace App\Tenant;
use App\Repository\TenantRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
#[AsEventListener(event: RequestEvent::class, priority: 50)]
final readonly class TenantResolverListener
{
public function __construct(
private TenantContext $context,
private TenantRepository $tenants,
private LoggerInterface $logger,
) {}
public function __invoke(RequestEvent $event): void
{
if ($event->getRequestType() !== HttpKernelInterface::MAIN_REQUEST) {
return;
}
$request = $event->getRequest();
// Bypass pour endpoints publics
if (str_starts_with($request->getPathInfo(), '/_internal/')) {
return;
}
$host = $request->getHost();
$tenant = $this->tenants->findBySubdomain($host)
?? throw new BadRequestException("Unknown tenant for host {$host}");
$this->context->setCurrent($tenant);
$this->context->setFeatureFlags($tenant->getFeatureFlags());
$this->logger->debug('Tenant resolved', [
'tenant_id' => $tenant->getId(),
'host' => $host,
]);
}
}<?php
// src/EventListener/WorkerBootListener.php
declare(strict_types=1);
namespace App\EventListener;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
// Pas d'événement "kernel.boot" en Symfony : on précharge au PREMIER
// kernel.request du worker, via un drapeau exécuté une seule fois.
#[AsEventListener(event: KernelEvents::REQUEST, priority: 4096)]
final class WorkerBootListener
{
private bool $warmedUp = false;
public function __construct(private readonly ManagerRegistry $doctrine) {}
public function __invoke(RequestEvent $event): void
{
if ($this->warmedUp) {
return;
}
$this->warmedUp = true;
// Préchauffe la connexion DB au démarrage du worker (best-effort)
try {
$this->doctrine->getConnection()->getNativeConnection();
} catch (\Throwable) {
// OK : la DB sera dispo plus tard
}
}
}# config/services.yaml
services:
_instanceof:
Symfony\Contracts\Service\ResetInterface:
tags: ['kernel.reset']
App\Tenant\TenantContext:
public: true # accès via container dans certains middlewares# Dockerfile production
FROM dunglas/frankenphp:1.4-php8.3-alpine
RUN install-php-extensions \
intl opcache pdo_pgsql redis zip apcu
ENV APP_ENV=prod
ENV FRANKENPHP_CONFIG="worker ./public/index.php"
ENV MAX_REQUESTS=1000
ENV PHP_INI_DIR=/usr/local/etc/php
# OPcache tuné pour worker mode
RUN echo "opcache.enable=1\n\
opcache.memory_consumption=256\n\
opcache.max_accelerated_files=20000\n\
opcache.validate_timestamps=0\n\
opcache.preload=/app/var/cache/prod/preload.php\n\
opcache.preload_user=www-data\n\
realpath_cache_size=4096K\n\
realpath_cache_ttl=600" > "${PHP_INI_DIR}/conf.d/zz-perf.ini"
WORKDIR /app
COPY composer.* symfony.lock ./
RUN composer install --no-dev --no-scripts --no-interaction --prefer-dist --no-progress
COPY . .
RUN composer dump-autoload --optimize --no-dev --classmap-authoritative \
&& php bin/console cache:warmup --env=prod \
&& chown -R www-data:www-data var/
EXPOSE 80 443 443/udp
ENTRYPOINT ["docker-php-entrypoint"]
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]# /etc/caddy/Caddyfile
{
frankenphp {
worker {
name app_worker
file /app/public/index.php
num 4
env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
max_consecutive_failures 10
}
}
}
:80 {
root * /app/public
encode zstd gzip
file_server
# le recyclage par nb de requêtes vient de l'ENV MAX_REQUESTS (cf. Dockerfile)
php_server
}<?php
// tests/Worker/TenantIsolationTest.php
declare(strict_types=1);
namespace App\Tests\Worker;
use App\Entity\Tenant;
use App\Tenant\TenantContext;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Contracts\Service\ResetInterface;
final class TenantIsolationTest extends KernelTestCase
{
public function testTenantStateDoesNotLeakBetweenRequests(): void
{
self::bootKernel();
$container = self::getContainer();
$context = $container->get(TenantContext::class);
$tenant = new Tenant('acme-corp');
// Simule la requête N
$context->setCurrent($tenant);
$context->setFeatureFlags(['flag_beta' => true]);
self::assertTrue($context->hasCurrent());
self::assertTrue($context->getFeatureFlag('flag_beta'));
// Simule le reset entre requêtes (ce que ferait FrankenPHP en worker mode)
$container->get('services_resetter')->reset();
// Requête N+1 : aucune fuite
self::assertFalse($context->hasCurrent());
self::assertFalse($context->getFeatureFlag('flag_beta'));
}
}L'exemple démontre les pratiques essentielles pour passer en worker mode : (1) tout service à état implémente ResetInterface et est auto-taggé via _instanceof, (2) un listener kernel.request (déclenché au premier passage, via un drapeau one-shot) préchauffe les ressources lentes (DB), (3) la config FrankenPHP active max_requests pour recycler proactivement, (4) un test dédié vérifie qu'aucune donnée tenant ne fuite entre requêtes — ligne rouge absolue en multi-tenant.
🔁 Quand utiliser / éviter
Passer en worker mode quand
- Votre app sert plus de 100 req/s ou subit des pics importants.
- Vous payez en infra des serveurs sous-utilisés à cause du boot Symfony répété.
- Vous avez des endpoints rapides où le boot coûte plus cher que le traitement (API gateways, micro-endpoints).
- Vous voulez du temps réel : Mercure, SSE, WebSocket sans serveur séparé.
- Vous distribuez une app embarquée (
frankenphp build). - Vous êtes en kubernetes / serverless et voulez réduire le nombre de pods pour la même charge.
Rester en FPM quand
- Votre app fait moins de 50 req/s et la latence actuelle est acceptable.
- Votre équipe n'est pas familière avec les pièges du worker mode (memory leaks, état global).
- Vous avez beaucoup de code legacy avec des
staticmutables, des sessions PHP natives, duexit()partout. - L'app utilise des extensions PHP non-réentrantes (rare, mais possible).
- Vous voulez la simplicité opérationnelle absolue :
cache:clearsuffit, pas besoin de redémarrer des workers. - Vous êtes sur un hébergement mutualisé sans contrôle de l'environnement.
Choisir le bon driver worker
| Critère | FrankenPHP | Swoole | RoadRunner | ReactPHP |
|---|---|---|---|---|
| Intégration Caddy / HTTPS auto | Oui native | Manuelle | Manuelle | Manuelle |
| HTTP/3 | Natif | Plugin | Non | Non |
| Mercure / SSE | Natif | Manuel | Manuel | Manuel |
| Coroutines async | Non (PHP fibers) | Oui natif | Non | Oui (event loop) |
| Image Docker officielle | Oui | Communautaire | Communautaire | Communautaire |
| Embedded binary | Oui | Non | Non | Non |
| Maturité Symfony 7.x | Excellente | Bonne | Bonne | Limitée |
| Communauté FR 2026 | Très active | Active | Active | Niche |
🧭 Comment un staff engineer raisonne sur le worker mode
Le worker mode n'est pas « PHP en plus rapide ». C'est un changement de modèle de cycle de vie de la mémoire, et un staff engineer l'aborde par les invariants qu'il casse, pas par les benchmarks qu'il promet.
Le modèle mental fondamental — trois durées de vie. En FPM, il n'existe qu'une seule durée de vie : la requête. Tout naît et meurt avec elle. En worker mode, il y a trois échelles de temps, et chaque ligne de code appartient à l'une d'elles :
| Échelle | Vit pendant | Exemples légitimes | Piège si mal classé |
|---|---|---|---|
| Process | Toute la vie du worker | Pool de connexions, container compilé, métadonnées Doctrine, OPcache | Y mettre de l'état lié à un utilisateur → fuite cross-tenant |
| Requête | Une seule requête | Request, user authentifié, tenant courant, transaction DB | Le laisser survivre (oubli de reset()) → contamination |
| Application | Recompilé au build | Routes, config DI, traductions | Le recalculer à chaud → latence first-request |
90 % des bugs worker viennent d'une donnée de durée de vie "requête" stockée dans un objet de durée de vie "process". Le static $cache, le service singleton qui mémorise le user courant, le RequestStack maison. La discipline n'est pas « éviter l'état », c'est savoir à quelle échelle de temps appartient chaque octet et le faire respecter par ResetInterface.
La question que pose un staff engineer avant de migrer n'est pas « combien de req/s en plus ? » mais « mon code suppose-t-il implicitement le shared-nothing ? ». La réponse se trouve par un audit ciblé, pas par un benchmark :
# Chasse aux suppositions shared-nothing dans le code applicatif
grep -rn 'static \$' src/ # état statique mutable
grep -rn 'session_start\|\$_SESSION' src/ # sessions natives
grep -rn '\bexit\b\|\bdie(' src/ # tue le worker, pas la requête
grep -rn 'register_shutdown_function' src/ # appelé à la mort du worker
grep -rn '__destruct' src/ # timing non garanti en workerLe raisonnement coût/risque. Le worker mode déplace le risque de la latence (problème FPM : boot répété) vers la correction (problème worker : isolation mémoire). Un staff engineer sait que le second est plus coûteux à diagnostiquer en prod : une fuite cross-tenant ne lève pas d'exception, elle livre les données du client A au client B silencieusement. D'où la règle : on ne migre jamais sans une suite de tests de contamination (cf. TenantIsolationTest) et sans MAX_REQUESTS comme filet. La perf est un bonus mesurable ; l'isolation est un invariant non négociable.
Le piège de la migration big-bang. Migrer toute l'app d'un coup transforme un déploiement en pari. L'approche staff : déployer FrankenPHP en mode FPM-compatible d'abord (FrankenPHP sans worker, juste comme serveur), valider que tout passe, puis activer le worker mode endpoint par endpoint via un canary, en surveillant la RAM par worker (docker stats) et le P99. Le worker mode est réversible à coût quasi nul (composer.json + redéploiement) — exploitez cette réversibilité.
🏋️ Exercices
Progression : on implémente, on durcit pour la prod, puis on casse-puis-répare. Chaque exercice suppose une app Symfony 7.x avec FrankenPHP installé.
Exercice 1 — Détecter et corriger une fuite d'état (implémenter)
Objectif : transformer un service à static mutable en service ResetInterface-safe et prouver l'isolation par un test.
On vous donne ce service legacy, écrit pour FPM :
final class CurrencyRateCache
{
public static array $rates = [];
public function get(string $pair): ?float
{
return self::$rates[$pair] ?? null;
}
public function set(string $pair, float $rate): void
{
self::$rates[$pair] = $rate;
}
}Réécrivez-le pour le worker mode, distinguez le cache légitimement partageable (taux de change = donnée applicative, pas liée à l'utilisateur) de l'état par requête, et écrivez un test qui échoue avec la version static et passe avec la version corrigée.
Indice/Solution : un cache de taux de change est de durée de vie process (sauf besoin d'invalidation par requête) → il peut rester en mémoire SANS
reset(), mais doit être un service singleton (propriété d'instance), pas unstatic. Lestaticest dangereux car il survit même si le service est recréé et n'est pas resettable. Version correcte :private array $rates = []+ ne PAS implémenterResetInterfacesi l'invalidation se fait par TTL. Le test vérifie que deux instances distinctes du service (new) ne partagent pas le cache, contrairement austatic.
Exercice 2 — Préchauffage de connexions + warm pool (production-grade)
Objectif : ouvrir DB et Redis au boot du worker, mesurer le gain sur la première requête, gérer l'échec de connexion sans tuer le worker.
Écrivez un listener kernel.request (avec un drapeau « exécuté une seule fois » pour ne préchauffer qu'au premier passage dans le worker) qui force l'ouverture des connexions Doctrine et Redis. Ajoutez un health-check qui n'échoue PAS le boot si la DB est temporairement indisponible (le worker doit démarrer quand même et reconnecter à la première requête). Mesurez le P50 de la première requête après restart worker, avec et sans préchauffage.
Indice/Solution :
#[AsEventListener(event: KernelEvents::REQUEST, priority: 4096)]+ une propriétébool $warmedUppour ne s'exécuter qu'au premier passage (il n'existe pas d'événementkernel.boot), appeler$connection->getNativeConnection()(Doctrine) et unPINGRedis dans untry/catch \Throwable. Le piège : si vous laissez l'exception remonter, FrankenPHP comptabilise un échec (max_consecutive_failures) et finit par crasher le worker en boucle si la DB est down au déploiement. Le warm pool doit être best-effort. Bench :hey -n 1 -c 1juste aprèsPOST /frankenphp/workers/restart.
Exercice 3 — Recyclage et observabilité des workers (production-grade)
Objectif : instrumenter la consommation mémoire par worker et exporter une métrique Prometheus, configurer MAX_REQUESTS de façon raisonnée.
Ajoutez un listener kernel.terminate qui pousse memory_get_usage(true) et memory_get_peak_usage(true) vers un compteur Prometheus (via promphp/prometheus_client_php ou un simple gauge sur /metrics). Tracez aussi le nombre de requêtes servies par le worker courant. Déterminez empiriquement une valeur de MAX_REQUESTS : laissez tourner 1h sous charge, observez la pente de la RAM, et fixez MAX_REQUESTS pour que le worker se recycle AVANT d'atteindre 80 % de sa limite mémoire cgroup.
Indice/Solution : compteur statique de requêtes dans un service (légitime ici, c'est de la métrique process-scoped, pas de la donnée métier).
MAX_REQUESTSn'est pas un nombre magique : c'est(limite_RAM_cgroup × 0.8 − RAM_baseline) / leak_par_requête. Si le leak est nul,MAX_REQUESTShaut (10000+) suffit comme filet. Exporter viakernel.terminatecar c'est le dernier hook avant le retour au handler FrankenPHP.
Exercice 4 — Casser puis réparer : la fuite cross-tenant (break-then-fix)
Objectif : reproduire volontairement une contamination de données entre tenants, la détecter par un test, puis la corriger.
Introduisez un bug réaliste : un service AuditLogger qui bufferise les logs dans une propriété d'instance private array $buffer = [] pour les flusher en batch, MAIS qui n'implémente PAS ResetInterface. Sous worker mode, les logs du tenant A se mélangent à ceux du tenant B. Écrivez un test de contamination qui le prouve (requête N pour tenant A, reset, requête N+1 pour tenant B, vérifier qu'aucun log de A ne fuit), puis corrigez.
Indice/Solution : le test échoue parce que
services_resetter->reset()n'appelle pasreset()sur un service qui n'implémente pas l'interface. Deux corrections possibles : (a) implémenterResetInterfaceet vider$buffer(mais attention : si le flush se fait danskernel.terminate, le reset doit venir APRÈS) ; (b) flusher danskernel.terminateavec priorité, de sorte que le buffer soit toujours vide au reset. La bonne réponse production combine les deux : flush enterminate+reset()défensif. Leçon : un buffer batch + worker mode = piège classique, l'ordre des hooks (terminatepuisreset) est critique.
Exercice 5 — exit() qui tue le worker (break-then-fix)
Objectif : observer qu'un exit() dans un contrôleur tue le worker entier en worker mode, et le remplacer par le mécanisme Symfony correct.
Écrivez un contrôleur de téléchargement qui fait readfile($path); exit; (pattern legacy fréquent pour le streaming de fichiers). Lancez-le en worker mode sous charge concurrente et observez : le worker meurt, les requêtes concurrentes en vol sur ce worker sont perdues, FrankenPHP relance le worker (latence de boot pour les requêtes suivantes). Corrigez avec BinaryFileResponse / StreamedResponse.
Indice/Solution :
exit/dieen worker =os.Exitdu process worker. En vol = requêtes parallèles tuées (FrankenPHP sert plusieurs requêtes par worker via threads). Correction :return new BinaryFileResponse($path)(gère Range, Last-Modified, X-Sendfile) ouStreamedResponsepour du flux généré. Test de non-régression :grep -rn '\bexit\b\|\bdie(' src/Controller/doit revenir vide, + un test qui vérifie que 50 téléchargements concurrents n'augmentent pas le compteur de restart worker.
Exercice 6 — Bench rigoureux FPM vs worker, avec analyse (production-grade, synthèse)
Objectif : produire un benchmark défendable (pas un chiffre marketing) comparant FPM et FrankenPHP worker sur VOTRE app, en isolant les variables.
Construisez deux images de la même app (FPM et FrankenPHP worker, même code, même OPcache preload, même DB). Benchez avec oha ou k6 (pas ab, trop limité) sur trois profils d'endpoint : (1) endpoint trivial sans DB, (2) endpoint avec 1 requête DB, (3) endpoint lourd (5 requêtes DB + Twig). Mesurez req/s, P50/P99, RAM/process, et le coût par million de requêtes en €. Concluez sur QUELS endpoints justifient le worker mode.
Indice/Solution : le gain worker est maximal sur l'endpoint (1) — quasi 100 % du temps était du boot. Sur (3), le boot est dilué dans le travail DB/Twig → gain relatif plus faible (mais toujours présent). C'est LE résultat à montrer en revue d'archi : le worker mode optimise le boot, pas le métier. Garder la même config OPcache des deux côtés sinon vous comparez OPcache, pas le runtime. Coût/million de req = (€/h du pod × nb pods) / (req/s × 3600) — c'est l'argument qui convainc la direction, pas les ms.
🎤 En entretien
Q : Pourquoi un static $cache = [] qui « marche très bien » en FPM devient-il un bug critique en worker mode ? R : En FPM chaque requête a son propre process qui meurt à la fin → le static est réinitialisé gratuitement. En worker le process survit à des milliers de requêtes : le static accumule de la mémoire (fuite) et, s'il contient des données liées à un utilisateur/tenant, les expose aux requêtes suivantes (contamination cross-tenant, faille de sécurité silencieuse). La correction : service singleton resettable (ResetInterface) ou cache externe (Redis), jamais de static mutable.
Q : Quelle est la différence entre kernel.terminate, __destruct, et reset() en worker mode, et quand utiliser lequel ? R : kernel.terminate s'exécute après l'envoi de la réponse, à chaque requête → idéal pour le cleanup post-requête (flush de logs, envoi d'emails différés). reset() (via ResetInterface + tag kernel.reset) est appelé entre deux requêtes pour vider l'état des services singletons → c'est le mécanisme d'isolation. __destruct n'est appelé qu'à la mort du worker (timing non déterministe) → ne JAMAIS y mettre de logique métier en worker mode. Règle : cleanup par requête = terminate, isolation d'état = reset, libération de ressource process = __destruct (avec prudence).
Q : Le worker mode multiplie le débit par 5 à 10. Pourquoi ne pas migrer toutes les apps Symfony par défaut ? R : Parce que le gain est sur le boot, pas sur le métier : une app dont le boot représente 5 % de la latence ne gagnera quasi rien, alors qu'elle hérite de tout le risque d'isolation mémoire. Le worker mode déplace le risque de la latence (mesurable, bénin) vers la correction (silencieuse, potentiellement faille de sécurité). On migre quand : le boot domine la latence, le trafic est élevé, le code est audité shared-nothing, et une suite de tests de contamination existe. Sinon FPM reste le bon choix — la simplicité opérationnelle a une valeur.
Q : Comment garantir en prod qu'aucune donnée d'un utilisateur ne fuit vers un autre via un service singleton ? R : Trois lignes de défense. (1) Discipline de conception : tout service stockant de l'état par requête implémente ResetInterface et est auto-tagué via _instanceof: ResetInterface → kernel.reset. (2) Tests de contamination : un test qui injecte une donnée sensible en requête N, force services_resetter->reset(), et asserte qu'elle est inaccessible en requête N+1 — exécuté en CI, bloquant. (3) Défense en profondeur : MAX_REQUESTS pour recycler les workers et borner la fenêtre d'un éventuel leak résiduel, plus un audit grep des static/sessions natives en pre-merge. Aucune des trois seule ne suffit : c'est la combinaison qui rend l'invariant tenable.
🔗 Liens
- Symfony Docs — Runtime Component
- FrankenPHP — Site officiel
- FrankenPHP — Documentation Symfony integration
- Dunglas (Kévin Dunglas) — Blog FrankenPHP
- Swoole — Documentation
- OpenSwoole — Fork actif
- RoadRunner — Documentation
- Spiral — RoadRunner + Symfony
- ReactPHP — Site officiel
Récap final
Le Symfony Runtime component est le pivot qui rend transparente la migration entre FPM et un mode worker. En modifiant uniquement composer.json et le runtime driver, votre code Symfony continue de fonctionner — à condition de respecter les règles de propreté en mode worker : pas de static mutable, implémentation de ResetInterface sur les services à état, gestion de kernel.terminate au lieu de __destruct, pas de exit(). FrankenPHP s'est imposé en 2026 comme l'option de référence grâce à son intégration native avec Caddy (HTTPS automatique, HTTP/3, Mercure), son image Docker officielle maintenue conjointement par l'équipe Symfony et son support des binaires embarqués. Swoole reste imbattable pour les coroutines fines, RoadRunner pour les écosystèmes Go-PHP mixtes, et FPM garde sa pertinence pour les apps modestes ou les équipes prudentes. Le mode worker n'est pas une optimisation prématurée : c'est un changement de paradigme. Mesurez avant (apdex, P99, coût infra), instrumentez (memory snapshots, restart auto via max_requests), et migrez progressivement — d'abord sur un endpoint, puis sur un service, puis sur l'app entière.