Introduction
Après un premier article consacré à Doctrine avancé, j’ai voulu explorer un autre composant critique de Symfony : HttpKernel.
Je ne vais pas revenir une nouvelle fois sur le fait qu’en 2026, FrankenPHP représente probablement l’évolution la plus importante de l’écosystème PHP moderne. Les gains en performance, la simplification du déploiement et le mode worker changent profondément la manière d’exécuter nos applications Symfony en production.
Mais derrière ces gains se cache un changement beaucoup plus important : le cycle de vie des données n’est plus le même.
Pendant des années, les développeurs PHP ont construit leurs applications avec une hypothèse simple : chaque requête repart de zéro. Avec FrankenPHP en mode worker, cette hypothèse devient fausse. Le kernel reste vivant en mémoire, certains services persistent, et les problématiques de concurrence ou d’état partagé deviennent soudainement bien réelles.
C’est précisément là que des composants comme Workflow et Lock prennent une importance majeure.
Dans cet article, nous allons reprendre les entités Order utilisées dans le précédent article sur Doctrine avancé afin d’explorer :
Comment les composants Workflow et Lock permettent de sécuriser les transitions métier dans un environnement concurrent.
Comment HttpKernel se comporte sous FrankenPHP,
Pourquoi le cycle de vie des données devient critique,
Worker mode vs Worker : deux concepts totalement différents
Avant d’aller plus loin, il est important de clarifier une confusion extrêmement fréquente autour de FrankenPHP et du terme worker.
Dans le modèle PHP historique — typiquement avec Apache ou PHP-FPM — chaque requête HTTP démarre un cycle d’exécution totalement isolé :
- bootstrap du framework,
- chargement des services,
- exécution,
- destruction complète de l’état mémoire.
Le modèle est donc essentiellement stateless et fortement lié au cycle :
1 requête HTTP = 1 cycle d’exécution PHPC’est précisément cette hypothèse qui a façonné une grande partie de l’écosystème PHP moderne pendant plus de vingt ans.
Ce que change FrankenPHP
FrankenPHP repose sur Caddy et exploite le runtime Go pour conserver l’application Symfony chargée en mémoire grâce au worker mode.
Concrètement :
- le kernel Symfony reste vivant,
- le container de services n’est plus reconstruit à chaque requête,
- certains objets persistent en mémoire,
- et les temps de réponse deviennent extrêmement faibles.
L’impact est particulièrement visible :
- sur les applications fortement sollicitées,
- sur les infrastructures serverless,
- ou sur des architectures avec du scale-to-zero.
Dans ce contexte, le warmup de l’application devient presque instantané puisque le moteur HTTP et le runtime PHP sont directement intégrés au serveur applicatif.
On se rapproche alors davantage du comportement observé sur :
- Node.js,
- Bun,
- ou certaines architectures Java modernes,
tout en conservant l’écosystème PHP et Symfony.
Un worker métier n’est pas le worker mode
Un autre point important : un worker métier n’a rien à voir avec le worker mode de FrankenPHP.
Un worker métier désigne simplement un processus chargé d’exécuter une tâche spécifique en arrière-plan :
- envoi d’emails,
- génération de PDF,
- traitement d’images,
- synchronisation API,
- validation de commandes,
- etc.
Par exemple :
- Symfony Messenger,
- RabbitMQ consumers,
- Redis queue workers,
- ou des commandes CLI supervisées.
Ces workers peuvent fonctionner :
- avec PHP classique,
- avec PHP-FPM,
- avec FrankenPHP,
- ou sans serveur HTTP du tout.
La confusion vient du fait que les deux notions manipulent des processus persistants, mais leur rôle est totalement différent.
Une base simple et compréhensible
Maintenant que les différences entre worker mode et workers métier sont clarifiées, nous allons mettre à jour notre environnement Docker afin d’exécuter Symfony avec FrankenPHP en mode worker.
L’objectif ici n’est pas de construire une stack de production ultra avancée, mais au contraire de partir d’une configuration volontairement minimaliste afin de mieux observer le comportement réel de l’application.
Même en environnement dev, nous allons forcer le worker mode pour reproduire au plus tôt les problématiques liées :
- au cycle de vie mémoire,
- à la persistance des services,
- et aux états partagés entre requêtes.
C’est précisément ce type de comportement qui reste souvent invisible avec PHP-FPM classique.
Configuration du Caddyfile
Voici une configuration extrêmement simple :
# Global options.
# - admin: defaults to localhost (in-container only). Set CADDY_ADMIN=0.0.0.0:2019
# to make the admin API reachable from the host (see compose.override.yaml) so
# tools like Ember can monitor it.
{
admin {$CADDY_ADMIN:localhost:2019}
metrics
}
{$SERVER_NAME:localhost} {
root * /app/public
encode zstd br gzip
php_server {
worker {
file index.php
num {$FRANKENPHP_WORKER_NUM:2}
watch
}
}
}Quelques points sont particulièrement intéressants ici.
Le mode worker
La section suivante active le worker mode de FrankenPHP :
worker {
file index.php
num {$FRANKENPHP_WORKER_NUM:2}
watch
}Concrètement :
file index.phpindique le point d’entrée Symfony,numdéfinit le nombre de workers persistants,watchrecharge automatiquement les workers lors des modifications en développement.
Nous avons volontairement configuré plusieurs workers afin de commencer à exposer certains comportements concurrents.
Même en local, cela permet déjà d’observer :
- des états persistants,
- des services conservés en mémoire,
- ou certains effets de bord invisibles sous PHP-FPM classique.
Le Dockerfile minimal
Le Dockerfile reste lui aussi volontairement très léger :
FROM dunglas/frankenphp:1-php8.4
WORKDIR /app
RUN install-php-extensions \
intl \
opcache \
pdo_pgsql \
zip
COPY Caddyfile /etc/caddy/Caddyfile
COPY . .
EXPOSE 80 443 443/udpIci, FrankenPHP embarque directement :
- le serveur HTTP,
- le runtime PHP,
- et l’exécution des workers persistants.
Nous n’avons donc :
- ni Nginx,
- ni Apache,
- ni PHP-FPM.
Le runtime applicatif est directement intégré au serveur HTTP, ce qui réduit fortement :
- le temps de bootstrap,
- les couches réseau internes,
- et le coût global de traitement des requêtes.
Le problème caché derrière la performance
Maintenant que l’environnement est prêt et que l’application tourne correctement sous FrankenPHP, nous allons pouvoir observer ce qui change réellement au niveau du composant HttpKernel.
Car le vrai sujet n’est pas uniquement la performance.
Le véritable changement concerne :
- le cycle de vie des objets,
- la persistance mémoire,
- la gestion des requêtes,
- les sessions,
- les services partagés,
- et plus globalement l’état applicatif.
Avec PHP-FPM classique, une grande partie de ces problématiques reste invisible puisque chaque requête détruit intégralement le process PHP.
Avec FrankenPHP en worker mode, le kernel Symfony reste chargé en mémoire entre plusieurs requêtes.
Et c’est précisément là que les choses deviennent intéressantes.
Une petite démo pour observer HttpKernel en worker mode
Pour visualiser concrètement ce comportement, j’ai volontairement créé une petite démonstration autour de deux services :
WorkerRuntimeRequestTraceContext
L’objectif est de montrer :
- ce qui doit rester persistant,
- ce qui doit être réinitialisé,
- et comment Symfony continue malgré tout de conserver son cycle HttpKernel classique.
<?php
namespace App\Application\HttpKernel;
final class WorkerRuntime
{
private readonly \DateTimeImmutable $startedAt;
private readonly int $processId;
private int $handledRequests = 0;
private int $terminatedRequests = 0;
public function __construct()
{
$this->startedAt = new \DateTimeImmutable();
$this->processId = getmypid() ?: 0;
}
public function nextRequestSequence(): int
{
return ++$this->handledRequests;
}
public function markTerminated(): void
{
++$this->terminatedRequests;
}
/**
* @return array<string, mixed>
*/
public function snapshot(): array
{
return [
'process_id' => $this->processId,
'started_at' => $this->startedAt->format(\DateTimeInterface::ATOM),
'handled_requests' => $this->handledRequests,
'terminated_requests' => $this->terminatedRequests,
'worker_mode' => (bool) ($_SERVER['FRANKENPHP_WORKER'] ?? false),
];
}
}
<?php
namespace App\Application\HttpKernel;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Contracts\Service\ResetInterface;
final class RequestTraceContext implements ResetInterface
{
private ?RequestTrace $current = null;
public function __construct(
private readonly WorkerRuntime $runtime,
) {
}
public function begin(Request $request): RequestTrace
{
return $this->current = new RequestTrace(
$this->resolveRequestId($request),
$this->runtime->nextRequestSequence(),
$request->getMethod(),
$request->getPathInfo(),
(bool) ($_SERVER['FRANKENPHP_WORKER'] ?? false),
getmypid() ?: 0,
new \DateTimeImmutable(),
memory_get_usage(true),
);
}
public function current(): RequestTrace
{
if (!$this->current instanceof RequestTrace) {
throw new \LogicException('No request trace has been started for the current request.');
}
return $this->current;
}
public function reset(): void
{
$this->current = null;
}
private function resolveRequestId(Request $request): string
{
$requestId = trim($request->headers->get('X-Request-Id', ''));
if ($requestId !== '') {
return $requestId;
}
return bin2hex(random_bytes(8));
}
}Distinguer état persistant et état par requête
C’est probablement le point le plus important à comprendre lorsqu’on commence à utiliser FrankenPHP.
Le service WorkerRuntime est volontairement persistant.
Son rôle est simple :
- conserver des informations liées au process PHP,
- compter le nombre de requêtes traitées,
- exposer le PID courant,
- et démontrer que le worker reste vivant entre plusieurs appels HTTP.
À l’inverse, RequestTraceContext représente un état strictement lié à une requête HTTP.
Il implémente donc ResetInterface :
public function reset(): void
{
$this->current = null;
}Et cette différence change absolument tout.
En worker mode :
- un service partagé peut survivre entre deux requêtes,
- conserver des propriétés internes,
- garder des références mémoire,
- ou exposer involontairement des données d’un utilisateur précédent.
Autrement dit :
Tout état temporaire doit être explicitement réinitialisé ou correctement scopé.
C’est précisément pour cette raison que Symfony fournit ResetInterface.
La démo montre donc une idée fondamentale :
FrankenPHP ne remplace pas HttpKernel.
Il garde simplement le kernel Symfony chaud en mémoire.
Et cela impose une discipline beaucoup plus stricte autour de l’état applicatif.
Observer un process PHP réellement persistant
La route /demo/http-kernel expose volontairement plusieurs informations internes :
process_idhandled_requeststerminated_requests
Sous FrankenPHP worker mode, un simple rafraîchissement de page permet d’observer un comportement très différent de PHP-FPM classique.
Le PID reste identique :
{
"process_id": 42,
"handled_requests": 8
}
Puis :
{
"process_id": 42,
"handled_requests": 9
}
Le process PHP reste vivant et continue de traiter plusieurs requêtes successives.
C’est une différence majeure avec le modèle historique de PHP.
HttpKernel continue pourtant de fonctionner normalement
Et c’est ici que la démonstration devient particulièrement intéressante.
Même avec un kernel persistant, Symfony continue de respecter intégralement le cycle HttpKernel classique.
Le subscriber écoute toujours :
kernel.requestkernel.responsekernel.terminate
Autrement dit :
- chaque requête possède toujours son propre cycle,
- les événements Symfony continuent de s’exécuter normalement,
- les contrôleurs restent stateless,
- et HttpKernel continue d’orchestrer la requête exactement comme avant.
La différence ne vient donc pas du cycle HttpKernel lui-même.
Elle vient du fait que certains services vivent désormais beaucoup plus longtemps que la requête qu’ils manipulent.
Et c’est précisément là que commencent les vrais problèmes :
- fuite de contexte utilisateur,
- services mutable state,
- données de session conservées involontairement,
- caches mémoire non nettoyés,
- locks oubliés,
- ou références Doctrine persistantes.
Observé et monitorer FrankenPHP nativement
Pour rendre la démonstration encore plus concrète, j’ai branché Ember afin d’observer directement le comportement interne de FrankenPHP pendant l’exécution de l’application Symfony. Et c’est probablement l’un des points les plus intéressants lorsqu’on commence à travailler sérieusement avec HttpKernel en worker mode : on visualise enfin ce que PHP cache habituellement complètement avec PHP-FPM classique.
La capture montre plusieurs informations extrêmement importantes pour comprendre ce qui se passe réellement au niveau du runtime.
D’abord, on voit que le process possède un uptime continu. Cela signifie que le runtime PHP n’est pas détruit après chaque requête HTTP. Le worker reste vivant, chargé en mémoire, exactement comme un serveur applicatif persistant. C’est un changement majeur dans la manière dont Symfony s’exécute.
Ensuite, Ember expose les threads actifs ainsi que les workers disponibles. Même lorsque l’application ne traite quasiment aucune requête, le runtime reste chargé, prêt à répondre immédiatement sans devoir reconstruire entièrement :
- le container Symfony,
- les services,
- le kernel,
- l’autoload,
- ou l’initialisation Doctrine.
C’est précisément ce qui explique les excellentes performances de FrankenPHP.
Mais dans le contexte de HttpKernel, le point le plus important n’est pas la vitesse. Ce qui devient réellement intéressant, c’est le fait que le process PHP conserve son état mémoire entre plusieurs requêtes.
La mémoire RSS visible sur Ember reste relativement stable et ne retombe jamais complètement à zéro après une requête. Cela signifie que certains objets existent encore en mémoire même après la fin du cycle HTTP courant.
Et c’est exactement ici que la distinction entre :
- état applicatif persistant,
- et état lié à une requête,
devient critique.
Avec PHP-FPM classique, cette problématique était largement masquée par le modèle de destruction systématique du process après exécution. Le développeur Symfony pouvait presque considérer implicitement que :
- les services étaient “propres”,
- la mémoire était jetable,
- et que tout contexte utilisateur disparaissait naturellement.
Sous FrankenPHP worker mode, cette hypothèse devient fausse.
Le même process PHP peut désormais traiter :
- plusieurs dizaines,
- plusieurs centaines,
- voire plusieurs milliers de requêtes successives.
Et HttpKernel continue pourtant de fonctionner normalement :
kernel.request,kernel.controller,kernel.response,kernel.terminate
continuent d’être déclenchés à chaque cycle HTTP.
Autrement dit, Symfony conserve intégralement son pipeline de requête classique, mais celui-ci s’exécute désormais dans un environnement persistant.
C’est exactement ce que montre la démonstration avec WorkerRuntime et RequestTraceContext.
Le service WorkerRuntime survit volontairement entre plusieurs requêtes afin de démontrer que le process reste vivant. Le compteur handled_requests continue donc d’augmenter tant que le worker existe.
À l’inverse, RequestTraceContext représente un contexte strictement lié à une requête HTTP. Il implémente donc ResetInterfaceafin de garantir que les données temporaires soient correctement nettoyées entre deux exécutions du kernel.
Et c’est probablement la leçon la plus importante de toute cette démonstration :
FrankenPHP ne change pas le cycle HttpKernel.
Il change la durée de vie des objets qui gravitent autour du kernel.
Cette nuance est fondamentale.
Parce qu’à partir du moment où des objets survivent plus longtemps que la requête elle-même, les erreurs classiques deviennent beaucoup plus dangereuses :
- fuite de contexte utilisateur,
- services mutable state,
- références Doctrine persistantes,
- sessions conservées involontairement,
- caches internes jamais nettoyés,
- locks oubliés,
- ou états métiers incohérents.
Et c’est précisément ce qui va nous amener aux composants Workflow et Lock dans la suite de l’article.
Composants Lock et Workflow : indispensables ?
Oui et non.
Comme toujours en architecture logicielle, ajouter une abstraction supplémentaire complexifie un projet. Et cette complexité n’est pas toujours justifiée.
Pour un simple site vitrine avec un blog, on n’a pas forcément besoin d’un composant Workflow pour gérer trois statuts d’article, ni d’un worker dédié pour envoyer une newsletter occasionnelle.
En revanche, dès qu’on manipule un état métier critique, la question change complètement.
Une commande, un paiement, une livraison, une réservation ou une facture ne sont pas de simples données. Ce sont des objets métier avec un cycle de vie précis, des transitions autorisées, et parfois plusieurs traitements concurrents capables d’agir sur le même état.
C’est exactement là que les composants Workflow et Lock deviennent intéressants.
Workflow, comme son nom l’indique, permet de modéliser une machine à états. Dans notre cas, il est parfaitement adapté à une entité Order, car une commande ne doit pas pouvoir passer n’importe comment d’un état à un autre.
Par exemple, une commande peut suivre un cycle logique :
cart → pending_payment → paid → shipped → delivered
Et certaines transitions doivent rester impossibles. Une commande ne devrait pas passer directement de cart à shipped, ni être livrée avant d’avoir été payée.
Voici une configuration volontairement simple pour notre démonstration :
framework:
workflows:
order:
type: state_machine
audit_trail:
enabled: '%kernel.debug%'
marking_store:
type: method
property: status
supports:
- App\Entity\Order
initial_marking: cart
places:
- cart
- pending_payment
- paid
- shipped
- delivered
- cancelled
transitions:
checkout:
from: cart
to: pending_payment
pay:
from: pending_payment
to: paid
ship:
from: paid
to: shipped
deliver:
from: shipped
to: delivered
cancel:
from: [pending_payment, paid]
to: cancelledici, le composant Workflow formalise les règles métier. Il ne se contente pas de stocker une chaîne de caractères dans une colonne status. Il décrit explicitement les états possibles et les chemins autorisés entre ces états.
Mais Workflow ne règle pas tout.
Il permet de répondre à une question :
Cette transition métier est-elle autorisée ?Il ne garantit pas, à lui seul, qu’une seule requête ou qu’un seul worker est en train de modifier la commande au même moment.
Et c’est là que le composant Lock entre en jeu.
En worker mode, ou avec des workers métier type Messenger, plusieurs traitements peuvent essayer d’agir simultanément sur la même commande. Cela peut arriver avec un double clic sur un bouton de paiement, un webhook reçu deux fois, un retry automatique, un consumer Messenger relancé, ou une montée en charge brutale.
Dans ce cas, le problème n’est plus seulement :
Est-ce que la transition pending_payment → paid est autorisée ?Mais aussi :
Est-ce que je suis le seul processus à essayer de l’exécuter maintenant ?Le rôle du Lock est précisément de verrouiller temporairement une ressource métier afin d’éviter que deux traitements concurrents modifient le même état en parallèle.
Pour une commande, la clé de verrouillage peut être aussi simple que :
order_123_payL’idée est de garantir qu’un seul traitement peut valider le paiement d’une commande donnée à un instant précis, même si plusieurs requêtes arrivent en même temps.
Le couple devient alors très clair :
Workflow = cohérence métier
Lock = cohérence concurrenteWorkflow protège le cycle de vie fonctionnel de l’entité.Lock protège l’exécution technique de la transition.
Et sous FrankenPHP worker mode, cette distinction devient beaucoup plus visible, parce que l’application traite davantage de requêtes dans un environnement persistant, avec une pression concurrente plus réaliste qu’en développement PHP-FPM classique.
Lock et Workflow pour garantir une unicité de données
Maintenant que le workflow est en place, regardons ce qu’il se passe réellement lorsqu’une transition est exécutée sur une commande.
Voici volontairement un contrôleur extrêmement simple :
// POST et non GET : une transition mute l'état et persiste en base.
// Un GET doit rester sûr/idempotent — sinon un prefetch navigateur ou un
// crawler peut déclencher la transition tout seul.
#[Route('/orders/{id}/apply/{transition}', name: 'apply', methods: ['POST'])]
public function apply(
Request $request,
// On reçoit l'ID brut, PAS « Order $order ».
// Sinon l'EntityValueResolver charge la commande pendant kernel.controller,
// donc AVANT le verrou : on lirait l'état hors section critique.
int $id,
string $transition,
OrderRepository $orderRepository,
EntityManagerInterface $entityManager,
LockFactory $lockFactory,
): Response {
// 1. On verrouille D'ABORD, avant toute lecture de l'état.
// NB : ce verrou n'est « distribué » que si le store l'est (Redis, PDO…).
// Avec le store flock par défaut, il reste local au conteneur.
$lock = $lockFactory->createLock('order:'.$id, ttl: 10.0);
if (!$lock->acquire(false)) {
return $this->respond($request, 'demo/workflow/show.html.twig', [
'lock_acquired' => false,
'applied' => false,
'error' => 'Un autre worker traite déjà cette commande.',
], Response::HTTP_CONFLICT);
}
try {
// 2. On lit la commande MAINTENANT, à l'intérieur du verrou.
// C'est le cœur du correctif : l'état est forcément frais.
// Avec l'ancienne version (lecture hors verrou), un double-clic décalé
// pouvait rejouer « pay » sur un $order périmé encore vu comme pending,
// car la lecture avait eu lieu avant le flush du worker précédent.
$order = $orderRepository->find($id);
if (!$order) {
throw $this->createNotFoundException(sprintf('Commande %d introuvable.', $id));
}
// 3. Le Workflow valide la cohérence métier sur un état à jour…
$this->orderStateMachine->apply($order, $transition);
// 4. …et Doctrine persiste à l'intérieur de la même section critique.
$entityManager->flush();
return $this->respond($request, 'demo/workflow/show.html.twig', $this->describe($order) + [
'lock_acquired' => true,
'applied' => true,
'last_transition' => $transition,
]);
} catch (UndefinedTransitionException) {
return $this->respond($request, 'demo/workflow/show.html.twig', [
'lock_acquired' => true,
'applied' => false,
'error' => sprintf('Transition "%s" inconnue pour le workflow order.', $transition),
], Response::HTTP_BAD_REQUEST);
} catch (NotEnabledTransitionException $e) {
$blockers = array_map(
static fn ($blocker): string => $blocker->getMessage(),
iterator_to_array($e->getTransitionBlockerList()),
);
return $this->respond($request, 'demo/workflow/show.html.twig', [
'lock_acquired' => true,
'applied' => false,
'error' => sprintf('Transition "%s" non autorisée depuis l\'état courant.', $transition),
'blockers' => array_values($blockers),
], Response::HTTP_CONFLICT);
} finally {
// 5. Toujours libérer, même en cas d'exception.
$lock->release();
}
}Le point le plus important ici n’est pas le contrôleur lui-même. Ce qui est intéressant, c’est la manière dont on protège une transition métier dans un environnement où plusieurs workers peuvent agir simultanément sur la même donnée.
La première étape consiste à créer un verrou distribué :
$lock = $lockFactory->createLock('order:'.$order->getId(), ttl: 10.0);La clé order:{id} représente la ressource métier que l’on souhaite protéger. Ici, on ne verrouille pas toute l’application, uniquement la commande concernée.
Et cette nuance est fondamentale.
Sous FrankenPHP worker mode, plusieurs workers peuvent parfaitement recevoir des requêtes concurrentes au même instant. Sans verrou, deux transitions pourraient alors modifier la même commande simultanément.
Par exemple :
- double clic utilisateur,
- webhook Stripe reçu deux fois,
- retry Messenger,
- ou simple montée en charge.
Le problème ne vient pas du workflow lui-même.
Le problème vient du fait que plusieurs processus peuvent essayer de jouer la même transition au même moment.
C’est précisément ce que cette ligne empêche :
if (!$lock->acquire(false)) {Le false indique que l’on ne souhaite pas attendre le verrou. Si un autre worker traite déjà cette commande, on échoue immédiatement avec une réponse 409 Conflict.
Autrement dit :
Une seule transition peut modifier cette commande à un instant donné.Une fois le verrou acquis, le composant Workflow prend le relais :
$this->orderStateMachine->apply($order, $transition);Ici, Symfony ne vérifie pas la concurrence. Il vérifie uniquement la cohérence métier.
Le workflow répond à une question très précise :
Cette transition est-elle autorisée depuis l’état actuel ?Par exemple :
pending_payment -> paidest valide,- mais
cart -> shippeddoit échouer.
Si la transition n’existe pas, UndefinedTransitionException est levée.
Si la transition existe mais n’est pas autorisée depuis l’état courant, Symfony déclenche NotEnabledTransitionException.
C’est ici qu’on voit toute la force du composant Workflow : l’état métier devient explicite et contrôlé.
Enfin, une fois la transition validée, Doctrine persiste le nouvel état :
$entityManager->flush();Et surtout, le verrou est systématiquement libéré :
finally {<br> $lock->release();<br>}Ce finally est probablement l’un des blocs les plus importants de tout le contrôleur.
Sans lui, une exception pourrait laisser un verrou actif et bloquer définitivement les transitions suivantes.
Au final, ce contrôleur illustre parfaitement la séparation des responsabilités :
Lock → protège l’exécution concurrente
Workflow → protège la cohérence métier
Doctrine → persiste l’état final
HttpKernel → orchestre le cycle de requête
Et c’est précisément cette combinaison qui devient particulièrement importante sous FrankenPHP worker mode, où plusieurs workers persistants peuvent désormais manipuler les mêmes ressources en parallèle.

Conclusion
Comme pour mon précédent article sur Doctrine avancé, l’objectif ici n’était pas simplement de présenter des composants Symfony, mais de revenir sur des bases techniques que tout développeur Symfony finit par rencontrer dès qu’une application commence à monter sérieusement en charge.
FrankenPHP apporte énormément :
- performances,
- simplicité de déploiement,
- réduction du bootstrap,
- worker mode,
- et un runtime beaucoup plus moderne que l’approche historique PHP-FPM.
Mais cette puissance vient aussi avec de nouvelles responsabilités techniques.
Lorsqu’un kernel Symfony reste vivant en mémoire, certaines hypothèses historiques du développement PHP deviennent fausses :
- les services ne sont plus systématiquement recréés,
- certains états persistent,
- plusieurs workers peuvent manipuler les mêmes ressources,
- et les problématiques de concurrence deviennent beaucoup plus visibles.
C’est précisément pour cette raison que je voulais revenir sur trois composants qui, à mon sens, deviennent centraux sous FrankenPHP worker mode :
- HttpKernel,
- Workflow,
- et Lock.
HttpKernel reste le coeur du cycle de requête Symfony.
Workflow permet de formaliser les transitions métier de manière explicite et fiable.
Lock protège l’application contre les problèmes de concurrence et les mutations simultanées d’un même état.
Et lorsqu’on combine les trois, on commence réellement à construire des applications Symfony capables de fonctionner proprement dans un environnement persistant et fortement concurrent.
Nous avons volontairement gardé une stack simple :
- un Dockerfile minimal,
- un Caddyfile configuré en worker mode,
- des métriques exposées,
- une démonstration autour du cycle HttpKernel,
- puis une orchestration métier complète autour d’une entité
Order.
L’objectif n’était pas de produire une architecture “enterprise”, mais de rendre visibles des problématiques que beaucoup de projets Symfony rencontrent sans forcément les comprendre immédiatement :
- états partagés,
- mémoire persistante,
- transitions concurrentes,
- locks oubliés,
- ou services mutable state.
Et impossible de terminer cet article sans reparler de Ember, qui apporte probablement l’un des meilleurs outils d’observabilité actuellement disponibles pour FrankenPHP. Pouvoir visualiser en temps réel :
- les workers,
- les threads,
- la mémoire,
- le runtime,
- et le comportement du serveur
change complètement la manière d’analyser une application Symfony moderne.
Je ne prétends évidemment pas tout savoir sur le sujet. Mais plus j’avance sur FrankenPHP, plus je suis convaincu d’une chose :
Le vrai changement n’est pas seulement la performance.
Le vrai changement, c’est que PHP commence enfin à vivre longtemps.
Et à partir du moment où le runtime reste vivant, comprendre :
=> HttpKernel,
=> le cycle de vie des services,
=> la concurrence,
=> les locks,
=> les workflows métier
devient absolument essentiel pour construire des applications Symfony robustes en production.