Haz un dump de un grafo de entidades en un stack de PHP 8.4 y la salida resulta casi aburrida. Donde antes te aparecía Proxies\\__CG__\\App\\Entity\\Customer, unas propiedades __initializer__ y __cloner__ y la nota mental de ignorarlo todo, ahora obtienes un lazy ghost object(App\\Entity\\Customer) cuya propiedad name dice uninitialized(string). Esa diferencia cosmética es la mayor victoria práctica de los objetos lazy nativos, y me atrevo a decir que vale más que cualquier cosa del lado del rendimiento. Lo que estás depurando por fin es la clase que tú escribiste. Lo que no es, en cambio, es una solución a los patrones de consulta que hicieron dolorosa la carga diferida desde el principio.

La maquinaria antigua merece un reconocimiento antes de que la enterremos. Sin soporte del motor, la ProxyFactory de Doctrine y los LazyGhostTrait y LazyProxyTrait de Symfony tenían que inventarse la pereza con las únicas herramientas que había en userland: generar en tiempo de ejecución una clase que extendiera o imitara a la original, sobrescribir métodos para reenviarlos a una instancia real, o interceptar lecturas y escrituras con __get() y __set() e inicializar sobre la marcha. Después, un eval() o escribirla en disco e incluirla, y apoyarse en OPcache para no pagar la generación en cada petición. Funcionó durante una década. También era una cantidad enorme de código interpuesta entre tú y un var_dump.

El reemplazo es una sola línea en comparación. ReflectionClass::newLazyGhost() recibe un closure, te devuelve un objeto de la clase real y el propio PHP vigila el primer acceso a una propiedad sin inicializar. Lo que me parece genuinamente elegante es cómo Symfony cableó su contenedor alrededor de esto: cuando un servicio está marcado con #[Lazy], el método factory generado se llama dos veces. En la primera pasada, $lazyLoad sigue valiendo true por defecto, así que getMailerServiceService::do() construye el ghost y lo guarda en $container->privates en la misma expresión. En la segunda, se dispara el inicializador y $lazyLoad es el propio ghost, con lo que la comprobación contra true falla y el código llama a __construct() sobre el objeto que ya existe. Misma instancia, mismo servicio compartido, sin envoltorio delegando llamadas a un gemelo oculto. Doctrine hace el mismo truco desde el otro lado: asigna el identificador directamente a través del accesor de propiedad para que escribir id no dispare el inicializador que acaba de enganchar.

Ahora la parte que me incomoda. El disparador se movió de sitio, y la mayoría no ha interiorizado adónde. Llamar a $order->getCustomer() no ejecuta nada, porque solo estás leyendo una propiedad de Order que ya contiene el ghost. El SELECT contra customer salta en el momento en que algo lee un estado que todavía no está ahí, que en el ejemplo es getName() tocando $name. Mete eso en un bucle sobre cincuenta pedidos en una plantilla Twig y tienes el mismo N+1 de siempre, salvo que el objeto ya no parece sospechoso. Tiene el nombre de clase correcto, los métodos correctos, todo correcto.

El contraargumento honesto es que los proxies antiguos eran feos de una manera útil. Una clase llamada Proxies\\__CG__\\App\\Entity\\Customer en mitad de un stack trace era una señal que un junior podía aprender a leer en una tarde. El fichero generado estaba en disco, podías abrirlo, podías poner un breakpoint dentro de __load() y ver exactamente quién forzaba la inicialización y desde dónde. Los ghosts nativos no te dan nada de eso. No hay fichero, no hay método donde parar, y la inicialización ocurre dentro del motor en una lectura de propiedad de aspecto totalmente corriente. Si tu equipo depuraba la carga diferida haciendo grep de __CG__ en los dumps, has perdido una herramienta de verdad.

Aun así, sigo pensando que lo nativo es el trato correcto. El motor gestiona bien las propiedades tipadas y readonly en lugar de que el framework tenga que negociar con ellas; no hay eval, no hay directorio de proxies que calentar en el despliegue, no hay penalización de caché fría en la primera petición tras una release. La identidad de objeto deja de ser un caso especial, lo que elimina en silencio toda una familia de bugs en los que un proxy y su instancia real no se ponían de acuerdo sobre quiénes eran. Y cuando algo se rompe a las tres de la mañana, leer un dump que te dice exactamente qué clase tienes entre manos vale más que un hábito de depuración que puedes reconstruir. Solo tienes que reconstruirlo a propósito.

En concreto, eso significa mover la señal del objeto al log de consultas. Cuenta consultas en tus tests funcionales y afirma sobre el número, no solo sobre el cuerpo de la respuesta; una ruta que pasa de cuatro consultas a cuarenta debería romper la CI, no esperar a que alguien se fije en el panel de Doctrine del profiler. Marca las asociaciones como eager cuando se usan en todos los caminos de ejecución de todas formas, y tira de un fetch join en el repositorio en vez de confiar en que la pereza te salve. Y sé algo comedido con #[Lazy] en los servicios: un ghost alrededor de un constructor que asigna dos strings no te aporta nada y te cuesta un nivel de indirección. Lo que me lleva a lo que de verdad me gustaría discutir en los comentarios: ¿dónde trazas tú esa línea? ¿Marcas los servicios como lazy por defecto y solo renuncias cuando mides un problema, o reservas la pereza para el puñado de servicios cuyos constructores abren conexiones y leen ficheros? Yo he cambiado de opinión dos veces este año y tengo curiosidad por lo que digan tus números en producción.