``` PHP Extended-Guide
Extended-Guide · 4h · Stand 2026

PHP
an der Grenze.

Internals, Fibers, FFI, DDD & Event Sourcing – PHP über das Übliche hinaus.

Was du danach kannst Reflection und Stream-Wrapper produktiv einsetzen · mit Fibers asynchron programmieren · eigenen DI-Container schreiben · Performance gezielt profilen und optimieren · komplexe Domänen mit DDD, Hexagonal Architecture und Event Sourcing strukturieren.
PHP 8.4

Inhalt

Teil 1 · Sprach-Internals
1

Reflection-API tief

Klassen, Methoden, Attribute zur Laufzeit erkunden

15 Min
2

SPL Datenstrukturen

SplStack, SplQueue, SplPriorityQueue, SplObjectStorage

15 Min
3

Stream-Wrapper und Filter

Eigene Protokolle wie file:// schreiben

15 Min
4

Garbage Collection

Zirkuläre Referenzen, Memory-Profiling

15 Min
5

FFI - C-Code direkt aufrufen

Foreign Function Interface für Native-Libraries

15 Min
Teil 2 · Performance & Async
6

OpCache und Preloading

Code-Compilation einmal, nicht pro Request

15 Min
7

Fibers - Cooperative Concurrency

Async ohne Threading (PHP 8.1+)

15 Min
8

ReactPHP & Event-Loops

Long-Running Server, non-blocking I/O

15 Min
9

Profiling mit Blackfire

CPU- und Memory-Hotspots finden

15 Min
10

Caching-Strategien

APCu, Redis, HTTP-Cache, ESI

15 Min
Teil 3 · Architektur & Patterns
11

Dependency Injection Container

Eigener Container in 100 Zeilen

15 Min
12

Event-Dispatcher Pattern

Lose Kopplung zwischen Bounded Contexts

15 Min
13

CQRS und Command Bus

Lese- und Schreibmodelle trennen

15 Min
14

Domain-Driven Design in PHP

Value Objects, Entities, Aggregates

15 Min
15

Hexagonal Architecture

Ports und Adapter, Domain im Zentrum

15 Min
16

Event Sourcing

State als Sequenz von Events

15 Min
01

Reflection-API tief

Klassen zur Laufzeit erkunden
Frage zum Einstieg Wie weiß Symfony zur Laufzeit, welche Routen in deinem Controller stecken? Wie generiert Doctrine SQL-Schemas aus deinen Entity-Klassen? Beide nutzen Reflection – PHPs eingebauter Mechanismus, um Code-Struktur zu introspecten.

ReflectionClass: alles über eine Klasse

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[]

Instanzen ohne Konstruktor

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.

Private Properties und Methoden

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);

Performance-Implikationen

Reflection ist nicht gratis. Pro ReflectionClass-Instanz parst PHP intern viele Metadaten. Frameworks wie Symfony cachen Reflection-Ergebnisse aggressiv. Faustregel:

Use CaseOK
Boot-Zeit, einmal pro Requestja
Dependency-Injection-Container Buildja, aber cachen
In jedem Method-Callnein, das ist langsam
Hot-Path-Loopsnein, niemals
i
Attribute via Reflection Wie im Fortgeschritten-Guide gezeigt: $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.
Recall
  1. Welche zwei großen Frameworks nutzen Reflection als Kern-Mechanismus?
  2. Wie umgehst du den Konstruktor beim Instanziieren?
  3. Warum ist Reflection in Hot-Paths problematisch?
02

SPL Datenstrukturen

Stack, Queue, Heap, ObjectStorage
Frage zum Einstieg PHP-Arrays sind extrem flexibel – aber für spezielle Use Cases nicht optimal. Eine Priority Queue mit Array nachbauen kostet O(n log n) pro Insert. Die Standard PHP Library (SPL) liefert spezialisierte Datenstrukturen. Wann lohnt sich der Wechsel?

SplStack und SplQueue

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.

SplPriorityQueue

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

SplObjectStorage: Map mit Objekten als Keys

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.

SplFixedArray für Memory-Optimierung

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;
}
!
SPL nur wenn nötig Für die meisten Web-Apps reichen normale Arrays völlig. SPL lohnt sich, wenn du Performance-kritische Pfade mit großen Mengen hast oder spezielle Semantik brauchst (Priority, Object-Keys). Sonst macht es Code unnötig komplex.
Recall
  1. Was ist der Performance-Vorteil von SplQueue gegenüber array_shift?
  2. Wofür nutzt du SplObjectStorage?
  3. Wann lohnt sich SplFixedArray?
03

Stream-Wrapper und Filter

Eigene Protokolle wie file:// schreiben
Frage zum Einstieg Du kennst 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?

Was sind Stream-Wrapper?

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:

WrapperWofür
file://Lokales Dateisystem (Default)
http:// / https://HTTP-Requests
php://memoryIn-Memory Stream
php://stdin / php://stdoutStandard-IO
php://inputRaw POST-Body
data://Base64 oder URL-encoded Daten
compress.zlib://gzip-Streams

Eigenen Wrapper schreiben

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!'

Praktischer Use Case: S3-Wrapper

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) {
  // ...
}

Stream-Filter

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
}
Flysystem für Cloud-Storage Für Production: nutze league/flysystem statt eigene Stream-Wrapper zu schreiben. Es abstrahiert S3, Azure, GCS, FTP, local mit einer einheitlichen API – stabiler und besser getestet als selbst gestrickte Wrapper.
Recall
  1. Welche Methoden braucht ein Stream-Wrapper mindestens?
  2. Wozu sind Stream-Filter da?
  3. Welche Library nutzt du stattdessen für Cloud-Storage?
04

Garbage Collection

Zirkuläre Referenzen, Memory-Profiling
Frage zum Einstieg PHP räumt Speicher automatisch auf – meistens. Aber in Long-Running-Prozessen (CLI-Tools, Workers, ReactPHP-Server) sammeln sich plötzlich GB an RAM. Was ist los? Die Antwort liegt in PHPs Garbage Collection und wie sie zirkuläre Referenzen behandelt.

Reference Counting

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.

Das Problem zirkulärer 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.

Cycle Collector steuern

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();

Long-Running-Prozesse

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.

WeakReference und WeakMap

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
Recall
  1. Was ist PHPs primäre GC-Strategie?
  2. Warum reicht Reference Counting bei zirkulären Referenzen nicht?
  3. Wofür nutzt du WeakReference?
05

FFI - C-Code direkt aufrufen

Foreign Function Interface
Frage zum Einstieg Du brauchst eine C-Library – etwa für GPU-Berechnungen, Bildverarbeitung oder spezielle Algorithmen. Klassisch: PHP-Extension in C schreiben (großer Aufwand). Mit FFI seit PHP 7.4 rufst du C-Funktionen direkt aus PHP-Code auf, ohne Kompilieren.

FFI aktivieren

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

C-Funktion aufrufen

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

Header-Datei laden

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(...);

Structs und Pointer

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;
}

Praxis-Use-Cases

Use CaseBeispiel-Library
Bildverarbeitunglibvips, OpenCV
Kompressionlibzstd, brotli
Cryptolibsodium (auch nativ in PHP)
ML-Inferenzlibtorch, onnxruntime
Grafik/UISDL, GTK
System-Callslibc direkt

Performance-Trade-Off

FFI-Calls sind nicht gratis: jeder Aufruf hat Overhead durch Type-Conversion. Faustregel:

!
FFI ist nicht für Web-Apps gedacht Mit 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.
Recall
  1. Welche zwei Dinge braucht FFI::cdef?
  2. Wann lohnt sich FFI, wann nicht?
  3. Warum solltest du FFI nicht in Web-Requests nutzen?
06

OpCache und Preloading

Code-Compilation einmal, nicht pro Request
Frage zum Einstieg PHP parst und kompiliert standardmäßig deinen Code bei jedem Request neu. Bei einer Laravel-App mit hunderten Dateien sind das viele Millisekunden Overhead pro Request. OpCache und Preloading eliminieren das.

OpCache aktivieren

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.

Validate Timestamps in Production

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

OpCache-Status zur Laufzeit

$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

Preloading: noch eine Stufe weiter

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.

JIT (Just-In-Time)

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 CaseSpeedup mit JIT
Web (Database/Network-bound)~5-10%
CLI-Tools, Compute-bound~30-50%
Mandelbrot/Mathematik~2-3x
i
Preloading-Limitationen Geladene Klassen können zur Laufzeit nicht mehr geändert werden. Wenn dein Framework Klassen dynamisch erweitert (z.B. Symfony Cache-Container), führt das zu Konflikten. Reload bei Deployment ist Pflicht.
Recall
  1. Was cached OpCache genau?
  2. Was ist der Unterschied zwischen validate_timestamps=1 und 0?
  3. Wann lohnt sich JIT besonders?
07

Fibers - Cooperative Concurrency

Async ohne Threading (PHP 8.1+)
Frage zum Einstieg PHP ist klassisch single-threaded und blockierend. Aber moderne Apps brauchen oft parallel laufende HTTP-Calls, gleichzeitige DB-Queries, Streaming. Fibers in PHP 8.1 bringen kooperative Concurrency – die Basis für moderne async Libraries.

Was ist eine Fiber?

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

Async-I/O mit Fibers

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 3.0 mit Fibers

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.

Fibers vs. echte Threads

AspektFibersThreads (parallel)
ParallelitätNein, kooperativJa, true parallel
Memory pro Unit~8 KB~MB
Context-SwitchSehr schnellOS-Overhead
Shared StateDirekt zugreifbarLocking nötig
Use CaseI/O-boundCPU-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.

Symfony 7 mit Fibers Symfony 7+ nutzt intern Fibers für HttpClient (Multi-Request) und Messenger (parallele Message-Verarbeitung). Auch ohne explizit Fibers zu schreiben profitierst du dadurch.
Recall
  1. Was ist der Hauptunterschied zwischen Fiber und Thread?
  2. Wann gibt eine Fiber Kontrolle ab?
  3. Wofür braucht es zusätzlich eine Event-Loop wie in Amp?
08

ReactPHP & Event-Loops

Long-Running Server, non-blocking I/O
Frage zum Einstieg PHP klassisch: ein Request, ein Prozess, Sterben am Ende. Aber WebSocket-Server, Pub/Sub-Workers oder Streaming-APIs brauchen Long-Running-Prozesse. ReactPHP bringt das Event-Loop-Modell (wie Node.js) in die PHP-Welt.

Event-Loop-Grundprinzip

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();

HTTP-Server mit ReactPHP

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.

Streams und Pipes

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";
});

WebSocket-Server mit Ratchet

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();

Wann ReactPHP, wann klassisch?

Use CaseWähle
Klassische CRUD-Appphp-fpm + Symfony/Laravel
WebSocket-ServerReactPHP + Ratchet
Pub/Sub-WorkerReactPHP oder Symfony Messenger
Streaming-APIReactPHP
Tausende kleine Microservice-CallsReactPHP (Connection-Reuse)
!
Blockierender Code killt die Event-Loop Ein einziger 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.
Recall
  1. Was ist der Hauptunterschied zwischen php-fpm und ReactPHP?
  2. Welches Pattern wäre für einen WebSocket-Chat geeignet?
  3. Warum sind blockierende Calls in ReactPHP tabu?
09

Profiling mit Blackfire

CPU- und Memory-Hotspots finden
Frage zum Einstieg Deine App ist langsam – aber wo genau? Der Controller? Die DB-Query? Die View-Rendering? Raten endet im Polieren der falschen Stellen. Blackfire und Xdebug Profiler zeigen dir Call-Graphs mit echten Messzahlen.

Xdebug-Profiler (gratis)

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 (professionell)

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.

Im Code instrumentieren

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);

Typische Findings

PatternWie oftLösung
N+1 Queryfast jede AppEager-Loading
JSON-Encoding mehrfachoftCache
Twig ohne Cachehäufigopcache.preload
Doctrine: gleiche Entity 100x geladenhäufigIdentity Map
Composer Autoload langsamoftcomposer dump-autoload -o

Memory-Profiling

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.

Misst, was zählt – nicht alles Optimiere nicht blind nach Profiler-Ausgabe. Eine Funktion, die 30% der Zeit braucht, aber nur einmal pro Tag läuft, ist weniger wichtig als eine, die 5% braucht, aber 1000 mal pro Minute. Schau dir absolute Zahlen × Frequenz an, nicht nur Prozent.
Recall
  1. Welcher Profiler ist gratis dabei?
  2. Was ist ein N+1 Query und warum häufig?
  3. Wofür ist memory_get_peak_usage()?
10

Caching-Strategien

APCu, Redis, HTTP-Cache, ESI
Frage zum Einstieg Eine Datenbank-Query, die 200ms dauert. Eine Berechnung, die immer wieder dasselbe Ergebnis liefert. Ein externer API-Call, der teuer ist. Caching ist meist die größte Performance-Optimierung – aber wo cachen und wie invalidieren?

Die Caching-Schichten

SchichtLatenzTech
OpCache (kompilierter Code)~0mseingebaut
Request-Cache (in-memory)~0msArrays
APCu (shared memory)~0.01msAPCu Extension
Redis (lokal)~0.5msRedis-Server
Redis (Netzwerk)~1-5msremote Redis
Datenbank-Query~5-100msMySQL/Postgres
Externe API~50-1000msHTTP

Jede Schicht hinunter ist 10-100x langsamer. Die Kunst: so weit oben wie möglich cachen, ohne stale Daten zu liefern.

PSR-6 / PSR-16 Cache

Die PHP-FIG hat Cache-Interfaces standardisiert. Sym­fo­ny 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 für Request-übergreifende Caches

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 für verteilte Caches

$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

HTTP-Cache und ESI

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.

Cache-Invalidierung

Das schwerste Problem. Strategien:

i
"Es gibt zwei schwere Probleme in der Informatik..." Cache-Invalidierung und Naming. Beide passieren immer. Akzeptiere, dass dein Cache manchmal stale ist, baue Monitoring ein, und plane Strategien für Invalidierung von Anfang an mit.
Recall
  1. Wann nutzt du APCu, wann Redis?
  2. Was macht ESI in Varnish?
  3. Welche Strategien für Cache-Invalidierung kennst du?
11

Dependency Injection Container

Eigener Container in 100 Zeilen
Frage zum Einstieg Symfony Container, Laravel Container, PHP-DI – alle lösen dasselbe Problem: Klassen automatisch instanziieren mit den richtigen Abhängigkeiten. Wie funktioniert das intern? Ein eigener DI-Container in 100 Zeilen Code zeigt das Prinzip.

Was ein DI-Container tut

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);

Minimaler Container

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);
  }
}

Bindings konfigurieren

$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

Production-Container-Features

Echte Container haben noch viel mehr:

Nutze einen ausgereiften Container Für eigene Projekte: PHP-DI oder Symfony DependencyInjection. Eigene Container bauen ist lehrreich, aber Production-Container haben Edge Cases und Performance-Optimierung, die du selbst nicht erreichst.
Recall
  1. Was ist die Hauptaufgabe eines DI-Containers?
  2. Welcher Reflection-Mechanismus ist Basis dafür?
  3. Was machen "Tags" in Symfony's Container?
12

Event-Dispatcher Pattern

Lose Kopplung zwischen Bounded Contexts
Frage zum Einstieg Eine Bestellung wird platziert. Folge: E-Mail an Kunde, Lagerplatz reservieren, Statistik aktualisieren, Webhook an Partner senden. Sollst du das alles in OrderService::place() reincoden? Das Event-Dispatcher-Pattern trennt das sauber.

Das Problem direkter Aufrufe

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.

Mit Event-Dispatcher

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 EventDispatcher

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.

Synchron vs. Asynchron

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.

i
Domain Events vs. Application Events Domain Events (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.
Recall
  1. Was ist das Hauptproblem von direkten Service-Aufrufen ohne Events?
  2. Wann nutzt du sync, wann async Event-Handling?
  3. Was ist der Unterschied zwischen Domain Event und Application Event?
13

CQRS und Command Bus

Lese- und Schreibmodelle trennen
Frage zum Einstieg Deine User-Liste braucht 12 verschiedene Filter, Pagination, Aggregate. Deine User-Updates haben komplexe Business-Regeln. Beide in einer Klasse abzubilden wird unhandlich. CQRS trennt Reads und Writes radikal.

Was ist CQRS?

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,
  ) {}
}

Command Bus

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);
  }
}

Query Bus

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 als Bus

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);
  }
}

Wann lohnt sich CQRS?

Use CaseCQRS sinnvoll?
Simple CRUD-Appnein, Overkill
Komplexe Business-Logik bei Writesja
Lese-Performance ist kritischja
Sehr unterschiedliche Read- und Write-Modelleja
Mehrere Teams an einem Bounded Contextja
!
CQRS ist keine Silver Bullet Es bringt mehr Code, mehr Klassen, mehr Indirection. Bei einer Standard-CRUD-App ist es Overkill. Wende es gezielt an: nur dort wo Komplexität es rechtfertigt. Innerhalb einer App kannst du auch nur einen Bounded Context mit CQRS bauen, den Rest mit klassischen Services.
Recall
  1. Was ist der Hauptunterschied zwischen Command und Query?
  2. Warum dürfen Query-Handler "denormalisiert" lesen?
  3. Wann ist CQRS Overkill?
14

Domain-Driven Design in PHP

Value Objects, Entities, Aggregates
Frage zum Einstieg Anämische Entities sind in PHP weit verbreitet: Klassen mit nur Properties und Getter/Setter, alle Business-Logik in Service-Klassen verstreut. Domain-Driven Design dreht das um – Logik lebt im Domain-Modell, nicht außerhalb.

Value Objects

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.

Entities mit Geschäftslogik

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.

Aggregates und Aggregate Root

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);

Repositories

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();
  }
}
i
DDD ist mehr als nur Klassen-Struktur DDD beinhaltet auch strategisches Design: Bounded Contexts, Ubiquitous Language, Context Maps. Die taktischen Patterns (Value Object, Entity, Aggregate) sind nur das Werkzeug für die strategische Trennung von Geschäftsbereichen.
Recall
  1. Was unterscheidet Value Object von Entity?
  2. Was ist die Regel für Zugriff auf Aggregate-Member?
  3. Wofür ist ein Repository da?
15

Hexagonal Architecture

Ports und Adapter, Domain im Zentrum
Frage zum Einstieg Deine App hängt überall an Symfony, an Doctrine, an Redis. Refactor auf ein anderes Framework? Praktisch unmöglich. Tests, die echte DB brauchen? Langsam. Hexagonal Architecture (auch "Ports und Adapter") löst das mit konsequenter Inversion of Control.

Das Prinzip

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: was die Domain anbietet

// 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);
  }
}

Inbound Adapter: HTTP, CLI, etc.

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);
    }
  }
}

Outbound Port und Adapter

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');
  }
}

Vorteile

Verzeichnis-Struktur

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
!
Nicht jede App braucht Hexagonal Die Architektur kostet: mehr Interfaces, mehr Klassen, mehr Indirection. Für CRUD-Apps Overkill. Lohnt sich, wenn die Geschäftslogik komplex und langlebig ist, Tests wichtig sind und die App über Jahre weiterleben soll.
Recall
  1. Wo lebt die Geschäftslogik in Hexagonal Architecture?
  2. Was ist der Unterschied zwischen Port und Adapter?
  3. Warum kann man die Domain ohne Datenbank testen?
16

Event Sourcing

State als Sequenz von Events
Frage zum Einstieg Klassische DB speichert den aktuellen Zustand: "Bestellung X hat 3 Items, Status 'paid'". Wer hat wann was geändert? Verloren. Event Sourcing dreht das um: speichere die Events, nicht den Zustand. Der aktuelle Zustand ist die Summe aller Events.

Das Grundprinzip

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        | {...}    |
+----+------------+------------------+----------+

Aggregate mit Events

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.

Rekonstruieren aus Events

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);
    }
  }
}

Vorteile

Nachteile

i
Libraries für Event Sourcing in PHP EventSauce ist die populärste Library im PHP-Ökosystem – ausgereift, mit Snapshot-Support, Upcasting für Schema-Evolution und CQRS-Integration. Broadway und Prooph sind ältere Alternativen.
Recall
  1. Was speichert ein Event Store statt aktuellem State?
  2. Warum trennt man recordEvent und apply?
  3. Welche Library nutzt man typischerweise in PHP?

Wie es weitergeht

Du hast PHP jetzt von Sprach-Internals über Performance-Optimierung bis zu fortgeschrittenen Architektur-Patterns durchlaufen. Damit kennst du PHP an seinen Grenzen.

Spaced-Repetition-Plan

Heute

Guide gelesen, Recall-Fragen aus jedem Kapitel beantwortet.

+7 Tage

Zwei Kapitel auswählen, vertiefen, eigene Implementierung versuchen.

+30 Tage

Spezialthema umsetzen: ReactPHP-Server, eigener DI-Container oder Hexagonal Architecture in echtem Projekt.

+90 Tage

Source-Code von Symfony, Doctrine oder EventSauce lesen – Patterns erkennen.

Was als nächstes lernen

Du bist jetzt jenseits des offiziellen PHP-Lernpfads. Empfehlungen für Tiefenexpertise:

Begleitmaterial

Dieser Guide schließt das Set ab:

```