```
Internals, Fibers, FFI, DDD & Event Sourcing – PHP über das Übliche hinaus.
Klassen, Methoden, Attribute zur Laufzeit erkunden
SplStack, SplQueue, SplPriorityQueue, SplObjectStorage
Eigene Protokolle wie file:// schreiben
Zirkuläre Referenzen, Memory-Profiling
Foreign Function Interface für Native-Libraries
Code-Compilation einmal, nicht pro Request
Async ohne Threading (PHP 8.1+)
Long-Running Server, non-blocking I/O
CPU- und Memory-Hotspots finden
APCu, Redis, HTTP-Cache, ESI
Eigener Container in 100 Zeilen
Lose Kopplung zwischen Bounded Contexts
Lese- und Schreibmodelle trennen
Value Objects, Entities, Aggregates
Ports und Adapter, Domain im Zentrum
State als Sequenz von Events
Mit ReflectionClass bekommst du alle Metadaten einer Klasse: Properties, Methoden, Attribute, Parent, Interfaces. Das ist die Basis fast aller Framework-Magie:
$reflection = new \ReflectionClass(User::class); $reflection->getName(); // 'App\User' $reflection->getShortName(); // 'User' $reflection->getMethods(); // ReflectionMethod[] $reflection->getProperties(); // ReflectionProperty[] $reflection->getParentClass(); // ReflectionClass|false $reflection->getInterfaceNames(); // string[] $reflection->getAttributes(); // ReflectionAttribute[]
Manchmal willst du eine Klasse instanziieren, ohne den Konstruktor aufzurufen – z.B. zum Deserialisieren von Datenbank-Rows oder JSON. Reflection erlaubt das:
$reflection = new \ReflectionClass(User::class); $user = $reflection->newInstanceWithoutConstructor(); // Jetzt Properties direkt setzen $nameProperty = $reflection->getProperty('name'); $nameProperty->setValue($user, 'Marek');
Doctrine ORM tut genau das beim Laden: Entity ohne Konstruktor instanziieren, Properties aus DB-Row setzen. Sonst müsste jede Entity einen "leeren" Konstruktor haben.
Reflection ignoriert Sichtbarkeitsregeln – essentiell für Testing-Frameworks und Serializer:
class Order { private string $internalRef; private function calculateTax(): float { /* ... */ } } $order = new Order(); $reflection = new \ReflectionClass($order); // Private property setzen (für Tests) $prop = $reflection->getProperty('internalRef'); $prop->setValue($order, 'TEST-123'); // Private Methode aufrufen $method = $reflection->getMethod('calculateTax'); $tax = $method->invoke($order);
Reflection ist nicht gratis. Pro ReflectionClass-Instanz parst PHP intern viele Metadaten. Frameworks wie Symfony cachen Reflection-Ergebnisse aggressiv. Faustregel:
| Use Case | OK |
|---|---|
| Boot-Zeit, einmal pro Request | ja |
| Dependency-Injection-Container Build | ja, aber cachen |
| In jedem Method-Call | nein, das ist langsam |
| Hot-Path-Loops | nein, niemals |
$reflection->getAttributes() liest PHP 8 Attribute aus. Das ist die Brücke zwischen deinem deklarativen Markup (#[Route('/users')]) und dem Framework-Code, der es interpretiert.
Stack (LIFO) und Queue (FIFO) als typisierte Strukturen. Bei großen Mengen schneller als Array-Operationen mit array_push/array_shift:
$stack = new \SplStack(); $stack->push('a'); $stack->push('b'); $stack->push('c'); $stack->pop(); // 'c' $stack->top(); // 'b' (peek ohne entfernen) $queue = new \SplQueue(); $queue->enqueue('first'); $queue->enqueue('second'); $queue->dequeue(); // 'first'
Wichtiger Vorteil gegenüber Array: O(1) für alle Operationen, auch dequeue. array_shift ist O(n) wegen Re-Indexing.
Bei Aufgaben mit Prioritäten – Job-Scheduler, A*-Pathfinding, Event-Loops – ist eine Priority Queue Standard:
$pq = new \SplPriorityQueue(); $pq->insert('low-priority-task', 1); $pq->insert('urgent-task', 10); $pq->insert('normal-task', 5); while (!$pq->isEmpty()) { echo $pq->extract() . "\n"; } // urgent-task // normal-task // low-priority-task
Reguläre PHP-Arrays erlauben nur Strings/Ints als Keys. Mit SplObjectStorage kannst du Objekte selbst als Keys nutzen:
$permissions = new \SplObjectStorage(); $alice = new User('Alice'); $bob = new User('Bob'); $permissions[$alice] = ['read', 'write']; $permissions[$bob] = ['read']; $permissions[$alice]; // ['read', 'write'] $permissions->contains($bob); // true // Iteration foreach ($permissions as $user) { $perms = $permissions[$user]; echo $user->name . ': ' . implode(',', $perms) . "\n"; }
Praktisch für Identity-Maps in ORMs, Permission-Systems, Graphen mit Objekt-Knoten.
Bei sehr großen, indizierten Arrays mit fester Größe ist SplFixedArray deutlich speichereffizienter:
// Reguläres Array: ~100MB für 1M Elemente $arr = []; for ($i = 0; $i < 1_000_000; $i++) { $arr[$i] = $i * 2; } // SplFixedArray: ~40MB für dasselbe $arr = new \SplFixedArray(1_000_000); for ($i = 0; $i < 1_000_000; $i++) { $arr[$i] = $i * 2; }
SplQueue gegenüber array_shift?SplObjectStorage?SplFixedArray?fopen('file:///pfad'), file_get_contents('http://...'), vielleicht fopen('php://memory'). All das funktioniert über Stream-Wrapper. Du kannst eigene schreiben – z.B. s3://bucket/key oder db://users/42. Wie geht das?
Ein Stream-Wrapper definiert ein Pseudo-Protokoll, das wie eine Datei aussieht. Alle PHP-Funktionen, die mit Streams arbeiten (fopen, fread, file_get_contents, file_put_contents), funktionieren transparent damit.
PHP bringt mehrere eingebaute Wrapper mit:
| Wrapper | Wofür |
|---|---|
file:// | Lokales Dateisystem (Default) |
http:// / https:// | HTTP-Requests |
php://memory | In-Memory Stream |
php://stdin / php://stdout | Standard-IO |
php://input | Raw POST-Body |
data:// | Base64 oder URL-encoded Daten |
compress.zlib:// | gzip-Streams |
Ein Stream-Wrapper ist eine Klasse mit bestimmten Methoden. Minimal-Beispiel: ein Wrapper, der Strings aus einem statischen Array liefert:
class DictionaryStreamWrapper { private static array $dict = [ 'hello' => 'Hallo, Welt!', 'goodbye' => 'Auf Wiedersehen.', ]; private int $position = 0; private string $data = ''; public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool { $key = parse_url($path, PHP_URL_HOST); if (!isset(self::$dict[$key])) return false; $this->data = self::$dict[$key]; return true; } public function stream_read(int $count): string { $chunk = substr($this->data, $this->position, $count); $this->position += strlen($chunk); return $chunk; } public function stream_eof(): bool { return $this->position >= strlen($this->data); } } // Registrieren und nutzen stream_wrapper_register('dict', DictionaryStreamWrapper::class); echo file_get_contents('dict://hello'); // 'Hallo, Welt!'
AWS-SDK für PHP registriert einen s3://-Wrapper. Dein Code arbeitet dann mit S3 wie mit lokalen Dateien:
use Aws\S3\S3Client; $client = new S3Client([/* config */]); $client->registerStreamWrapper(); // Jetzt arbeitet jede File-Funktion mit S3 $data = file_get_contents('s3://my-bucket/users/42.json'); file_put_contents('s3://my-bucket/log.txt', 'eintrag'); foreach (scandir('s3://my-bucket/uploads/') as $file) { // ... }
Filter transformieren Daten während des Streamings. Eingebaute Filter: string.rot13, string.toupper, zlib.deflate:
$handle = fopen('large.txt', 'r'); stream_filter_append($handle, 'string.toupper'); while (!feof($handle)) { echo fread($handle, 8192); // alles UPPERCASE }
PHP nutzt primär Reference Counting: jede Variable hat einen Counter, wie oft auf sie verwiesen wird. Fällt der Counter auf 0, wird der Speicher sofort freigegeben:
$a = 'hallo'; // refcount = 1 $b = $a; // refcount = 2 unset($a); // refcount = 1 unset($b); // refcount = 0 → Speicher frei
Das ist schnell und deterministisch. Aber es hat ein Problem: zirkuläre Referenzen.
class Node { public ?Node $next = null; } $a = new Node(); $b = new Node(); $a->next = $b; // $b refcount = 2 $b->next = $a; // $a refcount = 2 unset($a); // $a refcount = 1 (von $b->next) unset($b); // $b refcount = 1 (von $a->next) // Beide Counter sind 1, aber nichts greift mehr drauf zu // → Memory Leak!
Reference Counting allein erkennt das nicht. PHP hat dafür einen zusätzlichen Cycle Collector, der periodisch durchläuft und solche Zyklen findet.
Der Collector läuft automatisch, wenn ein interner Buffer voll ist (Default 10.000 Roots). Du kannst ihn manuell triggern:
// Aktuellen Status prüfen gc_status(); // runs, collected, threshold, roots // Manuell laufen lassen $collected = gc_collect_cycles(); // Anzahl entsorgter Objekte // Deaktivieren (Performance-kritische Sektion) gc_disable(); // ... Hot-Code ... gc_enable();
In CLI-Workern oder ReactPHP-Servern ist Memory-Management kritisch – PHP-Scripts sterben sonst nach Stunden. Pattern:
while ($message = $queue->consume()) { processMessage($message); // Memory-Check if (memory_get_usage() > 100 * 1024 * 1024) { gc_collect_cycles(); if (memory_get_usage() > 200 * 1024 * 1024) { exit(0); // Supervisor startet neu } } }
Symfony Messenger und Laravel Horizon nutzen genau dieses Pattern – sie killen Worker nach bestimmter Memory-Schwelle und lassen sie neu starten.
Seit PHP 7.4 (WeakReference) und 8.0 (WeakMap) gibt es einen offiziellen Mechanismus für Referenzen, die nicht in den Refcount zählen:
$user = new User('Marek'); $weak = \WeakReference::create($user); $weak->get(); // User-Objekt unset($user); // User wird sofort freigegeben $weak->get(); // null // WeakMap: nützlich für Caches, die nicht "festhalten" $cache = new \WeakMap(); $cache[$user] = 'cached-value'; // Sobald $user weg ist, ist auch der Cache-Eintrag weg
WeakReference?FFI ist standardmäßig installiert, muss aber in der php.ini aktiviert werden:
# php.ini extension=ffi ffi.enable=preload # sicher (nur für preloaded scripts) # oder: ffi.enable=true # offen, unsicher in Web-Context
FFI braucht zwei Dinge: die Signatur der C-Funktion und die Library-Datei. Klassisches Beispiel: die libc-Funktion getpid:
$ffi = \FFI::cdef(" int getpid(void); unsigned int sleep(unsigned int seconds); ", "libc.so.6"); echo $ffi->getpid(); // 12345 (Process-ID) $ffi->sleep(2); // 2 Sekunden schlafen
Für größere Libraries kannst du komplette C-Header parsen lassen. Beispiel: SDL für ein Spiele-Backend, oder libxml für XML-Verarbeitung:
$ffi = \FFI::load('/usr/include/sdl2/SDL.h'); // Jetzt sind alle SDL-Funktionen verfügbar $ffi->SDL_Init(SDL_INIT_VIDEO); $window = $ffi->SDL_CreateWindow(...);
C-Strukturen werden zu PHP-Objekten. Pointer und Memory-Allokation funktionieren auch:
$ffi = \FFI::cdef(" typedef struct { int x; int y; } Point; "); // Struct allokieren $point = $ffi->new('Point'); $point->x = 42; $point->y = 17; // Array von Structs $points = $ffi->new('Point[100]'); for ($i = 0; $i < 100; $i++) { $points[$i]->x = $i; }
| Use Case | Beispiel-Library |
|---|---|
| Bildverarbeitung | libvips, OpenCV |
| Kompression | libzstd, brotli |
| Crypto | libsodium (auch nativ in PHP) |
| ML-Inferenz | libtorch, onnxruntime |
| Grafik/UI | SDL, GTK |
| System-Calls | libc direkt |
FFI-Calls sind nicht gratis: jeder Aufruf hat Overhead durch Type-Conversion. Faustregel:
ffi.enable=preload nur in CLI-Scripts oder Preload-Files nutzen. In Web-Context (Apache/php-fpm) bedeutet falsche FFI-Nutzung Memory-Corruption oder Crashes. Halte FFI auf CLI-Tools oder Daemon-Code beschränkt.
FFI::cdef?OpCache ist ab PHP 5.5 dabei, muss aber in php.ini aktiviert werden:
# php.ini opcache.enable=1 opcache.memory_consumption=256 opcache.max_accelerated_files=20000 opcache.validate_timestamps=1 # Dev: 1 (prüft Änderungen) opcache.revalidate_freq=0 # Prod: 0 (kein Re-Check) opcache.jit_buffer_size=128M # PHP 8 JIT opcache.jit=tracing
OpCache cached die kompilierten OpCodes im Shared Memory. Beim nächsten Request wird der gecachte OpCode direkt ausgeführt – ohne Parse und Compile.
In Development willst du Code-Änderungen sofort sehen (validate_timestamps=1). In Production sind File-Stat-Calls bei jedem Request unnötiger Overhead – deshalb validate_timestamps=0:
# Bei Deployment manuell reload service php-fpm reload # oder opcache_reset() # im PHP-Code # oder cache file/api curl http://localhost/opcache-reset.php
$status = opcache_get_status(); $status['opcache_statistics']['hits']; // Cache Hits $status['opcache_statistics']['misses']; // Misses $status['memory_usage']['used_memory']; // Speicher $status['memory_usage']['free_memory']; // frei // Bei voller Speicher-Auslastung: opcache.memory_consumption erhöhen
Seit PHP 7.4 gibt es Preloading: beim Start des PHP-Prozesses werden Klassen einmal komplett geladen und stehen dann in jedem Request sofort zur Verfügung – ohne OpCache-Lookup:
# php.ini
opcache.preload=/var/www/preload.php
opcache.preload_user=www-data
// preload.php <?php require '/var/www/vendor/autoload.php'; // Lade alle Klassen aus src/ foreach (new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator('/var/www/src') ) as $file) { if ($file->getExtension() === 'php') { opcache_compile_file($file->getRealPath()); } }
Effekt: in Laravel- und Symfony-Apps oft 20-30% schnellere Requests, vor allem bei kleinen Aktionen.
Mit PHP 8 kam der JIT. Er kompiliert OpCodes weiter zu nativem Maschinencode. In typischen Web-Apps bringt JIT wenig (I/O-bound), bei rechenintensiven Loops und CLI-Tools deutlich:
| Use Case | Speedup mit JIT |
|---|---|
| Web (Database/Network-bound) | ~5-10% |
| CLI-Tools, Compute-bound | ~30-50% |
| Mandelbrot/Mathematik | ~2-3x |
validate_timestamps=1 und 0?Eine Fiber ist eine "leichtgewichtige" Coroutine: sie hat ihren eigenen Stack, kann pausieren und später fortgesetzt werden. Anders als Threads laufen sie nicht parallel, sondern kooperativ – sie geben Kontrolle freiwillig ab:
$fiber = new \Fiber(function(): void { echo "Fiber gestartet\n"; \Fiber::suspend('pause-wert'); // pausieren echo "Fiber fortgesetzt\n"; }); $value = $fiber->start(); // 'pause-wert' echo "In Main: $value\n"; $fiber->resume('antwort'); // Fiber läuft weiter
Ausgabe:
Fiber gestartet In Main: pause-wert Fiber fortgesetzt
Klassischer Use Case: HTTP-Requests parallel. Statt zu blockieren, suspendiert die Fiber während sie wartet:
function fetchUrl(string $url): string { return new \Fiber(function() use ($url) { $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // Während wir warten: andere Fibers können laufen \Fiber::suspend(); return curl_exec($ch); }); }
Das einfache Beispiel hier zeigt nur das Konzept – in der Praxis brauchst du eine Scheduler/Event-Loop, die mehrere Fibers verwaltet. Frameworks wie ReactPHP und Amp bieten das.
Amp (revierschiff in PHP-Async-Welt) nutzt Fibers in Version 3.0 für eine viel saubere API als die alten Promise-basierten Patterns:
use function Amp\async; use function Amp\Future\await; // Drei parallele HTTP-Requests $results = await([ async(fetchUrl(...), 'https://api.a.com'), async(fetchUrl(...), 'https://api.b.com'), async(fetchUrl(...), 'https://api.c.com'), ]); // Hier sind alle drei Responses verfügbar foreach ($results as $response) { // ... }
Im Hintergrund läuft eine Event-Loop, die alle Fibers verwaltet und sie aufweckt, wenn ihre I/O fertig ist.
| Aspekt | Fibers | Threads (parallel) |
|---|---|---|
| Parallelität | Nein, kooperativ | Ja, true parallel |
| Memory pro Unit | ~8 KB | ~MB |
| Context-Switch | Sehr schnell | OS-Overhead |
| Shared State | Direkt zugreifbar | Locking nötig |
| Use Case | I/O-bound | CPU-bound |
Für klassische Web-Apps (viele I/O-Calls, wenig CPU) sind Fibers fast immer die richtige Wahl. Wenn du echtes Parallel-Processing brauchst (Bildverarbeitung, Berechnungen), nutzt du PHP parallel-Extension oder lagerst es in separate Prozesse aus.
Eine Event-Loop ist eine Endlos-Schleife, die Events aus verschiedenen Quellen (Sockets, Timer, Streams) liest und Handler aufruft. I/O-Operationen blockieren nicht – sie geben Promises/Callbacks zurück.
use React\EventLoop\Loop; // Timer: nach 2 Sekunden, einmal Loop::addTimer(2.0, function() { echo "Nach 2 Sekunden\n"; }); // Periodisch alle 5 Sekunden Loop::addPeriodicTimer(5.0, function() { echo "Alle 5 Sekunden\n"; }); // Event-Loop starten (blockierend bis stop) Loop::run();
Ein vollständiger HTTP-Server in wenigen Zeilen – ohne Apache/nginx davor:
use React\Http\HttpServer; use React\Http\Message\Response; use React\Socket\SocketServer; use Psr\Http\Message\ServerRequestInterface; $http = new HttpServer(function(ServerRequestInterface $request) { return Response::plaintext("Hello, " . $request->getUri()->getPath()); }); $socket = new SocketServer('0.0.0.0:8080'); $http->listen($socket); echo "Server läuft auf http://localhost:8080\n"; // Event-Loop läuft implizit weiter
Vorteil gegenüber php-fpm: State bleibt zwischen Requests erhalten. Datenbankverbindungen, Caches, geladene Klassen werden einmal aufgebaut. Bei vielen kleinen Requests ist das deutlich schneller.
use React\Stream\ReadableResourceStream; use React\Stream\WritableResourceStream; $stdin = new ReadableResourceStream(STDIN); $stdout = new WritableResourceStream(STDOUT); $stdin->on('data', function($chunk) use ($stdout) { $stdout->write(strtoupper($chunk)); }); $stdin->on('end', function() { echo "\nInput beendet\n"; });
Ratchet baut auf ReactPHP auf und bringt WebSocket-Support:
use Ratchet\Server\IoServer; use Ratchet\WebSocket\WsServer; use Ratchet\MessageComponentInterface; use Ratchet\ConnectionInterface; class ChatServer implements MessageComponentInterface { protected \SplObjectStorage $clients; public function __construct() { $this->clients = new \SplObjectStorage(); } public function onOpen(ConnectionInterface $conn) { $this->clients->attach($conn); } public function onMessage(ConnectionInterface $from, $msg) { foreach ($this->clients as $client) { if ($client !== $from) $client->send($msg); } } // onClose, onError ... } $server = IoServer::factory(new WsServer(new ChatServer()), 8081); $server->run();
| Use Case | Wähle |
|---|---|
| Klassische CRUD-App | php-fpm + Symfony/Laravel |
| WebSocket-Server | ReactPHP + Ratchet |
| Pub/Sub-Worker | ReactPHP oder Symfony Messenger |
| Streaming-API | ReactPHP |
| Tausende kleine Microservice-Calls | ReactPHP (Connection-Reuse) |
sleep(10), file_get_contents() auf eine langsame URL, oder ein synchrones PDO::query() blockiert die gesamte Event-Loop. Alle Connections frieren ein. In ReactPHP musst du konsequent non-blocking I/O nutzen.
Xdebug bringt einen einfachen Profiler mit. In php.ini:
xdebug.mode=profile xdebug.start_with_request=trigger xdebug.output_dir=/tmp/xdebug
Mit ?XDEBUG_TRIGGER=1 als Query-Parameter aktivierst du das Profiling für einen Request. Ergebnis: eine Cachegrind-Datei in /tmp/xdebug/, die du mit KCacheGrind (Linux) oder QCacheGrind (macOS) öffnest.
Blackfire ist ein kommerzieller Profiler mit Web-UI, Vergleichen, Regressions-Detection und Production-Profiling. Setup: Agent installieren, Probe als PHP-Extension, dann profilen:
# CLI-Script profilen blackfire run php script.php # Eine bestimmte URL blackfire curl https://example.com/slow-page # Mit Iterationen (Mittelwert) blackfire --samples=10 curl https://example.com/page
Das Ergebnis ist ein Call-Graph: jede Funktion mit ihrer Zeit, wer sie aufruft, wie oft, welcher Anteil an der Gesamtzeit.
use Blackfire\Client; use Blackfire\Profile\Configuration; $blackfire = new Client(); $config = (new Configuration())->setTitle('Order Processing'); $probe = $blackfire->createProbe($config); // Code, der gemessen werden soll $service->processOrders($orders); $blackfire->endProbe($probe);
| Pattern | Wie oft | Lösung |
|---|---|---|
| N+1 Query | fast jede App | Eager-Loading |
| JSON-Encoding mehrfach | oft | Cache |
| Twig ohne Cache | häufig | opcache.preload |
| Doctrine: gleiche Entity 100x geladen | häufig | Identity Map |
| Composer Autoload langsam | oft | composer dump-autoload -o |
Memory-Leaks sind oft schlimmer als CPU-Probleme. memory_get_peak_usage() gibt dir die maximale Speichernutzung eines Requests:
$start = memory_get_usage(); processLargeData(); $end = memory_get_usage(); $peak = memory_get_peak_usage(); echo "Used: " . ($end - $start) . " bytes\n"; echo "Peak: $peak bytes\n";
Für tieferes Memory-Profiling: php-meminfo (Extension) oder Blackfire's Memory-Profile.
memory_get_peak_usage()?| Schicht | Latenz | Tech |
|---|---|---|
| OpCache (kompilierter Code) | ~0ms | eingebaut |
| Request-Cache (in-memory) | ~0ms | Arrays |
| APCu (shared memory) | ~0.01ms | APCu Extension |
| Redis (lokal) | ~0.5ms | Redis-Server |
| Redis (Netzwerk) | ~1-5ms | remote Redis |
| Datenbank-Query | ~5-100ms | MySQL/Postgres |
| Externe API | ~50-1000ms | HTTP |
Jede Schicht hinunter ist 10-100x langsamer. Die Kunst: so weit oben wie möglich cachen, ohne stale Daten zu liefern.
Die PHP-FIG hat Cache-Interfaces standardisiert. Symfony Cache, Laravel Cache und Doctrine Cache implementieren sie. Dein Code bleibt agnostisch:
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; class UserRepository { public function __construct(private CacheInterface $cache) {} public function find(int $id): ?User { return $this->cache->get("user.$id", function(ItemInterface $item) use ($id) { $item->expiresAfter(3600); // 1 Stunde return $this->loadFromDb($id); }); } }
APCu ist Shared Memory pro PHP-Server-Prozess. Sehr schnell, aber:
// Direkte API apcu_store('key', $data, 300); // 5 Min TTL $data = apcu_fetch('key'); // Atomic increment für Counter $hits = apcu_inc('hits:home');
$redis = new \Redis(); $redis->connect('redis-server', 6379); // Einfacher Key-Value $redis->set('user:42', json_encode($user), 3600); $cached = json_decode($redis->get('user:42'), true); // Pipeline für Batch-Operationen $pipe = $redis->multi(Redis::PIPELINE); foreach ($ids as $id) { $pipe->get("user:$id"); } $results = $pipe->exec(); // alle in einem Roundtrip
Für komplette Seiten oder Fragmente bietet sich HTTP-Caching an. Cache-Control und ETag sagen Browser und CDNs, wann sie nicht neu fragen müssen:
// Symfony Response $response->setPublic(); $response->setMaxAge(3600); $response->setSharedMaxAge(86400); // CDN cached 1 Tag $response->setEtag(md5($content));
ESI (Edge Side Includes) erlaubt, dass CDN/Varnish Teile einer Seite separat cachen – Header bleibt 1 Stunde gecached, der User-spezifische Mini-Cart nur 1 Minute. Symfony unterstützt ESI nativ.
Das schwerste Problem. Strategien:
Du sagst dem Container: "Gib mir eine Instanz von OrderService". Er liest die Konstruktor-Signatur, sieht "braucht PaymentGateway und EmailSender", instanziiert die auch automatisch und übergibt sie:
class OrderService { public function __construct( private PaymentGateway $gateway, private EmailSender $mailer, ) {} } // Container kümmert sich um alles $container = new Container(); $service = $container->get(OrderService::class);
Hier ein vereinfachter Container, der Klassen über Reflection auflöst. Production-Container haben mehr Features, das Kern-Prinzip bleibt:
class Container { private array $bindings = []; private array $instances = []; public function bind(string $abstract, callable|string $concrete): void { $this->bindings[$abstract] = $concrete; } public function singleton(string $abstract, callable|string $concrete): void { $this->bind($abstract, $concrete); $this->instances[$abstract] = null; } public function get(string $id): object { // Singleton-Cache if (array_key_exists($id, $this->instances) && $this->instances[$id]) { return $this->instances[$id]; } $instance = $this->resolve($id); if (array_key_exists($id, $this->instances)) { $this->instances[$id] = $instance; } return $instance; } private function resolve(string $id): object { $concrete = $this->bindings[$id] ?? $id; if (is_callable($concrete)) { return $concrete($this); } $reflection = new \ReflectionClass($concrete); $constructor = $reflection->getConstructor(); if (!$constructor) { return new $concrete(); } $args = []; foreach ($constructor->getParameters() as $param) { $type = $param->getType(); if ($type && !$type->isBuiltin()) { $args[] = $this->get($type->getName()); } elseif ($param->isDefaultValueAvailable()) { $args[] = $param->getDefaultValue(); } else { throw new \Exception("Cannot resolve $id"); } } return $reflection->newInstanceArgs($args); } }
$container = new Container(); // Interface → konkrete Klasse $container->bind(LoggerInterface::class, FileLogger::class); // Factory-Closure $container->bind(\PDO::class, fn() => new \PDO('mysql:host=localhost', 'user', 'pass') ); // Singleton (gleiche Instanz für alle Calls) $container->singleton(CacheInterface::class, RedisCache::class); // Auflösen $service = $container->get(OrderService::class); // Container baut automatisch: PaymentGateway, EmailSender, deren Dependencies
Echte Container haben noch viel mehr:
OrderService::place() reincoden? Das Event-Dispatcher-Pattern trennt das sauber.
class OrderService { public function __construct( private EmailService $mailer, private InventoryService $inventory, private AnalyticsService $analytics, private WebhookService $webhooks, // noch 5 weitere ... ) {} public function place(Order $order): void { $this->save($order); $this->mailer->sendConfirmation($order); $this->inventory->reserve($order); $this->analytics->track($order); $this->webhooks->notify($order); // ... } }
OrderService kennt alle anderen Services. Jeder neue Listener bedeutet eine Code-Änderung. Tests brauchen alle Mocks. Tight Coupling.
Der Service publiziert nur ein Event. Wer darauf reagiert, ist ihm egal. Andere Bounded Contexts subscriben sich:
class OrderPlaced { public function __construct(public readonly Order $order) {} } class OrderService { public function __construct(private EventDispatcherInterface $events) {} public function place(Order $order): void { $this->save($order); $this->events->dispatch(new OrderPlaced($order)); } } // Listener leben in ihren eigenen Modulen class SendConfirmationListener { public function __invoke(OrderPlaced $event): void { $this->mailer->send($event->order->customer, 'Bestätigung'); } } class ReserveInventoryListener { public function __invoke(OrderPlaced $event): void { $this->inventory->reserve($event->order); } }
Jetzt kann ein neues Team einen neuen Listener hinzufügen, ohne OrderService anzufassen. Tests des Services brauchen nur einen Mock-Dispatcher.
Symfony hat einen ausgereiften Dispatcher (PSR-14-konform). Listener werden über Tags oder Attribute registriert:
use Symfony\Component\EventDispatcher\Attribute\AsEventListener; #[AsEventListener(event: OrderPlaced::class)] class SendConfirmationListener { public function __invoke(OrderPlaced $event): void { // ... } }
Symfony scannt beim Container-Build alle Klassen mit dem Attribut und verdrahtet sie automatisch.
Standardmäßig laufen Listener synchron – im selben Request, hintereinander. Für langsame Operationen (E-Mail, Webhook) willst du sie async machen – sonst friert der User-Request ein:
use Symfony\Component\Messenger\Attribute\AsMessageHandler; // Statt EventListener: MessageHandler in Symfony Messenger #[AsMessageHandler] class SendConfirmationHandler { public function __invoke(OrderPlaced $message): void { $this->mailer->send(...); } } # config/messenger.yaml framework: messenger: routing: App\Event\OrderPlaced: 'async' # landet in Queue
Der User-Request kehrt sofort zurück, der Mail-Versand passiert im Worker-Prozess. Wichtig für UX und Resilienz.
OrderPlaced) sind Teil der Geschäftslogik, beschreiben "was passiert ist". Application Events sind technisch (kernel.request in Symfony). Mische sie nicht – Domain Events leben im Domain-Layer, Application Events im Framework.
Command Query Responsibility Segregation – ein Pattern, das Lese-Operationen (Queries) und Schreib-Operationen (Commands) auf separate Modelle teilt:
// Command: Intention "tu das" class CreateUserCommand { public function __construct( public readonly string $name, public readonly string $email, ) {} } // Query: Frage "wie ist das" class FindUsersByCountryQuery { public function __construct( public readonly string $country, public readonly int $page = 1, ) {} }
Statt Controller-Code, der direkt Repository-Aufrufe macht, dispatched er einen Command an den Bus. Der Bus findet den richtigen Handler:
class UserController { public function create(Request $request, CommandBus $bus): Response { $command = new CreateUserCommand( name: $request->getString('name'), email: $request->getString('email'), ); $bus->dispatch($command); return new Response('Created', 201); } } // Handler – die einzige Stelle mit Business-Logik class CreateUserCommandHandler { public function __construct(private UserRepository $repo) {} public function __invoke(CreateUserCommand $cmd): void { $user = new User($cmd->name, $cmd->email); $this->repo->save($user); } }
Analog für Reads – aber mit Rückgabewert. Queries lesen oft aus optimierten Read-Modellen (denormalisierte Views, Search-Index):
class FindUsersByCountryQueryHandler { public function __construct(private \PDO $db) {} public function __invoke(FindUsersByCountryQuery $q): array { // Direktes SQL, optimiert für die View $stmt = $this->db->prepare(' SELECT id, name, email, created_at, order_count FROM users_with_stats WHERE country = ? LIMIT ? OFFSET ? '); $stmt->execute([$q->country, 50, ($q->page - 1) * 50]); return $stmt->fetchAll(); } }
Beachte: keine Entities, kein Repository – direkter DB-Zugriff auf eine speziell für diese Query optimierte View. Performance über Domain-Sauberkeit, weil Queries nichts ändern.
Symfony Messenger ist nicht nur für async – auch als Command/Query Bus nutzbar:
# config/messenger.yaml
framework:
messenger:
buses:
command.bus:
middleware:
- validation
- doctrine_transaction
query.bus:
middleware:
- validation
class UserController { public function __construct( private MessageBusInterface $commandBus, private MessageBusInterface $queryBus, ) {} public function list(string $country): Response { $users = $this->queryBus->dispatch(new FindUsersByCountryQuery($country)); return Response::json($users); } }
| Use Case | CQRS sinnvoll? |
|---|---|
| Simple CRUD-App | nein, Overkill |
| Komplexe Business-Logik bei Writes | ja |
| Lese-Performance ist kritisch | ja |
| Sehr unterschiedliche Read- und Write-Modelle | ja |
| Mehrere Teams an einem Bounded Context | ja |
Ein Value Object ist ein Wert ohne eigene Identität, unveränderlich. Statt string $email nimm Email $email – mit eingebauter Validierung:
final class Email { public function __construct(public readonly string $value) { if (!filter_var($value, FILTER_VALIDATE_EMAIL)) { throw new \InvalidArgumentException("Ungültige Email: $value"); } } public function domain(): string { return substr($this->value, strpos($this->value, '@') + 1); } public function equals(Email $other): bool { return strtolower($this->value) === strtolower($other->value); } } $email = new Email('marek@example.com'); // validiert automatisch $email->domain(); // 'example.com'
Wo immer eine Email durch deinen Code wandert, ist sie garantiert valide. Keine "ist das ein gültiges Format?"-Checks mehr überall.
Eine Entity hat eine Identität (z.B. ID), kann sich über die Zeit verändern, aber bleibt dasselbe Objekt. Klassisches Anti-Pattern: anämische Entity mit nur Gettern/Settern. DDD-Ansatz: Logik lebt in der Entity:
class Order { private array $items = []; private OrderStatus $status; public function __construct(public readonly OrderId $id, public readonly CustomerId $customerId) { $this->status = OrderStatus::Draft; } public function addItem(Product $product, int $quantity): void { if ($this->status !== OrderStatus::Draft) { throw new CannotModifyConfirmedOrder(); } $this->items[] = new OrderItem($product, $quantity); } public function confirm(): void { if (empty($this->items)) throw new CannotConfirmEmptyOrder(); $this->status = OrderStatus::Confirmed; } public function total(): Money { return array_reduce( $this->items, fn(Money $sum, OrderItem $item) => $sum->add($item->subtotal()), Money::zero() ); } }
Die Order schützt ihre eigenen Invarianten – sie lässt nicht zu, dass jemand sie in einen inkonsistenten Zustand bringt.
Ein Aggregate ist eine Gruppe von Objekten, die zusammen konsistent bleiben müssen. Der Aggregate Root ist der einzige Einstiegspunkt:
// Order ist Aggregate Root // OrderItem ist Teil des Aggregates, NICHT direkt zugreifbar // Schlecht: OrderItem direkt aus Repository holen $item = $itemRepo->find(123); $item->quantity = 5; $itemRepo->save($item); // Order weiß nichts davon → Inkonsistenz // Gut: Immer durch Aggregate Root $order = $orderRepo->find($orderId); $order->changeItemQuantity($itemId, 5); // Order prüft Invarianten $orderRepo->save($order);
Ein Repository ist die Schnittstelle zur Persistenz für ein Aggregate. Er sieht aus wie eine Collection:
interface OrderRepository { public function find(OrderId $id): ?Order; public function save(Order $order): void; public function remove(Order $order): void; } // Implementierung mit Doctrine class DoctrineOrderRepository implements OrderRepository { public function __construct(private EntityManagerInterface $em) {} public function find(OrderId $id): ?Order { return $this->em->find(Order::class, $id->value); } public function save(Order $order): void { $this->em->persist($order); $this->em->flush(); } }
Im Zentrum steht die Domain (Geschäftslogik). Drumherum sind Ports (Interfaces) und Adapter (Implementierungen). Die Domain weiß nichts von Symfony, Doctrine oder HTTP – sie kennt nur ihre eigenen Interfaces:
┌─────────────────┐
HTTP → │ │
│ │
CLI → │ DOMAIN │ → Database
│ (Geschäfts- │ → File-Storage
Worker → │ logik) │ → External API
│ │
└─────────────────┘
↑ ↓
Adapter Adapter
(Inbound) (Outbound)
// Inbound Port: definiert, was die Domain anbietet // Liegt im src/Application/ interface PlaceOrderUseCase { public function execute(PlaceOrderInput $input): PlaceOrderOutput; } // Implementierung in der Domain (kennt keine Framework-Klassen) class PlaceOrderService implements PlaceOrderUseCase { public function __construct( private OrderRepository $orders, private PaymentGateway $payment, private EventDispatcher $events, ) {} public function execute(PlaceOrderInput $input): PlaceOrderOutput { $order = new Order(...); $this->payment->charge($order); $this->orders->save($order); $this->events->dispatch(new OrderPlaced($order)); return new PlaceOrderOutput($order->id); } }
Der HTTP-Controller ist ein Adapter – er übersetzt zwischen HTTP-Request und Domain-Input:
// Inbound Adapter (Symfony Controller) // Liegt im src/Infrastructure/Http/ class PlaceOrderController { public function __construct(private PlaceOrderUseCase $useCase) {} #[Route('/orders', methods: ['POST'])] public function __invoke(Request $request): JsonResponse { $input = new PlaceOrderInput( customerId: $request->get('customer_id'), items: $request->get('items'), ); try { $output = $this->useCase->execute($input); return new JsonResponse(['order_id' => $output->orderId], 201); } catch (DomainException $e) { return new JsonResponse(['error' => $e->getMessage()], 400); } } }
Die Domain definiert, was sie braucht (Port), die Infrastruktur liefert wie (Adapter):
// Outbound Port (in der Domain) // Liegt im src/Domain/ interface PaymentGateway { public function charge(Order $order): PaymentReceipt; } // Outbound Adapter (in der Infrastructure) // Liegt im src/Infrastructure/Payment/ class StripeGateway implements PaymentGateway { public function __construct(private StripeClient $stripe) {} public function charge(Order $order): PaymentReceipt { $charge = $this->stripe->charges->create([...]); return new PaymentReceipt($charge->id); } } // Alternative für Tests class FakePaymentGateway implements PaymentGateway { public function charge(Order $order): PaymentReceipt { return new PaymentReceipt('fake-receipt'); } }
src/ ├── Domain/ # Reine Business-Logik │ ├── Order/ │ │ ├── Order.php │ │ ├── OrderId.php │ │ ├── OrderRepository.php # Interface │ │ └── PaymentGateway.php # Interface │ └── User/... ├── Application/ # Use Cases │ ├── PlaceOrderUseCase.php │ └── PlaceOrderService.php └── Infrastructure/ # Adapters ├── Http/ │ └── PlaceOrderController.php ├── Persistence/ │ └── DoctrineOrderRepository.php └── Payment/ └── StripeGateway.php
Statt: UPDATE orders SET status='paid' WHERE id=42 speicherst du: OrderPaid(orderId: 42, at: 2026-01-15) in einer Event-Store-Tabelle. Der aktuelle Zustand entsteht durch Replay aller Events:
events_table:
+----+------------+------------------+----------+
| id | aggregate | event_type | data |
+----+------------+------------------+----------+
| 1 | order:42 | OrderCreated | {...} |
| 2 | order:42 | ItemAdded | {...} |
| 3 | order:42 | ItemAdded | {...} |
| 4 | order:42 | OrderConfirmed | {...} |
| 5 | order:42 | OrderPaid | {...} |
+----+------------+------------------+----------+
abstract class AggregateRoot { private array $pendingEvents = []; private int $version = 0; protected function recordEvent(DomainEvent $event): void { $this->apply($event); $this->pendingEvents[] = $event; } public function pullEvents(): array { $events = $this->pendingEvents; $this->pendingEvents = []; return $events; } abstract protected function apply(DomainEvent $event): void; } class Order extends AggregateRoot { private OrderStatus $status; private array $items = []; public static function create(OrderId $id, CustomerId $customer): self { $order = new self(); $order->recordEvent(new OrderCreated($id, $customer)); return $order; } public function addItem(Product $product, int $quantity): void { if ($this->status !== OrderStatus::Draft) throw new ...; $this->recordEvent(new ItemAdded($product, $quantity)); } protected function apply(DomainEvent $event): void { match($event::class) { OrderCreated::class => $this->whenOrderCreated($event), ItemAdded::class => $this->whenItemAdded($event), // ... }; } private function whenOrderCreated(OrderCreated $e): void { $this->status = OrderStatus::Draft; } private function whenItemAdded(ItemAdded $e): void { $this->items[] = new OrderItem($e->product, $e->quantity); } }
Beachte die zwei Phasen: recordEvent erstellt das Event und ruft apply auf. apply ändert nur State, niemals Validierung – sonst kann beim Replay nichts schiefgehen.
Beim Laden eines Aggregates: alle Events aus dem Store holen, neuen Aggregate-Instanz erstellen, jedes Event apply'en:
class EventSourcedOrderRepository { public function __construct(private EventStore $store) {} public function find(OrderId $id): ?Order { $events = $this->store->getEvents("order:$id"); if (empty($events)) return null; $order = (new \ReflectionClass(Order::class))->newInstanceWithoutConstructor(); foreach ($events as $event) { $order->applyHistoric($event); } return $order; } public function save(Order $order): void { foreach ($order->pullEvents() as $event) { $this->store->append("order:$order->id", $event); } } }
recordEvent und apply?Du hast PHP jetzt von Sprach-Internals über Performance-Optimierung bis zu fortgeschrittenen Architektur-Patterns durchlaufen. Damit kennst du PHP an seinen Grenzen.
Guide gelesen, Recall-Fragen aus jedem Kapitel beantwortet.
Zwei Kapitel auswählen, vertiefen, eigene Implementierung versuchen.
Spezialthema umsetzen: ReactPHP-Server, eigener DI-Container oder Hexagonal Architecture in echtem Projekt.
Source-Code von Symfony, Doctrine oder EventSauce lesen – Patterns erkennen.
Du bist jetzt jenseits des offiziellen PHP-Lernpfads. Empfehlungen für Tiefenexpertise:
Dieser Guide schließt das Set ab: