Il y a, dans src/Service, src/EventListener et src/EventSubscriber de ce blog, 192 fichiers qui déclarent une classe final readonly. Six ne le sont pas. Trois de ces six implémentent une interface dont aucun catalogue de design patterns n'a jamais parlé : ResetInterface.
Ce n'est pas une coquetterie de style. C'est la trace d'un présupposé que le Gang of Four n'avait aucune raison d'écrire en 1994, et que PHP a tenu sans y penser pendant vingt-cinq ans : à la fin de la requête, tout meurt.
FrankenPHP en worker mode a supprimé cette garantie. Ce billet regarde ce que ça change, pattern par pattern, sur du code qui tourne en production, avec un cas réel attrapé en mai 2026.
Le présupposé que personne n'écrit
Les patterns du Gang of Four ont été catalogués pour des langages où la durée de vie d'un objet est un problème du programmeur. En C++, on décide quand un objet naît et quand il meurt, et se tromper coûte cher.
PHP a résolu ce problème en refusant de le poser. Le modèle share-nothing fait naître un interpréteur, exécuter un script, tout jeter. Chaque requête repart d'un tas vide. On peut écrire le service le plus sale du monde, accumuler des tableaux, mémoïser n'importe quoi : la frontière de requête passe derrière et nettoie.
Ça a une conséquence amusante sur le premier pattern que tout le monde apprend. En PHP-FPM, un Singleton est un mensonge qui dure le temps d'une requête. On promet « une seule instance », on obtient une seule instance par requête, ce qui n'est pas du tout la même promesse. Personne ne s'en plaignait, parce que personne ne pouvait observer la différence.
Ce que le worker mode fait de ce mensonge
En worker mode, FrankenPHP démarre le kernel une fois, le garde en mémoire, et lui fait servir des milliers de requêtes. Le container n'est pas reconstruit entre deux visiteurs. Les services non plus.
Le mensonge devient vrai. « Une seule instance » veut enfin dire une seule instance, et la durée de vie de l'objet n'est plus celle de la requête mais celle du process, c'est-à-dire des heures ou des jours.
C'est exactement ce qu'on voulait pour la performance. C'est aussi un contrat qu'on n'a pas lu : toute propriété qu'un service mute pendant une requête est encore là pour la requête suivante, avec les données du visiteur précédent dedans.
Singleton : le seul pattern qu'on subit sans l'écrire
On n'écrit jamais private static ?self $instance = null dans une application Symfony. Le container le fait, et il le fait pour tous les services d'un coup.
En PHP-FPM, c'est une optimisation : on évite de réinstancier trois fois le même service dans la même requête. En worker mode, c'est un choix de durée de vie. Le même objet en mémoire répond à des requêtes qui n'ont rien à voir entre elles, parfois de gens différents.
Le pattern n'a pas changé. Le runtime qui l'exécute, si.
Factory : ce qui produit et oublie
Les factories s'en sortent bien, et pour une raison précise : elles fabriquent, elles rendent, elles n'ont pas besoin de se souvenir.
MeilisearchClientFactory en est un cas typique. Elle lit deux paramètres de configuration, décide si Meilisearch est activé, et rend un client ou null :
// src/Service/Search/MeilisearchClientFactory.php
final readonly class MeilisearchClientFactory
{
public function __construct(
#[Autowire(param: 'app.meilisearch.url')]
private string $host,
#[Autowire(param: 'app.meilisearch.master_key')]
private string $apiKey,
private LoggerInterface $logger,
) {}
public function createClient(): ?Client
{
if (!$this->isEnabled()) {
// log dummy-fallback, puis :
return null;
}
return new Client($this->normalizeUrl(trim($this->host)), trim($this->apiKey));
}
}Son docblock tranche la question au lieu de la laisser au lecteur : le client SDK est un wrapper HTTP sans état, donc sûr comme singleton final readonly en mode worker, pas de ResetInterface requis.
Le piège existe quand même, et il est facile à écrire sans y penser : la factory qui mémoïse. Un private ?Client $client = null ajouté « pour éviter de recréer l'objet » transforme une factory inoffensive en cache de durée de vie inconnue.
Ce n'est pas une hypothèse d'article. C'est arrivé sur ce repo, et pas sur une factory.
Le cache qui grossit
RoutingExtension est une extension Twig. Elle traduit un nom court de controller en FQCN, et comme la résolution coûte un parcours de toutes les routes du projet, elle mémoïse. Deux fois.
// src/Twig/Extension/RoutingExtension.php
/** @var array<int, string>|null */
private ?array $controllers = null;
/** @var array<string, array{controller: class-string, parameters: array<string, mixed>}> */
private array $resolvedControllers = [];En PHP-FPM, ces deux tableaux sont un cache de requête. Ils se remplissent pendant le rendu, ils accélèrent les appels suivants du même template, ils meurent avec le process. Ce fichier a été écrit le 16 février 2025, et il était correct ce jour-là.
En worker mode, une extension Twig est un service partagé comme un autre. Les deux tableaux ne se vident plus. Ils grossissent d'une requête à l'autre, pour toute la durée de vie du worker. Le reset() est arrivé le 21 mai 2026, quinze mois plus tard, et la chasse qui l'a débusqué mérite son propre billet.
Ce qui compte ici est plus étroit. Le fichier seul est irréprochable, et aucune relecture ligne à ligne ne l'aurait sauvé. Le bug n'est pas dans la classe, il est dans l'écart entre ce que la classe suppose et ce que le serveur fait.
Observer : celui qui avait vraiment besoin de se souvenir
Il existe aussi des services qui ne peuvent pas être sans état, parce que le problème qu'ils résolvent s'étale sur deux événements.
IndexNowPingSubscriber prévient les moteurs quand un contenu change d'état public. Il a besoin de deux informations que Doctrine ne donne jamais au même moment. Les changesets, qui disent ce qui a bougé, ne sont disponibles que dans onFlush.
Mais dispatcher un message à ce moment-là serait faux : en ORM 3.6, les événements post* sont émis à l'intérieur de la transaction, et on pingerait des URLs issues d'un flush qui va être rollbacké.
D'où un pattern en deux temps. On bufferise dans onFlush, on dispatche dans postFlush, qui n'est atteint qu'après un commit réussi :
// src/EventListener/Content/IndexNowPingSubscriber.php
#[AsDoctrineListener(event: Events::onFlush, priority: -10)]
#[AsDoctrineListener(event: Events::postFlush)]
final class IndexNowPingSubscriber implements ResetInterface
{
/** @var array<string, true> */
private array $pendingUrls = [];
public function postFlush(): void
{
if ($this->pendingUrls === []) {
return;
}
$urls = array_keys($this->pendingUrls);
$this->pendingUrls = [];
$this->messageBus->dispatch(new PingIndexNowMessage($urls));
}
public function reset(): void
{
$this->pendingUrls = [];
}
}Entre les deux événements, cet objet porte un état. Il ne peut donc pas être final readonly, et sa mutabilité est un choix assumé plutôt qu'un oubli.
Reste la question que le catalogue ne pose pas : que se passe-t-il si une exception saute entre onFlush et postFlush ? Le buffer n'est jamais vidé. En PHP-FPM, aucune importance, le process meurt et emporte le tableau. En worker mode, ces URLs attendent le visiteur suivant, qui déclenchera un flush sur tout autre chose et enverra aux moteurs des URLs qu'il n'a jamais touchées.
Decorator : ajouter un comportement sans ouvrir la classe
Le Decorator est le pattern que Symfony a le mieux absorbé, au point qu'il tient en un attribut.
AudienceValidatingBearerTokenValidator décore le validateur de tokens du serveur OAuth2. La bibliothèque league valide la signature, l'expiration et la révocation, mais jamais l'audience. On veut refuser un token émis par notre propre serveur d'autorisation pour un autre client, rejoué contre le serveur MCP.
// src/Security/Mcp/AudienceValidatingBearerTokenValidator.php
#[AsDecorator('league.oauth2_server.bearer_token_validator')]
final class AudienceValidatingBearerTokenValidator extends BearerTokenValidator
{
#[\Override]
public function validateAuthorization(ServerRequestInterface $request): ServerRequestInterface
{
$request = parent::validateAuthorization($request);
$audience = $request->getAttribute('oauth_client_id', '');
if (!\is_string($audience) || !hash_equals(rtrim($this->expectedAudience, '/'), rtrim($audience, '/'))) {
throw OAuthServerException::accessDenied('Invalid token audience');
}
return $request;
}
}Le comportement d'origine est appelé par parent::, la vérification s'ajoute autour, et aucune ligne de league n'est touchée. La décoration se fait ici par héritage plutôt que par composition, pour une raison très concrète : le Resource Server pousse la clé publique sur le validateur au runtime via setPublicKey(), et l'héritage la propage nativement.
C'est la vraie leçon du Decorator, et elle n'a rien à voir avec le worker mode : le pattern est simple, son intégration dans un framework existant ne l'est jamais tout à fait.
Facade : le pattern qui ne change pas
Toutes les histoires ne finissent pas mal, et c'est utile de le dire.
HtmlContentSanitizer est une facade au sens strict. Elle masque le sanitizer Symfony configuré sur le profil app.blog_content, et son seul rôle propre est de court-circuiter les entrées vides.
// src/Service/Security/HtmlContentSanitizer.php
final readonly class HtmlContentSanitizer
{
public function __construct(
private HtmlSanitizerInterface $sanitizer,
) {}
public function sanitizeHtml(string $html): string
{
if (trim($html) === '') {
return '';
}
return $this->sanitizer->sanitize($html);
}
}Elle reçoit une chaîne, elle rend une chaîne, elle ne retient rien. Le worker mode ne lui fait rien du tout. Une facade devient dangereuse le jour où elle se met à mémoriser ce qu'elle a déjà nettoyé, pas avant.
La règle que le code a fini par écrire tout seul
Le compte, sur ce repo, au moment où ce billet est écrit. 192 classes final readonly dans les trois dossiers de services et de listeners. Six classes qui ne le sont pas, une fois les classes d'exception mises de côté.
Sur ces six, trois portent un vrai état mutable, et toutes les trois implémentent ResetInterface. Les trois autres n'ont aucune propriété assignée en dehors de leur constructeur : elles sont sans état, simplement pas marquées comme telles.
Ce ratio raconte quelque chose que la convention final readonly ne dit pas explicitement. Un mot-clé du langage a été promu garde-fou d'exécution. readonly interdit la mutation à la compilation, donc un service readonly est worker-safe par construction, sans revue, sans discipline, sans confiance dans le prochain qui passera dans le fichier. Ce prochain, c'est moi, et je ne me fais pas confiance.
Et l'absence de readonly devient un signal. Elle ne veut pas dire « ce service est buggé », elle veut dire « ce service doit se justifier ». Trois l'ont fait avec un reset().
ResetInterface : le pattern que le catalogue n'a pas
Symfony repère les implémentations de ResetInterface, les tague en kernel.reset et appelle reset() entre deux requêtes. C'est un point d'entrée de cycle de vie qui n'a aucun sens dans un modèle share-nothing, puisqu'il y remplace un travail que la mort du process fait gratuitement.
Il n'est dans aucun catalogue, et c'est logique : personne n'écrit un pattern pour un problème que le runtime résout tout seul. Il n'y entre que le jour où le runtime arrête de le résoudre.
Reste que ces bugs sont pénibles à trouver à la main, pour une raison structurelle : une suite de tests repart d'un état neuf à chaque cas. Elle est donc précisément aveugle à la classe de bugs qui ne se manifeste qu'à la deuxième requête d'un même process. C'est pour ça que la détection est outillée plutôt que confiée à la relecture.
Le catalogue décrit des formes, le runtime décide lesquelles sont sûres
Le vrai sujet est ailleurs : ce qu'un catalogue de patterns peut dire, et ce dont il se tait.
Un pattern décrit une forme : qui appelle qui, qui connaît qui, où la complexité se range. Il est muet sur la durée de vie, parce qu'en 1994 la durée de vie était le métier du programmeur, et qu'en PHP elle était le métier de la requête. Aucune des deux réponses n'est encore vraie dans un process qui ne meurt plus.
Ce qu'on adopte en adoptant un pattern, ce n'est donc pas une forme portable. C'est une forme, plus le contexte d'exécution dans lequel quelqu'un l'a validée. Le mensonge de 1994 sur les singletons est devenu, trente ans plus tard, une spécification qu'il faut lire jusqu'au bout.
Reste la question à se poser sur son propre code. Quand la doc d'une bibliothèque affirme qu'un service est « sans état », sans état pendant combien de temps ? Et si votre application tourne en worker mode depuis six mois, combien de vos services sont au courant ?
Une coquille, une erreur dans ce billet ? Signale-la-moi.
Ce billet est publié sous licence Creative Commons BY-NC-SA 4.0 (attribution, pas d'usage commercial, partage dans les mêmes conditions).