Le 15 mai 2025, FrankenPHP a officiellement rejoint l'organisation GitHub php/, aux côtés de PHP-src, PECL et des projets maintenus par la PHP Foundation. Ce n'est pas un détail administratif : c'est le signal que le runtime PHP construit sur Caddy est désormais considéré comme une infrastructure sérieuse pour la production. Conçu par Kévin Dunglas (créateur de API Platform), FrankenPHP apporte une fonctionnalité que la communauté PHP attendait depuis longtemps — le worker mode, qui conserve ton application Symfony bootstrappée entre les requêtes. Résultat mesuré en conditions réelles : 2x à 5x de débit en plus, sans toucher une ligne de code métier.
Le cold start PHP : un impôt payé à chaque requête
Le modèle d'exécution traditionnel de PHP est un modèle share-nothing : à chaque requête HTTP, le runtime charge les fichiers, les compile (OPcache aide, mais partiellement), instancie le container de dépendances, charge la configuration, connecte les listeners d'événements, puis traite la requête et meurt. Sur une application Symfony 7.3 bien chargée, ce cycle de bootstrapping représente facilement 40 à 80 ms, même avec OPcache chaud. Sur 200 requêtes par seconde, c'est un budget conséquent gaspillé à reconstruire ce qui n'a pas changé depuis le dernier déploiement. Voici ce qui se rejoue à chaque hit :
- Compilation et résolution du container d'injection de dépendances (plusieurs centaines de services pour une app courante)
- Chargement de la configuration YAML/PHP et fusion des extensions de bundle
- Instanciation des event listeners et subscribers
- Initialisation de Doctrine (mapping, metadata cache, connexion)
- Chargement du routing et compilation des routes
Worker mode : une seule initialisation, des milliers de requêtes
Le worker mode inverse ce paradigme. Un processus PHP tourne en boucle infinie ; le kernel Symfony est instancié une seule fois au démarrage du worker. Pour chaque requête entrante, FrankenPHP appelle handle() directement sur le kernel déjà en mémoire, puis remet le container dans un état propre avant la requête suivante. Le pseudo-code ci-dessous illustre ce que le Runtime fait sous le capot :
<?php
// Illustration simplifiée du mécanisme worker
// Le Runtime FrankenPHP Symfony gère cela pour toi automatiquement
require './vendor/autoload.php';
$kernel = new App\Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$kernel->boot();
// Boucle principale du worker : tourne jusqu'au redémarrage
while ($request = \FrankenPhp\getNextRequest()) {
// Le kernel est déjà chaud — zéro bootstrap ici
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);
// Le Runtime appelle reset() sur les services ResettableInterface
}Le gain vient exactement de ce saut : on passe du cycle boot → handle → die → recommencer au cycle handle → reset → handle → reset. Plus l'app est lourde à initialiser, plus le gain est grand. Une API sans rendu de templates mais avec un gros container Doctrine est le cas idéal — les benchmarks montrent typiquement un facteur 4x à 5x. Une app orientée rendu Twig reste autour de 2x à 3x, ce qui est déjà significatif sur un coût d'infrastructure.
Dockerfile multi-stage optimisé pour la production
L'image officielle dunglas/frankenphp est disponible sur Docker Hub avec des variantes Alpine et Debian, pour PHP 8.3 et 8.4. Utilise la variante Alpine + PHP 8.4 en production : image plus légère, surface d'attaque réduite. Le build multi-stage sépare proprement les assets front, les dépendances Composer, et l'image finale.
# ── Stage 1 : assets front ──────────────────────────────────
FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile
COPY assets/ ./assets/
COPY vite.config.js ./
RUN npm run build
# ── Stage 2 : dépendances Composer ──────────────────────────
FROM composer:2.8 AS vendor
WORKDIR /app
COPY composer.json composer.lock symfony.lock ./
RUN composer install \
--no-dev \
--prefer-dist \
--no-scripts \
--no-autoloader
COPY . .
RUN composer dump-autoload --classmap-authoritative --no-dev
# ── Stage 3 : image de production ───────────────────────────
FROM dunglas/frankenphp:1-php8.4-alpine AS production
# Extensions requises pour Symfony — adapte selon ton stack
RUN install-php-extensions \
intl \
opcache \
pdo_pgsql \
redis \
apcu \
pcntl \
zip
# Configuration PHP de production
COPY docker/php/php.ini $PHP_INI_DIR/conf.d/app.ini
COPY Caddyfile /etc/caddy/Caddyfile
WORKDIR /app
# Copie des artefacts des stages précédents
COPY --from=vendor /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
COPY . .
RUN chown -R www-data:www-data var/ \
&& mkdir -p var/cache var/log
ENV APP_ENV=prod \
APP_DEBUG=0 \
APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
EXPOSE 80 443 443/udp
USER www-data
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]Note importante : le cache Symfony est préchauffé au démarrage du container via un script d'entrée plutôt que pendant le build, pour éviter d'injecter APP_SECRET dans les layers Docker. Ajoute un entrypoint.sh qui exécute php bin/console cache:warmup --env=prod --no-debug avant de lancer frankenphp run.
Caddyfile : configurer et dimensionner les workers
Le Caddyfile remplace à la fois la configuration Nginx et PHP-FPM. Le bloc frankenphp { worker ... } déclare combien de workers PHP persistent en mémoire. La règle de départ : 2 fois le nombre de cœurs CPU disponibles. Trop de workers entraîne de la contention mémoire ; trop peu crée des files d'attente sous charge. La variable d'environnement WORKER_NUM permet d'ajuster ce paramètre par instance sans reconstruire l'image.
{
frankenphp {
worker {
file /app/public/index.php
# Ajuste WORKER_NUM via variable d'env selon l'instance
num {$WORKER_NUM:4}
}
}
}
:80 {
root * /app/public
# Headers de sécurité
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
# Assets statiques servis directement, sans passer par PHP
@static {
file
path *.css *.js *.ico *.jpg *.jpeg *.png *.webp *.svg *.woff2 *.ttf
}
handle @static {
header Cache-Control "public, max-age=31536000, immutable"
file_server
}
# Tout le reste vers FrankenPHP worker
php_server
encode zstd gzip
log {
output stdout
format json
}
}Intégration Symfony 7.3 sans réécriture du code métier
C'est la promesse centrale de l'intégration officielle : zéro modification du code applicatif. Le composant Symfony Runtime prend en charge l'ensemble du cycle de vie du worker. Il suffit d'un package supplémentaire et d'une variable d'environnement.
composer require runtime/frankenphp-symfonyBesoin d'un expert Symfony ?
Réserver un appel →Le fichier public/index.php adopte la syntaxe Runtime de Symfony. Si tu as déjà migré vers le composant Runtime (Symfony 6.x ou 7.x), il est probablement déjà dans ce format — aucun changement nécessaire :
<?php
// public/index.php — aucun changement par rapport à un projet Symfony standard
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context): Kernel {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};La variable APP_RUNTIME déclare au composant Runtime quelle implémentation utiliser. En production, elle est définie dans l'image Docker (voir Dockerfile ci-dessus). En développement local, tu peux la placer dans .env.local et utiliser frankenphp php-server pour simuler l'environnement — sans activer le worker mode, ce qui préserve le rechargement automatique du code. Le fichier php.ini de production vaut quelques lignes d'attention :
; docker/php/php.ini — tuning production pour worker mode
memory_limit = 256M
max_execution_time = 30
realpath_cache_size = 4096K
realpath_cache_ttl = 600
; OPcache — critique : validate_timestamps=0 car le code ne change pas en prod
opcache.enable = 1
opcache.memory_consumption = 256
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
opcache.save_comments = 1
opcache.fast_shutdown = 1
; APCu — pour le cache Symfony en mémoire partagée
apc.enabled = 1
apc.shm_size = 128MDimensionner et monitorer : variables d'environnement clés
FrankenPHP expose plusieurs leviers de configuration via des variables d'environnement, ce qui simplifie le dimensionnement par environnement sans toucher au Caddyfile ni reconstruire l'image. Le paramètre MAX_REQUESTS est particulièrement important : il force le redémarrage du worker après un nombre défini de requêtes traitées, ce qui protège contre les fuites mémoire lentes dans du code tiers sans nécessiter un audit exhaustif en amont du premier déploiement.
# .env.prod ou variables injectées par ton orchestrateur (Kubernetes, Nomad, Docker Swarm)
# Runtime Symfony : active le worker mode FrankenPHP
APP_RUNTIME="Runtime\FrankenPhpSymfony\Runtime"
# Nombre de workers PHP persistants
# Règle de départ : 2× nombre de vCPU
WORKER_NUM=8
# Redémarre le worker après N requêtes — protection contre les leaks mémoires
# Le Runtime FrankenPHP Symfony lit cette variable automatiquement
MAX_REQUESTS=1000
# OPcache via env (si tu utilises les FrankenPHP env overrides)
PHP_OPCACHE_VALIDATE_TIMESTAMPS=0
PHP_OPCACHE_MEMORY_CONSUMPTION=256Les pièges concrets qui attendent en production
Le worker mode change fondamentalement le contrat de durée de vie des objets PHP. Ce qui était implicitement détruit à chaque requête persiste maintenant. Cette liste couvre les problèmes rencontrés sur des projets réels, pas des cas théoriques.
- Variables statiques et propriétés de classe : `static $cache = [];` dans un service accumule des données entre chaque requête. En mode classique, le processus mourait et la variable était réinitialisée. Plus le cas ici.
- EntityManager Doctrine : DoctrineBundle implémente `ResettableInterface` depuis la version 2.8 et se réinitialise correctement entre les requêtes via le Runtime. Mais si tu gardes des références à des entités dans tes propres services, ces entités deviennent « detached » de façon silencieuse — et Doctrine lève une `EntityNotFoundException` aux requêtes suivantes.
- Sessions PHP natives : `session_start()`, `$_SESSION`, `session_destroy()` — tout ça est hors-jeu en worker mode. Utilise exclusivement le composant `symfony/http-foundation` Session. Le Runtime s'occupe du reset entre les requêtes.
- Extensions C stateful : certains clients AMQP ou drivers exotiques maintiennent un état interne en C qui ne se réinitialise pas via PHP. Teste ces extensions en conditions long-running avant de déployer.
- Fuites mémoire cumulatives : une fuite de 1 Ko par requête représente 1 Go après un million de hits. Le paramètre `MAX_REQUESTS` limite l'exposition, mais il ne remplace pas un audit des services personnalisés.
La solution pour les services applicatifs qui maintiennent un état interne est d'implémenter Symfony\Contracts\Service\ResetInterface. Le Runtime appelle automatiquement reset() sur tous les services qui l'implémentent entre chaque requête :
<?php
namespace App\Service;
use Symfony\Contracts\Service\ResetInterface;
final class RapportService implements ResetInterface
{
/** @var array<string, mixed> */
private array $resultatsCache = [];
private ?string $contexteUtilisateur = null;
private int $compteurAppels = 0;
public function reset(): void
{
// Appelé automatiquement par le Runtime FrankenPHP entre chaque requête
// Garantit un état propre pour la requête suivante
$this->resultatsCache = [];
$this->contexteUtilisateur = null;
$this->compteurAppels = 0;
}
public function calculer(string $cle): mixed
{
if (!isset($this->resultatsCache[$cle])) {
$this->resultatsCache[$cle] = $this->calculInterne($cle);
++$this->compteurAppels;
}
return $this->resultatsCache[$cle];
}
private function calculInterne(string $cle): mixed
{
// Logique métier...
return null;
}
}Pour identifier tous les services candidats au ResetInterface dans ta base de code, un simple grep suffit pour démarrer : grep -r 'private.*=\s*\[\]\|static.*=' src/. Chaque propriété initialisée à une valeur vide ou statique dans un service Symfony est un candidat à auditer.
Ce que ça donne en chiffres réels
Sur une API Symfony 7.3 servie depuis une instance 4 vCPU / 8 Go RAM, la comparaison PHP-FPM 8.3 vs FrankenPHP 1.x worker mode donne les ordres de grandeur suivants. Avec PHP-FPM classique (sans worker) et OPcache chaud, un endpoint CRUD standard plafonne autour de 350 req/s avec une latence médiane de 28 ms. Avec FrankenPHP en worker mode — 8 workers, MAX_REQUESTS=1000 — le même endpoint monte à 1 200 à 1 500 req/s pour une latence médiane de 7 ms, soit un facteur proche de 4x. Sur une app orientée rendu de templates Twig, le gain est plus modeste (2x à 2,5x) car le parsing et le rendu restent coûteux même sans bootstrap. La consommation mémoire par worker oscille entre 60 et 90 Mo selon la taille du container de services — c'est le curseur à calibrer : plus de workers donnent plus de débit jusqu'à la limite physique de RAM disponible.
FrankenPHP en worker mode n'est pas une optimisation parmi d'autres : c'est un changement de paradigme pour PHP. L'adhésion à l'organisation php/ en mai 2025 consolide sa légitimité pour la production. Si ton application Symfony est correctement découpée en services sans état ou avec des services qui implémentent ResetInterface, la migration se résume à deux ajouts — un package Composer et une variable d'environnement. Le gain de débit, lui, arrive immédiatement. C'est rarement le cas des optimisations les moins risquées.
Besoin d'un expert Symfony ?
20 ans d'expérience sur l'écosystème PHP/Symfony.