Fais un dump d'un graphe d'entités sur une stack PHP 8.4 et le résultat a presque l'air ennuyeux. Là où tu obtenais Proxies\__CG__\App\Entity\Customer, des propriétés __initializer__ et __cloner__ et une note mentale pour ignorer tout ça, tu obtiens maintenant un lazy ghost object(App\Entity\Customer) dont la propriété name affiche uninitialized(string). Cette différence cosmétique est le plus gros gain pratique des objets lazy natifs, et je dirais même qu'elle pèse plus lourd que tout ce qui touche aux performances. Ce que tu débugges est enfin la classe que tu as écrite. Ce que ce n'est pas, c'est une solution aux patterns de requêtes qui rendaient le lazy loading pénible au départ.

Rendons hommage à l'ancienne mécanique avant de l'enterrer. Sans support du moteur, la ProxyFactory de Doctrine et les LazyGhostTrait et LazyProxyTrait de Symfony devaient inventer la paresse avec les seuls outils disponibles côté userland : générer une classe à l'exécution qui étend ou imite l'originale, surcharger les méthodes pour les transférer à une vraie instance, ou intercepter lectures et écritures via __get() et __set() et initialiser sur place. Puis passer le tout à eval() ou l'écrire sur disque et l'inclure, en s'appuyant sur OPcache pour ne pas payer la génération à chaque requête. Ça a tenu dix ans. C'était aussi une quantité astronomique de code posée entre toi et un var_dump.

Le remplaçant tient en une ligne, comparativement. ReflectionClass::newLazyGhost() prend une closure, te rend un objet de la vraie classe, et PHP lui-même surveille le premier accès à une propriété non initialisée. Ce que je trouve vraiment élégant, c'est la façon dont Symfony a branché son conteneur dessus : quand un service est marqué #[Lazy], la méthode de factory générée est appelée deux fois. Premier passage, $lazyLoad vaut encore true par défaut, donc getMailerServiceService::do() construit un ghost et le range dans $container->privates dans la même expression. Deuxième passage, l'initialiseur se déclenche et $lazyLoad est le ghost lui-même, donc la comparaison avec true échoue et le code appelle __construct() sur l'objet qui existe déjà. Même instance, même service partagé, aucun wrapper qui délègue les appels à un jumeau caché. Doctrine joue le même tour par l'autre bout : il pose l'identifiant directement via l'accesseur de propriété pour que l'écriture de id ne déclenche pas l'initialiseur qu'il vient d'attacher.

Passons à ce qui me dérange. Le déclencheur a bougé, et la plupart des gens n'ont pas intégré où il a bougé. Appeler $order->getCustomer() n'exécute rien, parce que tu lis juste une propriété d'Order qui contient déjà le ghost. Le SELECT sur customer part au moment où quelque chose lit un état encore absent, en l'occurrence getName() qui touche $name. Mets ça dans une boucle sur cinquante commandes dans un template Twig et tu retrouves exactement le même N+1 qu'avant, sauf que l'objet n'a plus rien de suspect. Il a le bon nom de classe, les bonnes méthodes, tout le bon.

Le contre-argument honnête, c'est que les anciens proxies étaient moches d'une manière utile. Une classe nommée Proxies\__CG__\App\Entity\Customer posée dans une stack trace, c'était un signal qu'un dev junior pouvait apprendre à lire en un après-midi. Le fichier généré était sur disque, tu pouvais l'ouvrir, poser un point d'arrêt dans __load() et observer précisément qui forçait l'initialisation et depuis où. Les ghosts natifs ne t'offrent rien de tout ça. Pas de fichier, pas de méthode où poser un breakpoint, et l'initialisation se produit dans le moteur sur une lecture de propriété d'apparence banale. Si ton équipe débuggait le lazy loading en grepant les dumps à la recherche de __CG__, tu as réellement perdu un outil.

J'estime malgré tout que le natif est le bon compromis. Le moteur gère correctement les propriétés typées et readonly au lieu de laisser le framework négocier avec elles ; plus d'eval, plus de répertoire de proxies à préchauffer au déploiement, plus de pénalité de cache froid sur la première requête après une mise en prod. L'identité des objets cesse d'être un cas particulier, ce qui élimine discrètement toute une famille de bugs où un proxy et sa vraie instance n'étaient pas d'accord sur leur propre identité. Et quand ça casse à trois heures du matin, lire un dump qui te dit exactement quelle classe tu tiens vaut plus qu'une habitude de débuggage que tu peux reconstruire. Il faut juste la reconstruire volontairement.

Concrètement, ça veut dire déplacer le signal de l'objet vers le log de requêtes. Compte les requêtes dans tes tests fonctionnels et fais des assertions sur le nombre, pas seulement sur le corps de la réponse ; une route qui passe de quatre requêtes à quarante devrait faire échouer la CI, pas attendre que quelqu'un remarque le panneau Doctrine dans le profiler. Marque les associations en eager quand elles sont utilisées sur tous les chemins de code de toute façon, et sors un fetch join dans le repository au lieu d'espérer que la paresse te sauve. Et sois un peu économe avec #[Lazy] sur les services : un ghost autour d'un constructeur qui affecte deux chaînes ne t'apporte rien et te coûte un niveau d'indirection. Ce qui m'amène au point dont j'aimerais vraiment débattre en commentaires : où places-tu cette limite ? Est-ce que tu marques tes services lazy par défaut en ne faisant marche arrière que quand tu mesures un problème, ou est-ce que tu réserves la paresse à la poignée de services dont les constructeurs ouvrent des connexions et lisent des fichiers ? J'ai changé d'avis deux fois cette année et je suis curieux de savoir ce que disent tes chiffres en production.