Dump mal einen Entity-Graphen auf einem PHP-8.4-Stack, und die Ausgabe wirkt fast langweilig. Wo früher Proxies\__CG__\App\Entity\Customer stand, dazu ein paar __initializer__- und __cloner__-Properties und die mentale Notiz, das alles zu ignorieren, steht jetzt ein lazy ghost object(App\Entity\Customer), dessen Property name schlicht uninitialized(string) meldet. Dieser kosmetische Unterschied ist der größte praktische Gewinn der nativen Lazy Objects, und ich würde behaupten, er wiegt schwerer als alles auf der Performance-Seite. Das Ding, das du debuggst, ist endlich die Klasse, die du geschrieben hast. Was es nicht ist: eine Lösung für die Query-Muster, die Lazy Loading überhaupt erst schmerzhaft gemacht haben.
Bevor wir die alte Maschinerie begraben, gebührt ihr Respekt. Ohne Unterstützung aus der Engine mussten Doctrines ProxyFactory und Symfonys LazyGhostTrait und LazyProxyTrait Lazyness aus dem einzigen Werkzeugkasten schnitzen, den Userland hatte: zur Laufzeit eine Klasse generieren, die das Original erweitert oder nachbaut, Methoden überschreiben und an eine echte Instanz weiterreichen, oder Lese- und Schreibzugriffe über __get() und __set() abfangen und an Ort und Stelle initialisieren. Das Ganze dann durch eval() jagen oder auf die Platte schreiben und includen, und auf OPcache hoffen, damit du die Generierung nicht bei jedem einzelnen Request bezahlst. Es hat ein Jahrzehnt lang funktioniert. Es war aber auch eine gewaltige Menge Code zwischen dir und einem var_dump.
Der Ersatz ist im Vergleich ein Einzeiler. ReflectionClass::newLazyGhost() nimmt eine Closure, gibt dir ein Objekt der echten Klasse zurück, und PHP selbst wacht über den ersten Zugriff auf eine nicht initialisierte Property. Richtig elegant finde ich, wie Symfony seinen Container darum herum verdrahtet hat: Ist ein Service mit #[Lazy] markiert, wird die generierte Factory-Methode zweimal aufgerufen. Beim ersten Durchgang ist $lazyLoad noch der Default true, also baut getMailerServiceService::do() ein Ghost und legt es im selben Ausdruck in $container->privates ab. Beim zweiten Durchgang feuert der Initializer, und $lazyLoad ist das Ghost selbst, der Vergleich gegen true schlägt fehl, und der Code ruft __construct() auf dem Objekt auf, das schon existiert. Gleiche Instanz, gleicher Shared Service, kein Wrapper, der Aufrufe an einen versteckten Zwilling delegiert. Doctrine spielt denselben Trick von der anderen Seite: Der Identifier wird direkt über den Property-Accessor gesetzt, damit das Schreiben von id nicht den Initializer auslöst, den man gerade erst drangehängt hat.
Jetzt der Teil, der mich stört. Der Auslöser ist umgezogen, und die wenigsten haben verinnerlicht, wohin. $order->getCustomer() führt gar nichts aus, weil du nur eine Property auf Order liest, die das Ghost bereits enthält. Das SELECT gegen customer feuert in dem Moment, in dem etwas Zustand liest, der noch nicht da ist, im Beispiel also getName(), das $name anfasst. Pack das in eine Schleife über fünfzig Bestellungen in einem Twig-Template, und du hast dasselbe N+1 wie immer, nur dass das Objekt nicht mehr verdächtig aussieht. Es hat den richtigen Klassennamen, die richtigen Methoden, einfach alles richtig.
Das ehrliche Gegenargument lautet: Die alten Proxies waren auf nützliche Weise hässlich. Eine Klasse namens Proxies\__CG__\App\Entity\Customer mitten im Stacktrace war ein Signal, das ein Junior an einem Nachmittag lesen lernen konnte. Die generierte Datei lag auf der Platte, du konntest sie öffnen, du konntest einen Breakpoint in __load() setzen und genau zusehen, wer die Initialisierung von wo aus erzwingt. Native Ghosts geben dir nichts davon. Keine Datei, keine Methode zum Anhalten, und die Initialisierung passiert in der Engine bei einem ganz gewöhnlich aussehenden Property-Zugriff. Wenn dein Team Lazy Loading debuggt hat, indem es Dumps nach __CG__ gegreppt hat, dann hast du tatsächlich ein Werkzeug verloren.
Ich halte nativ trotzdem für den richtigen Deal. Die Engine behandelt typisierte und readonly Properties korrekt, statt dass das Framework mit ihnen verhandeln muss; kein eval, kein Proxy-Verzeichnis, das beim Deploy warmgelaufen werden will, keine Cold-Cache-Strafe beim ersten Request nach einem Release. Objektidentität ist kein Sonderfall mehr, was still und leise eine ganze Bug-Familie entsorgt, in der ein Proxy und seine echte Instanz sich uneinig darüber waren, wer sie eigentlich sind. Und wenn um drei Uhr nachts etwas schiefgeht, ist ein Dump, der dir exakt sagt, welche Klasse du in der Hand hältst, mehr wert als eine Debugging-Gewohnheit, die du dir neu aufbauen kannst. Du musst sie eben bewusst neu aufbauen.
Konkret heißt das: Das Signal wandert vom Objekt ins Query-Log. Zähl Queries in deinen Functional Tests und assertiere auf die Zahl, nicht nur auf den Response Body; eine Route, die von vier auf vierzig Queries springt, soll die CI rot färben und nicht darauf warten, dass jemand zufällig ins Doctrine-Panel im Profiler schaut. Markier Assoziationen eager, wenn sie ohnehin auf jedem Codepfad gebraucht werden, und greif im Repository lieber zu einem Fetch Join, statt zu hoffen, dass Lazyness dich rettet. Und geh mit #[Lazy] auf Services etwas sparsamer um: Ein Ghost um einen Konstruktor, der zwei Strings zuweist, bringt dir nichts und kostet dich eine Indirektionsebene. Womit ich bei dem bin, worüber ich in den Kommentaren wirklich streiten will: Wo ziehst du diese Grenze? Markierst du Services standardmäßig lazy und nimmst sie erst raus, wenn du ein Problem misst, oder hebst du dir Lazyness für die Handvoll Services auf, deren Konstruktoren Verbindungen öffnen und Dateien lesen? Ich bin dieses Jahr schon zweimal umgeschwenkt und bin gespannt, was deine Produktionszahlen sagen.
Kommentare
Noch keine Kommentare — schreib den ersten.
Starte die Diskussion
Kein Konto, kein Passwort nötig — gib einfach deine E-Mail-Adresse ein, wir senden dir einen einmaligen Anmelde-Link. Beim ersten Mal bist du damit automatisch angemeldet.
Deine Bewertung wird nach der Anmeldung automatisch übernommen.
Schau in dein Postfach
Wir haben einen Anmelde-Link an … gesendet. Öffne ihn auf diesem Gerät — dieser Tab meldet dich automatisch an.
Nichts angekommen? Wirf einen Blick in den Spam-Ordner — und markiere die Mail dort als „Kein Spam“, dann landet sie künftig direkt im Postfach.