Je me souviens encore de la première fois où j'ai déroulé une entité Doctrine dans un débogueur pour atterrir dans une classe nommée quelque chose comme Proxies\__CG__\App\Entity\Invoice. Du code généré, déversé dans un répertoire de cache, qui héritait de mon entité et surchargeait chaque getter pour y glisser un appel d'hydratation. Ça marchait, et en même temps on sentait qu'on tordait le langage dans une forme qu'il ne voulait pas prendre. Avec PHP 8.4, ce bras de fer est terminé. La paresse est désormais une propriété du modèle objet lui-même, exposée via ReflectionClass::newLazyGhost() et ReflectionClass::newLazyProxy(), et ma position est simple : c'est l'un des changements de runtime les plus lourds de conséquences que PHP ait livrés depuis des années, et même si tu n'appelles jamais ces méthodes toi-même, tu devrais les comprendre assez bien pour savoir laquelle des deux formes ton framework a choisie, et pourquoi.
Voici la distinction qui compte vraiment, et elle n'a rien à voir avec la syntaxe. Un lazy ghost est un seul et même objet pour toute sa vie. Tu le crées, ses propriétés restent non initialisées, et au moment où quelque chose lit son état, PHP exécute ton initialiseur sur cette instance précise. L'identité ne change jamais. Un lazy proxy, ce sont deux objets dans le même trench-coat : le substitut que tu fais circuler, et l'instance réelle qu'une factory produit au premier usage. Les deux annoncent la même classe via get_class(), mais compare-les avec === ou passe-les à spl_object_id() et l'illusion se fissure. Cette asymétrie justifie à mes yeux une règle de maison : ghosts par défaut, proxies uniquement quand la construction vit réellement ailleurs, disons derrière une factory de connexions que tu ne contrôles pas.
Pourquoi tant de prudence avec les proxies ? Parce que les bugs d'identité sont les plus silencieux de tous. Imagine un SplObjectStorage utilisé comme cache d'entités traitées, ou un système d'événements qui déduplique ses listeners par identité d'objet. Quelqu'un stocke l'instance réelle renvoyée par la factory, quelqu'un d'autre stocke le proxy, et voilà le même objet logique présent deux fois dans ton ensemble. Rien ne lève d'exception. Tes tests passent, parce que les tests mélangent rarement le proxy et sa cible. Puis la production le fait, et tu passes un après-midi à fixer deux objets qui s'affichent à l'identique et refusent d'être égaux. Un ghost ne peut tout simplement pas produire ce bug, parce qu'il n'existe pas de second objet avec lequel se mélanger les pinceaux.
Le contre-argument mérite d'être entendu : la plupart des développeurs d'applications ne toucheront jamais cette API directement, alors pourquoi se soucier de la saveur choisie par Symfony ou Doctrine ? C'est vrai, c'est de la plomberie. L'article qui fait suite à celui qui a inspiré cette chronique couvre précisément comment Symfony a reconstruit son injection de dépendances et Doctrine son lazy loading d'ORM sur le modèle natif, et pour beaucoup d'équipes l'histoire s'arrêtera là : mettre à jour, effacer quelques classes proxy générées de son modèle mental, passer à autre chose. Mais j'ai cessé de croire à la catégorie « infrastructure que je n'ai pas besoin de comprendre ». Les anciennes sous-classes générées fuyaient en permanence, par les méthodes final, par la sérialisation, par la réflexion. Le nouveau modèle fuit aussi, mais à des endroits mieux définis, et ces endroits sont désormais des sémantiques de langage documentées plutôt que des anecdotes de framework. Quand ton entité paresseuse se comporte bizarrement sous clone, tu peux vérifier que le clonage déclenche d'abord l'initialisation et que __clone() s'exécute sur l'instance réelle d'un proxy, pas sur le proxy lui-même. C'est une règle du langage que tu apprends une fois, pas une bizarrerie de framework que tu redécouvres à chaque projet.
La conception au niveau du moteur fait aussi ses preuves dans les recoins que les implémentations userland rataient toujours. Si ton initialiseur lève une exception, PHP ramène l'objet à son état paresseux au lieu de te laisser avec un zombie à moitié hydraté. Les destructeurs des ghosts ne s'exécutent que si l'initialisation a réellement eu lieu, donc un objet paresseux jamais touché ne déclenchera pas de logique de nettoyage pour des ressources qu'il n'a jamais acquises. Et mon détail préféré : appeler une méthode ne réveille pas l'objet en soi. L'initialisation ne se déclenche que quand le moteur doit observer l'état. Une méthode qui ne lit jamais de propriété s'exécute sans problème sur un objet non initialisé. Essaie d'obtenir cette granularité avec une sous-classe générée qui surcharge chaque méthode publique avec sa petite danse initialise-puis-délègue.
Il y a une fonctionnalité que je signale à quiconque construit quelque chose en forme d'ORM, même un petit mapper maison : ReflectionProperty::setRawValueWithoutLazyInitialization(), et son motif cousin avec skipLazyInitialization() suivi d'un setValue() classique. Tu connais souvent l'ID d'une entité bien avant d'avoir besoin de ses données, via une clé étrangère ou un paramètre de route. Tu peux maintenant planter cet ID dans un objet paresseux de sorte que sa lecture ne coûte rien, tandis que toucher n'importe quelle autre propriété déclenche toujours le chargement complet. Ça exigeait autrefois une comptabilité minutieuse dans des proxies faits main. C'est désormais deux lignes de réflexion.
Quelques limites honnêtes avant de t'emballer. Les classes internes de PHP ne peuvent pas être rendues paresseuses, donc pas de DateTimeImmutable paresseux, même si les classes définies par l'utilisateur et stdClass sont dans le périmètre. Et la paresse n'est pas de la performance gratuite : un ghost qui se fait toujours initialiser dès la première ligne de chaque requête n'est qu'un objet normal avec du cérémonial en plus et une stack trace un peu plus moche. Le motif est rentable quand une vraie fraction des requêtes ne touche jamais la chose coûteuse, le service de mail sur des requêtes qui n'envoient jamais de mail, l'objet de session sur des endpoints qui ne le lisent jamais. Si ton profileur te dit que tout s'initialise de toute façon, supprime la paresse et construis en avance la conscience tranquille.
Voici donc où j'atterris : adopte le modèle mental dès maintenant, saisis newLazyGhost() quand tu as un vrai objet coûteux parfois inutilisé, réserve newLazyProxy() au cas où la factory possède la construction, et réjouis-toi qu'un genre entier de génération de code vienne de devenir inutile. Mais je ne suis qu'un développeur en activité avec son propre lot de cicatrices, et les miennes ont justement la forme d'un bug d'identité. Et les tiennes ? As-tu déjà remplacé un value holder fait main ou un proxy généré par l'API native dans du vrai code, et la scission d'identité des proxies t'a-t-elle réellement mordu un jour, ou est-ce que je me protège d'un bug que tu n'as jamais croisé en production ? Raconte-moi ça en commentaire, les récits de guerre sont particulièrement bienvenus.
Commentaires
Pas encore de commentaire — écris le premier.
Lance la discussion
Pas de compte ni de mot de passe — saisis simplement ton adresse e-mail et nous t’envoyons un lien de connexion à usage unique. Première visite ? Tout se met en place automatiquement.
Ton évaluation sera appliquée automatiquement après ta connexion.
Vérifie ta boîte mail
Nous avons envoyé un lien de connexion à …. Ouvre-le sur cet appareil — cet onglet te connectera automatiquement.
Rien reçu ? Vérifiez le dossier spam — et marquez le message « Non spam » pour qu'il arrive directement la prochaine fois.