Ich erinnere mich noch genau an das erste Mal, als ich in einem Debugger durch eine Doctrine-Entity gestiegen bin und in einer Klasse namens Proxies\__CG__\App\Entity\Invoice gelandet bin. Generierter Code, abgelegt in einem Cache-Verzeichnis, der meine Entity ableitet und jeden Getter überschreibt, um heimlich einen Hydration-Aufruf einzuschleusen. Es hat funktioniert, aber es fühlte sich an, als würde man die Sprache in eine Form zwingen, die sie eigentlich nicht annehmen will. Mit PHP 8.4 ist dieser Ringkampf vorbei. Laziness ist jetzt eine Eigenschaft des Objektmodells selbst, zugänglich über ReflectionClass::newLazyGhost() und ReflectionClass::newLazyProxy(), und meine Position ist simpel: Das ist eine der folgenreichsten Runtime-Änderungen, die PHP seit Jahren ausgeliefert hat. Selbst wenn du diese Methoden nie selbst aufrufst, solltest du sie gut genug verstehen, um zu wissen, welche der beiden Varianten dein Framework gewählt hat und warum.
Hier ist der Unterschied, auf den es wirklich ankommt, und er hat nichts mit Syntax zu tun. Ein Lazy Ghost ist sein Leben lang ein einziges Objekt. Du erzeugst es, seine Properties liegen uninitialisiert herum, und in dem Moment, in dem irgendetwas seinen Zustand liest, führt PHP deinen Initializer gegen genau diese Instanz aus. Die Identität ändert sich nie. Ein Lazy Proxy dagegen ist zwei Objekte in einem Trenchcoat: der Platzhalter, den du herumreichst, und die echte Instanz, die eine Factory bei der ersten Benutzung produziert. Beide melden über get_class() dieselbe Klasse, aber vergleich sie mit === oder wirf sie in spl_object_id(), und die Illusion bekommt Risse. Diese Asymmetrie ist der Grund, warum ich für eine Hausregel plädiere: Ghosts als Standard, Proxies nur dann, wenn die Konstruktion wirklich woanders lebt, etwa hinter einer Connection-Factory, die du nicht kontrollierst.
Warum so vorsichtig bei Proxies? Weil Identitätsbugs die schlimmste Sorte von leise sind. Stell dir eine SplObjectStorage vor, die als Cache für verarbeitete Entities dient, oder ein Event-System, das Listener über Objektidentität dedupliziert. Jemand speichert die echte Instanz, die die Factory zurückgegeben hat, jemand anderes speichert den Proxy, und schon liegt dasselbe logische Objekt zweimal in deinem Set. Nichts wirft eine Exception. Deine Tests laufen grün, weil Tests selten den Proxy und sein Ziel mischen. Dann tut es die Produktion doch, und du verbringst einen Nachmittag damit, auf zwei Objekte zu starren, die identisch aussehen, wenn man sie dumpt, und sich trotzdem weigern, gleich zu sein. Ein Ghost kann diesen Bug schlicht nicht produzieren, weil es kein zweites Objekt gibt, mit dem man etwas verwechseln könnte.
Das Gegenargument verdient eine faire Anhörung: Die meisten Anwendungsentwickler werden diese API nie direkt anfassen, warum sollte es sie also kümmern, welche Variante Symfony oder Doctrine gewählt hat? Es stimmt, das ist Infrastruktur im Maschinenraum. Der Folgeartikel zu dem Stück, das diese Kolumne ausgelöst hat, behandelt genau, wie Symfony seine Dependency Injection und Doctrine sein ORM-Lazy-Loading auf dem nativen Modell neu aufgebaut haben, und für viele Teams wird das die ganze Geschichte sein: updaten, ein paar generierte Proxy-Klassen aus dem mentalen Modell streichen, weitermachen. Aber ich habe aufgehört, an die Kategorie der Infrastruktur zu glauben, die man nicht verstehen muss. Die alten generierten Subklassen haben ständig geleakt, durch finale Methoden, durch Serialisierung, durch Reflection. Das neue Modell leakt auch, nur an besser definierten Stellen, und diese Stellen sind jetzt dokumentierte Sprachsemantik statt Framework-Trivia. Wenn sich deine lazy Entity beim clone seltsam verhält, kannst du nachlesen, dass Klonen zuerst die Initialisierung auslöst und dass __clone() bei einem Proxy auf der echten Instanz läuft, nicht auf dem Proxy selbst. Das ist eine Sprachregel, die du einmal lernst, keine Framework-Eigenheit, die du pro Projekt neu entdeckst.
Das Design auf Engine-Ebene zahlt sich auch in den Ecken aus, in denen Userland-Implementierungen immer gestolpert sind. Wenn dein Initializer eine Exception wirft, setzt PHP das Objekt in seinen lazy Zustand zurück, statt dich mit einem halb hydrierten Zombie zurückzulassen. Destruktoren von Ghosts laufen nur, wenn die Initialisierung tatsächlich passiert ist, ein lazy Objekt, das du nie angefasst hast, feuert also keine Aufräumlogik für Ressourcen ab, die es nie besessen hat. Und mein Lieblingsdetail: Ein Methodenaufruf weckt das Objekt nicht automatisch auf. Die Initialisierung greift nur, wenn die Engine wirklich Zustand beobachten muss. Eine Methode, die nie eine Property liest, läuft fröhlich auf einem uninitialisierten Objekt. Versuch mal, diese Granularität aus einer generierten Subklasse zu bekommen, die jede öffentliche Methode mit einem Initialisieren-dann-Weiterleiten-Tanz überschreibt.
Ein Feature möchte ich jedem ans Herz legen, der irgendetwas ORM-Artiges baut, und sei es nur ein kleiner hausinterner Mapper: ReflectionProperty::setRawValueWithoutLazyInitialization() und das verwandte Muster mit skipLazyInitialization() gefolgt von einem normalen setValue(). Oft kennst du die ID einer Entity lange bevor du ihre Daten brauchst, aus einem Fremdschlüssel oder einem Routen-Parameter. Jetzt kannst du diese ID in ein lazy Objekt einpflanzen, sodass das Lesen der ID nichts kostet, während der Zugriff auf jede andere Property weiterhin den vollen Load auslöst. Früher brauchte das sorgfältige Buchhaltung in handgeschriebenen Proxies. Jetzt sind es zwei Zeilen Reflection.
Ein paar ehrliche Grenzen, bevor du dich hinreißen lässt. Interne PHP-Klassen lassen sich nicht lazy machen, also kein lazy DateTimeImmutable, wobei selbst definierte Klassen und stdClass problemlos gehen. Und Laziness ist keine Gratis-Performance: Ein Ghost, der in jeder Request sowieso in der ersten Zeile initialisiert wird, ist nur ein normales Objekt mit extra Zeremonie und einem etwas hässlicheren Stacktrace. Das Muster lohnt sich, wenn ein echter Anteil deiner Requests das teure Ding überhaupt nie anfasst, der Mailer-Service in Requests, die nie eine Mail verschicken, das Session-Objekt in Endpoints, die sie nie lesen. Wenn dein Profiler sagt, dass sowieso alles initialisiert wird, dann lösch die Laziness und konstruiere mit gutem Gewissen eager.
Hier lande ich also: Übernimm das mentale Modell jetzt, greif zu newLazyGhost(), wenn du ein wirklich manchmal ungenutztes teures Objekt hast, reserviere newLazyProxy() für den Fall, dass die Factory die Konstruktion besitzt, und sei dankbar, dass ein ganzes Genre von Code-Generierung gerade überflüssig geworden ist. Aber ich bin nur ein einzelner Entwickler mit einem einzelnen Satz Narben, und meine sind zufällig identitätsförmig. Wie sieht es bei dir aus? Hast du in echtem Code schon einen handgeschriebenen Value Holder oder generierten Proxy durch die native API ersetzt, und hat dich die Identitätsspaltung von Proxies je wirklich gebissen, oder wappne ich mich hier gegen einen Bug, den du in freier Wildbahn noch nie gesehen hast? Schreib es mir unten in die Kommentare, Kriegsgeschichten sind besonders willkommen.
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.