Todavía recuerdo la primera vez que recorrí una entidad de Doctrine con el depurador y aterricé en una clase llamada algo así como Proxies\__CG__\App\Entity\Invoice. Código generado, volcado en un directorio de caché, heredando de mi entidad y sobrescribiendo cada getter para colar una llamada de hidratación. Funcionaba, sí, pero se sentía como forzar al lenguaje a adoptar una forma que no quería tener. Con PHP 8.4, ese combate se acabó. La pereza es ahora una propiedad del propio modelo de objetos, expuesta a través de ReflectionClass::newLazyGhost() y ReflectionClass::newLazyProxy(), y mi postura es simple: es uno de los cambios de runtime más importantes que PHP ha publicado en años, y aunque nunca llames a estos métodos tú mismo, deberías entenderlos lo bastante bien como para saber cuál de las dos formas eligió tu framework y por qué.
Aquí va la distinción que de verdad importa, y no tiene nada que ver con la sintaxis. Un lazy ghost es un único objeto durante toda su vida. Lo creas, sus propiedades se quedan ahí sin inicializar, y en el momento en que algo lee su estado, PHP ejecuta tu inicializador contra esa misma instancia. La identidad nunca cambia. Un lazy proxy son dos objetos compartiendo una gabardina: el marcador de posición que vas pasando por ahí y la instancia real que una factoría produce en el primer uso. Ambos reportan la misma clase con get_class(), pero compáralos con === o pásalos por spl_object_id() y la ilusión se resquebraja. Esa asimetría es la razón por la que defiendo una regla de la casa: ghosts por defecto, proxies solo cuando la construcción vive genuinamente en otro sitio, por ejemplo detrás de una factoría de conexiones que no controlas.
¿Por qué tanta cautela con los proxies? Porque los bugs de identidad son los más silenciosos de todos. Imagina un SplObjectStorage usado como caché de entidades procesadas, o un sistema de eventos que deduplica listeners por identidad de objeto. Alguien guarda la instancia real que devolvió la factoría, otra persona guarda el proxy, y ahora el mismo objeto lógico está dos veces en tu conjunto. Nada lanza excepciones. Tus tests pasan, porque los tests rara vez mezclan el proxy con su objetivo. Luego producción sí los mezcla, y te pasas una tarde mirando dos objetos que se vuelcan idénticos y se niegan a ser iguales. Un ghost sencillamente no puede producir este bug, porque no existe un segundo objeto con el que confundirse.
El contraargumento merece una audiencia justa: la mayoría de quienes desarrollan aplicaciones nunca tocará esta API directamente, así que ¿por qué debería importarles qué variante eligió Symfony o Doctrine? Es cierto que esto es fontanería. El artículo de seguimiento de la pieza que motivó esta columna cubre exactamente cómo Symfony reconstruyó su inyección de dependencias y Doctrine la carga perezosa de su ORM sobre el modelo nativo, y para muchos equipos esa será toda la historia: actualizar, borrar unas cuantas clases proxy generadas de su modelo mental y seguir adelante. Pero yo he dejado de creer en la categoría de 'infraestructura que no necesito entender'. Las viejas subclases generadas tenían fugas constantes, por métodos final, por serialización, por reflexión. El nuevo modelo también tiene fugas, solo que en sitios mejor definidos, y esos sitios ahora son semántica documentada del lenguaje y no trivia de framework. Cuando tu entidad perezosa se comporte raro bajo clone, puedes consultar que clonar dispara primero la inicialización y que __clone() se ejecuta sobre la instancia real de un proxy, no sobre el proxy en sí. Esa es una regla del lenguaje que aprendes una vez, no una rareza de framework que redescubres en cada proyecto.
El diseño a nivel de motor también se gana el pan en los rincones donde las implementaciones en userland siempre tropezaban. Si tu inicializador lanza una excepción, PHP devuelve el objeto a su estado perezoso en lugar de dejarte con un zombi hidratado a medias. Los destructores de los ghosts solo se ejecutan si la inicialización llegó a producirse, así que un objeto perezoso que nunca tocaste no disparará lógica de limpieza para recursos que nunca adquirió. Y mi detalle favorito: llamar a un método no despierta al objeto por sí solo. La inicialización se dispara únicamente cuando el motor tiene que observar estado. Un método que nunca lee una propiedad se ejecuta tan contento sobre un objeto sin inicializar. Intenta conseguir esa granularidad con una subclase generada que sobrescribe cada método público con su danza de inicializar y reenviar.
Hay una funcionalidad que quiero señalar a cualquiera que construya algo con forma de ORM, aunque sea un pequeño mapper interno: ReflectionProperty::setRawValueWithoutLazyInitialization(), y su patrón hermano con skipLazyInitialization() seguido de un setValue() normal. A menudo conoces el ID de una entidad mucho antes de necesitar sus datos, por una clave foránea o un parámetro de ruta. Ahora puedes plantar ese ID en un objeto perezoso de modo que leerlo no cueste nada, mientras que tocar cualquier otra propiedad sigue disparando la carga completa. Eso antes exigía una contabilidad cuidadosa dentro de proxies hechos a mano. Ahora son dos líneas de reflexión.
Unos cuantos límites honestos antes de que te emociones demasiado. Las clases internas de PHP no pueden hacerse perezosas, así que nada de DateTimeImmutable perezoso, aunque las clases definidas por el usuario y stdClass sí valen. Y la pereza no es rendimiento gratis: un ghost que siempre se inicializa en la primera línea de cada petición es solo un objeto normal con ceremonia extra y una traza de pila un poco peor. El patrón compensa cuando una fracción real de las peticiones nunca toca la cosa cara, el servicio de correo en peticiones que nunca envían correo, el objeto de sesión en endpoints que nunca lo leen. Si tu profiler dice que todo se inicializa de todas formas, borra la pereza y construye de forma ansiosa con la conciencia tranquila.
Así que aquí es donde aterrizo: adopta el modelo mental ya, echa mano de newLazyGhost() cuando tengas un objeto caro genuinamente usado solo a veces, reserva newLazyProxy() para el caso en que la construcción pertenece a una factoría, y agradece que todo un género de generación de código acaba de volverse innecesario. Pero yo soy un desarrollador en activo con un conjunto concreto de cicatrices, y las mías tienen forma de identidad. ¿Y las tuyas? ¿Ya has sustituido en código real algún contenedor de valores hecho a mano o algún proxy generado por la API nativa, y la división de identidad de los proxies te ha mordido alguna vez de verdad, o estoy protegiéndome de un bug que tú no has visto ni una sola vez en producción? Cuéntamelo abajo, las batallitas son especialmente bienvenidas.
Comentarios
Aún no hay comentarios — escribe el primero.
Inicia la conversación
Sin cuenta ni contraseña — introduce tu correo y te enviamos un enlace de acceso de un solo uso. ¿Primera vez? Todo se configura automáticamente.
Tu valoración se aplicará automáticamente al iniciar sesión.
Revisa tu bandeja de entrada
Hemos enviado un enlace de acceso a …. Ábrelo en este dispositivo — esta pestaña te conectará automáticamente.
¿No llega nada? Mira en la carpeta de spam — y marca el correo como «No es spam» para que la próxima vez llegue directo.