Lazy Loading und Interception ohne Framework
Eine dynamische Proxy-Klasse steht stellvertretend für ein echtes Objekt und fängt Methodenaufrufe ab, bevor sie beim eigentlichen Objekt ankommen. Mit __call, eval()-basierter Codegenerierung und Reflection lassen sich in PHP Proxy-Klassen bauen, die Lazy Loading, Logging und Zugriffskontrolle ermöglichen, ganz ohne die Klasse selbst anzufassen oder ein komplettes ORM einzubinden.
Inhaltsverzeichnis
- 1. Was ein Proxy in PHP ist und wozu man ihn braucht
- 2. Statische Proxy-Klassen von Hand: wo die Grenzen liegen
- 3. Interception mit __call und __get ohne Codegenerierung
- 4. Proxy-Klassen zur Laufzeit mit eval() generieren
- 5. Lazy-Loading-Proxies: Initialisierung verzögern
- 6. Ein generischer Proxy-Generator für beliebige Interfaces
- 7. Proxy-Klassen cachen und in Dateien persistieren
- 8. Fallstricke: final Klassen, private Methoden, Konstruktoren
- 9. Proxy-Ansätze im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Was ein Proxy in PHP ist und wozu man ihn braucht
Eine Proxy-Klasse ist ein Stellvertreter, der dieselbe Schnittstelle wie ein echtes Objekt anbietet, Methodenaufrufe aber abfängt, bevor sie tatsächlich beim eigentlichen Objekt ankommen. Zwischen Aufrufer und echtem Objekt kann der Proxy zusätzliche Logik einschieben: Logging, Zugriffskontrolle, Caching von Rückgabewerten oder eben verzögerte Initialisierung. Der Aufrufer merkt im Idealfall nichts davon, weil der Proxy dieselbe Typdeklaration erfüllt wie das Original.
In PHP begegnet man dynamischen Proxy-Klassen vor allem in zwei Kontexten: ORMs wie Doctrine erzeugen Proxy-Entitäten, die eine Datenbankzeile erst laden, wenn tatsächlich auf eine Property zugegriffen wird, und Dependency-Injection-Container erzeugen Proxies, um teure Services erst bei tatsächlicher Nutzung zu instanziieren. Beide Fälle haben gemeinsam, dass die Proxy-Klasse nicht von Hand für jede mögliche Zielklasse geschrieben werden kann, sie muss zur Laufzeit generiert werden.
Der Unterschied zu einem klassischen, von Hand geschriebenen Decorator liegt genau in dieser Generizität: Ein Decorator wird für eine konkrete Klasse geschrieben und gewartet, eine dynamisch generierte Proxy-Klasse funktioniert für beliebige Zielklassen, die eine gemeinsame Struktur wie ein Interface teilen, ohne dass für jede neue Zielklasse neuer Code geschrieben werden muss.
2. Statische Proxy-Klassen von Hand: wo die Grenzen liegen
Der einfachste Einstieg in das Proxy Pattern ist eine von Hand geschriebene Klasse, die ein Interface implementiert und jeden Methodenaufruf an ein internes, echtes Objekt weiterleitet. Das funktioniert gut, solange es nur wenige Zielklassen gibt und sich deren Interface selten ändert. Der Nachteil zeigt sich, sobald ein Interface um eine neue Methode erweitert wird: Jede von Hand geschriebene Proxy-Klasse muss synchron angepasst werden, sonst bricht die Typkompatibilität.
Bei zehn oder zwanzig Zielklassen mit ähnlicher Struktur wird das schnell zu Boilerplate-Code, der bei jeder Interface-Änderung an mehreren Stellen gepflegt werden muss. Genau hier setzt die Idee der dynamischen Proxy-Klasse an: Statt für jede Zielklasse eine eigene Proxy-Datei zu pflegen, wird der Proxy-Code aus der Struktur der Zielklasse zur Laufzeit oder zur Buildzeit automatisch abgeleitet.
<?php
declare(strict_types=1);
interface PaymentGateway
{
public function charge(int $amountCents): bool;
}
// Hand-written proxy: works, but must be updated whenever the interface changes
final class LoggingPaymentGatewayProxy implements PaymentGateway
{
public function __construct(private readonly PaymentGateway $inner)
{
}
public function charge(int $amountCents): bool
{
$start = microtime(true);
$result = $this->inner->charge($amountCents);
$duration = microtime(true) - $start;
error_log(sprintf('charge(%d) took %.4fs, result=%s', $amountCents, $duration, $result ? 'true' : 'false'));
return $result;
}
}
3. Interception mit __call und __get ohne Codegenerierung
Der leichtgewichtigste Weg zu einer dynamischen Proxy-Klasse nutzt PHPs Magic Methods __call() und __get(). Statt für jede Methode des Interfaces eine eigene Implementierung zu schreiben, fängt eine einzige __call()-Methode jeden nicht direkt vorhandenen Methodenaufruf ab, leitet ihn per call_user_func_array() an das echte Objekt weiter und kann davor oder danach beliebige Logik ausführen. Der Vorteil: keine Codegenerierung, keine eval()-Nutzung, funktioniert sofort mit jeder Zielklasse.
Der Nachteil dieses Ansatzes ist ebenso real: Ohne konkrete Methodendeklarationen erkennt weder die IDE noch PHPStan, welche Methoden die Proxy-Klasse tatsächlich anbietet, Autovervollständigung und Static Analysis fallen komplett aus. Für interne Werkzeuge und Prototypen ist das ein akzeptabler Kompromiss, für öffentliche Bibliotheks-APIs ist eine der folgenden, code-generierenden Techniken fast immer die bessere Wahl.
<?php
declare(strict_types=1);
final class GenericLoggingProxy
{
public function __construct(private readonly object $target)
{
}
// Intercepts every method call without knowing the target's interface upfront
public function __call(string $name, array $arguments): mixed
{
$start = microtime(true);
$result = $this->target->{$name}(...$arguments);
$duration = microtime(true) - $start;
error_log(sprintf('%s() took %.4fs', $name, $duration));
return $result;
}
}
$gateway = new GenericLoggingProxy(new StripePaymentGateway());
$gateway->charge(1999); // dispatched dynamically to StripePaymentGateway::charge()
4. Proxy-Klassen zur Laufzeit mit eval() generieren
Wer die Nachteile von Magic Methods vermeiden will, aber trotzdem keine Datei pro Zielklasse pflegen möchte, generiert echten PHP-Code als String und lädt ihn zur Laufzeit mit eval(). Dabei wird per Reflection API das Interface der Zielklasse untersucht, für jede Methode ein passender Methodenkopf als String zusammengesetzt und der gesamte Klassencode am Ende in einem einzigen eval()-Aufruf kompiliert. Das Ergebnis ist eine echte Klasse mit echten Methodendeklarationen, die IDEs und PHPStan wie jede andere Klasse verstehen.
eval() hat in PHP einen schlechten Ruf, meist zu Recht, wenn es um das Ausführen von Benutzereingaben geht. Für generierten, kontrollierten Code aus bekannten Interface-Definitionen ist die Situation anders: Es handelt sich nicht um fremde Eingaben, sondern um Code, den die eigene Anwendung selbst konstruiert hat. Trotzdem sollte jeder generierte Code vor dem Kompilieren geloggt oder wenigstens in einer Testumgebung überprüft werden, um Fehler in der Codegenerierung frühzeitig zu erkennen.
<?php
declare(strict_types=1);
function generateProxyClass(string $interfaceName, string $proxyClassName): void
{
$reflection = new ReflectionClass($interfaceName);
$methodBodies = [];
foreach ($reflection->getMethods() as $method) {
$params = implode(', ', array_map(
static fn (ReflectionParameter $p): string => '$' . $p->getName(),
$method->getParameters()
));
$methodBodies[] = sprintf(
'public function %s(%s) { return $this->target->%s(%s); }',
$method->getName(),
$params,
$method->getName(),
$params
);
}
$code = sprintf(
'final class %s implements %s {
public function __construct(private readonly %s $target) {}
%s
}',
$proxyClassName,
$interfaceName,
$interfaceName,
implode(' ', $methodBodies)
);
eval($code);
}
generateProxyClass(PaymentGateway::class, 'GeneratedPaymentGatewayProxy');
$proxy = new GeneratedPaymentGatewayProxy(new StripePaymentGateway());
5. Lazy-Loading-Proxies: Initialisierung verzögern
Ein Lazy-Loading-Proxy ist eine spezialisierte Form der dynamischen Proxy-Klasse, bei der das echte Objekt nicht sofort erzeugt wird, sondern erst beim ersten tatsächlichen Methodenaufruf. Das ist besonders wertvoll für teure Ressourcen wie Datenbankverbindungen, entfernte API-Clients oder große Objektgraphen, die nicht in jedem Request gebraucht werden. Statt das echte Objekt im Konstruktor zu erzeugen, speichert der Proxy nur eine Closure, die das Objekt bei Bedarf erzeugt, und ruft diese Closure beim ersten Zugriff genau einmal auf.
PHP 8.4 vereinfacht diesen Anwendungsfall zusätzlich mit nativen Lazy Objects über ReflectionClass::newLazyGhost(), die genau dieses Verhalten auf Sprachebene abbilden, ohne dass man eine eigene Proxy-Klasse schreiben muss. Für PHP-Versionen vor 8.4 oder für Fälle, in denen zusätzliche Interception-Logik neben dem reinen Lazy Loading gebraucht wird, bleibt die manuelle Closure-basierte Lösung relevant.
<?php
declare(strict_types=1);
final class LazyPaymentGatewayProxy implements PaymentGateway
{
private ?PaymentGateway $resolved = null;
/** @param Closure(): PaymentGateway $factory */
public function __construct(private readonly Closure $factory)
{
}
private function resolve(): PaymentGateway
{
// Instantiate the expensive real object only on first actual use
return $this->resolved ??= ($this->factory)();
}
public function charge(int $amountCents): bool
{
return $this->resolve()->charge($amountCents);
}
}
$proxy = new LazyPaymentGatewayProxy(static fn (): PaymentGateway => new StripePaymentGateway());
// StripePaymentGateway is only constructed here, not a moment earlier
$proxy->charge(1999);
6. Ein generischer Proxy-Generator für beliebige Interfaces
In größeren Anwendungen lohnt sich ein zentraler Proxy-Generator, der für ein beliebiges Interface auf Zuruf eine passende Proxy-Klasse erzeugt, statt für jeden Anwendungsfall eine eigene Generierungsfunktion zu schreiben. Ein solcher Generator kombiniert die im vorherigen Abschnitt gezeigte eval()-Technik mit einer Registry, die bereits generierte Klassennamen pro Interface merkt, damit dasselbe Interface nicht mehrfach im selben Request generiert wird.
Für Interception-Logik, die für alle generierten Proxies gleich sein soll, etwa einheitliches Logging oder Zugriffsprüfung, lohnt es sich, die generierte Methode nicht direkt an das Zielobjekt weiterzuleiten, sondern über eine zentrale invoke()-Methode einer Basisklasse zu routen. So bleibt die generierte Codemenge minimal, während die eigentliche Logik an einer einzigen Stelle gepflegt wird.
7. Proxy-Klassen cachen und in Dateien persistieren
Wiederholtes Generieren derselben Proxy-Klasse mit eval() bei jedem Request ist unnötiger Overhead. Die gängige Lösung: Generierten Code beim ersten Bedarf als reguläre PHP-Datei in ein Cache-Verzeichnis schreiben und bei nachfolgenden Requests einfach per require einbinden, statt erneut eval() aufzurufen. Das hat den zusätzlichen Vorteil, dass OPcache diese generierten Dateien wie jede andere PHP-Datei kompilieren und cachen kann, was bei reinem eval() nicht in gleichem Maß der Fall ist.
Ähnlich wie bei der Reflection-Cache-Strategie aus dem Artikel zur Reflection API im Detail sollte auch hier zwischen Entwicklung und Produktion unterschieden werden: In der Entwicklung prüft man idealerweise, ob sich das Quell-Interface seit der letzten Generierung geändert hat, in Produktion vertraut man dem einmal beim Deployment aufgebauten Cache vollständig.
<?php
declare(strict_types=1);
final class ProxyClassCache
{
public function __construct(private readonly string $cacheDir)
{
}
public function loadOrGenerate(string $interfaceName, string $proxyClassName): string
{
$file = $this->cacheDir . '/' . $proxyClassName . '.php';
if (is_file($file)) {
require_once $file;
return $proxyClassName;
}
$code = $this->buildProxyCode($interfaceName, $proxyClassName);
file_put_contents($file, "<?php\n\n" . $code, LOCK_EX);
require_once $file;
return $proxyClassName;
}
private function buildProxyCode(string $interfaceName, string $proxyClassName): string
{
// Reuse the generation logic from the eval()-based approach
$reflection = new ReflectionClass($interfaceName);
$methods = [];
foreach ($reflection->getMethods() as $method) {
$methods[] = sprintf('public function %s() {}', $method->getName());
}
return sprintf(
'final class %s implements %s { %s }',
$proxyClassName,
$interfaceName,
implode(' ', $methods)
);
}
}
8. Fallstricke: final Klassen, private Methoden, Konstruktoren
Nicht jede Klasse lässt sich mit einer dynamischen Proxy-Klasse umgeben. Als final deklarierte Klassen können nicht per Vererbung überschrieben werden, was bei Composition-basierten Proxies wie den hier gezeigten kein Problem ist, wohl aber bei Vererbungs-basierten Ansätzen, wie sie manche ORMs für Lazy Loading verwenden. Private Methoden einer Zielklasse sind grundsätzlich nicht über __call() abfangbar, weil sie außerhalb der Klasse gar nicht sichtbar sind, unabhängig davon, ob ein Proxy dazwischengeschaltet ist.
Ein weiterer häufiger Fallstrick betrifft Konstruktoren mit obligatorischen, komplexen Abhängigkeiten. Ein generierter Proxy, der die Zielklasse per Vererbung erweitert, muss deren Konstruktor entweder aufrufen oder mit newInstanceWithoutConstructor() umgehen, was wiederum bedeutet, dass sämtliche Properties der Basisklasse manuell nachbefüllt werden müssen. Composition-basierte Proxies, wie in diesem Artikel gezeigt, umgehen dieses Problem elegant, weil sie das echte Objekt fertig instanziiert entgegennehmen, statt es selbst per Vererbung zu konstruieren.
9. Proxy-Ansätze im Vergleich
Die vorgestellten Techniken unterscheiden sich deutlich in Typsicherheit, Performance und Implementierungsaufwand. Die folgende Tabelle fasst die wichtigsten Unterschiede zusammen.
| Ansatz | Typsicherheit | Performance | Wann geeignet |
|---|---|---|---|
| Hand-geschriebener Proxy | Vollständig | Optimal | Wenige, stabile Interfaces |
| __call-Interception | Keine (IDE, PHPStan blind) | Gut | Interne Tools, Prototypen |
| eval()-Codegenerierung | Vollständig | Gut (mit Caching) | Viele wechselnde Interfaces |
| Closure-basiertes Lazy Loading | Vollständig | Sehr gut | Teure, selten genutzte Objekte |
| Datei-Cache generierter Klassen | Vollständig | Optimal (OPcache-fähig) | Produktive Frameworks, viele Proxies |
In der Praxis kombinieren produktive Systeme meist mehrere dieser Techniken: eval()- oder Reflection-basierte Codegenerierung für die Struktur, Datei-Caching für Performance, und Closures für den Spezialfall Lazy Loading. Die Wahl hängt vor allem davon ab, wie stabil die Zielinterfaces sind und wie viele unterschiedliche Zielklassen mit Proxies umgeben werden müssen.
Mironsoft
PHP-Architektur, Lazy Loading und Codegenerierung
Teure Objekte per Lazy-Loading-Proxy entkoppeln?
Wir entwerfen dynamische Proxy-Klassen für Lazy Loading, Logging und Zugriffskontrolle, inklusive Caching-Strategie für produktive PHP-Anwendungen.
Proxy-Generatoren
Generische Proxy-Erzeugung für beliebige Interfaces
Lazy Loading
Teure Ressourcen erst bei tatsächlichem Bedarf initialisieren
Codegenerierung
Sichere eval()-Alternativen und Datei-Caching für generierte Klassen
10. Zusammenfassung
Eine dynamische Proxy-Klasse in PHP steht stellvertretend für ein echtes Objekt und fängt Methodenaufrufe ab, um Logging, Zugriffskontrolle oder Lazy Loading einzuschieben. __call()-basierte Proxies sind am schnellsten geschrieben, opfern aber Typsicherheit. Mit der Reflection API und eval() generierter Code liefert echte Methodendeklarationen und bleibt für IDEs und PHPStan sichtbar.
Lazy-Loading-Proxies verzögern die Erzeugung teurer Objekte bis zum ersten tatsächlichen Zugriff, entweder über eine Closure oder, seit PHP 8.4, über native Lazy Objects. Generierte Proxy-Klassen sollten als Dateien gecacht werden, damit OPcache sie wie regulären Code behandeln kann, statt bei jedem Request erneut eval() aufzurufen. Final deklarierte Klassen und private Methoden bleiben dabei grundsätzliche Grenzen jeder Proxy-Technik.
Dynamische Proxy-Klassen in PHP generieren — Das Wichtigste auf einen Blick
__call-Interception
Schnellster Einstieg, aber keine Typsicherheit für IDE und PHPStan.
eval()-Codegenerierung
Reflection liest das Interface, echte Methoden werden zur Laufzeit kompiliert.
Lazy Loading
Closure-basiert oder nativ ab PHP 8.4 mit newLazyGhost().
Caching
Generierte Klassen als Dateien persistieren, damit OPcache sie kompilieren kann.