``` PHP Fortgeschritten-Guide
Fortgeschritten-Guide · 3h · Stand 2026

PHP
tiefer.

Von Interfaces und Generators bis Tests – Production-PHP, das hält.

Was du danach kannst OOP-Patterns idiomatisch einsetzen · funktional mit Closures und Generators arbeiten · Attribute für saubere Metadaten nutzen · Datenbanken mit PDO sicher anbinden · APIs konsumieren · mit PHPStan und PHPUnit produktionsreifen Code schreiben.
PHP 8.4

Inhalt

Teil 1 · OOP-Patterns
1

Interfaces und abstrakte Klassen

Kontrakte definieren, Implementierungen trennen

15 Min
2

Traits

Code-Reuse jenseits klassischer Vererbung

15 Min
3

Enums richtig nutzen

Backed Enums, Methods, Pattern-Matching

15 Min
4

Attribute (PHP 8)

Metadaten typensicher statt Annotations

15 Min
Teil 2 · Funktional & Generators
5

Closures und Arrow Functions

First-Class Callables, use-Capture

15 Min
6

Higher-Order Functions

array_map, array_filter, array_reduce in der Praxis

15 Min
7

Iterators und Generators

Lazy Evaluation mit yield

15 Min
8

Generics via PHPDoc

Templates ohne native Generics

15 Min
Teil 3 · Production-Tools
9

PDO und sichere Datenbanken

Prepared Statements, Transaktionen

15 Min
10

HTTP-Requests mit Guzzle

APIs konsumieren, async Requests

15 Min
11

PHPStan und Static Analysis

Bugs vor der Ausführung finden

15 Min
12

Testen mit PHPUnit

Unit-Tests, Mocks, Data Providers

15 Min
01

Interfaces und abstrakte Klassen

Kontrakte definieren, Implementierungen trennen
Frage zum Einstieg Du hast drei verschiedene Logger – Datei, Datenbank, Sentry. Sie sollen austauschbar sein, ohne dass der aufrufende Code etwas merkt. Wie zwingst du sie zu einer gemeinsamen API, ohne sie zu Geschwistern in einer Klassen-Hierarchie zu machen? Interfaces sind die Antwort.

Interface als reiner Kontrakt

Ein Interface definiert nur, was Methoden tun, nicht wie. Klassen, die das Interface implementieren, müssen alle Methoden bereitstellen:

interface LoggerInterface {
  public function info(string $message): void;
  public function error(string $message, array $context = []): void;
}

class FileLogger implements LoggerInterface {
  public function __construct(private readonly string $path) {}

  public function info(string $message): void {
    file_put_contents($this->path, "[INFO] $message\n", FILE_APPEND);
  }

  public function error(string $message, array $context = []): void {
    file_put_contents($this->path, "[ERROR] $message\n", FILE_APPEND);
  }
}

Der Gewinn: jede Funktion kann den Typ LoggerInterface erwarten und akzeptiert dadurch jede Implementierung – ohne deren Klasse zu kennen.

Dependency Injection in Aktion

class OrderService {
  public function __construct(
    private readonly LoggerInterface $logger
  ) {}

  public function place(Order $order): void {
    $this->logger->info("Order $order->id placed");
    // ...
  }
}

// In Tests: Fake-Logger reinreichen
// In Production: FileLogger oder SentryLogger
$service = new OrderService(new FileLogger('/var/log/app.log'));

Abstrakte Klassen

Wenn du gemeinsamen Code teilen willst, aber bestimmte Methoden offen lassen, nimmst du eine abstrakte Klasse. Sie kann Properties, fertige Methoden und abstrakte Methoden mischen:

abstract class Notification {
  public function __construct(protected readonly string $recipient) {}

  // Fertige Methode
  public function send(string $message): void {
    $formatted = $this->format($message);
    $this->deliver($formatted);
  }

  // Muss von Subklassen implementiert werden
  abstract protected function format(string $msg): string;
  abstract protected function deliver(string $msg): void;
}

Interface oder abstrakte Klasse?

Use CaseWähle
Reiner KontraktInterface
Mehrere "Verträge" erfüllenInterface (mehrfach implementierbar)
Gemeinsame Code-Basis teilenAbstrakte Klasse
Template Method PatternAbstrakte Klasse
PSR-Interfaces nutzen Die PHP-FIG hat Standard-Interfaces für Logger, Container, HTTP und mehr definiert: Psr\Log\LoggerInterface, Psr\Container\ContainerInterface. Wenn du deine Klassen daran ausrichtest, passen sie zu jedem Framework.
Recall
  1. Was ist der Hauptunterschied zwischen Interface und abstrakter Klasse?
  2. Wie viele Interfaces darf eine Klasse implementieren?
  3. Was ist Dependency Injection in einem Satz?
02

Traits

Code-Reuse jenseits klassischer Vererbung
Frage zum Einstieg PHP unterstützt nur Einfach-Vererbung – eine Klasse hat genau eine Eltern-Klasse. Aber was, wenn zwei nicht verwandte Klassen denselben Code brauchen? Logging-Methoden in User und Order kopieren? Traits lösen das eleganter.

Trait: wiederverwendbarer Code-Block

Ein Trait ist wie eine Klasse, aber du erstellst keine Instanz davon. Stattdessen wirst du in andere Klassen "einkopiert". Mehrere Traits pro Klasse sind erlaubt:

trait Timestampable {
  private ?\DateTimeImmutable $createdAt = null;
  private ?\DateTimeImmutable $updatedAt = null;

  public function touch(): void {
    $this->updatedAt = new \DateTimeImmutable();
    $this->createdAt ??= $this->updatedAt;
  }

  public function getCreatedAt(): ?\DateTimeImmutable {
    return $this->createdAt;
  }
}

class Article {
  use Timestampable;

  public function __construct(public string $title) {
    $this->touch();
  }
}

class Comment {
  use Timestampable;       // gleiche Methoden, ohne Doppelung
}

Mehrere Traits kombinieren

trait SoftDeletable {
  private ?\DateTimeImmutable $deletedAt = null;

  public function delete(): void {
    $this->deletedAt = new \DateTimeImmutable();
  }

  public function isDeleted(): bool {
    return $this->deletedAt !== null;
  }
}

class Article {
  use Timestampable, SoftDeletable;   // beide Sets von Methoden
}

Konflikt-Auflösung

Wenn zwei Traits Methoden mit gleichem Namen haben, gibt es einen Konflikt. PHP zwingt dich, explizit zu wählen:

trait A { public function hello(): string { return 'A'; } }
trait B { public function hello(): string { return 'B'; } }

class X {
  use A, B {
    A::hello insteadof B;       // nimm A's hello, ignoriere B's
    B::hello as helloFromB;     // B's hello als 'helloFromB' verfügbar
  }
}
!
Traits sind kein Multi-Inheritance-Ersatz Traits machen Code-Reuse, keinen Typ-Polymorphismus. Eine Klasse mit Trait ist nicht "vom Typ Trait" – nutze dafür Interfaces. Faustregel: Trait für Implementierung, Interface für Vertrag.

Wann Trait, wann Komposition?

Traits sind verlockend, aber sie binden Code statisch in Klassen ein. Eine moderne Alternative ist Komposition: ein Service-Objekt als Property, das die Logik kapselt. Faustregel:

Recall
  1. Was ist der Unterschied zwischen Trait und abstrakter Klasse?
  2. Wie löst du Methoden-Konflikte zwischen zwei Traits?
  3. Wann nutzt du Komposition statt Trait?
03

Enums richtig nutzen

Backed Enums, Methods, Pattern-Matching
Frage zum Einstieg Früher waren Status-Werte oft Strings ("draft", "published") oder Klassen-Konstanten – fehleranfällig und ohne Typ-Sicherheit. PHP 8.1 hat echte Enums gebracht. Wie nutzt du sie idiomatisch?

Einfache Enums

enum Status {
  case Draft;
  case Published;
  case Archived;
}

$status = Status::Draft;

if ($status === Status::Draft) {
  echo 'noch nicht veröffentlicht';
}

Die Cases sind Singletons – jede Verwendung von Status::Draft ist dasselbe Objekt. Vergleich mit === funktioniert wie erwartet.

Backed Enums (mit Wert)

Für Serialisierung – etwa in Datenbank oder JSON – brauchst du Backed Enums mit konkretem String oder Int als Backing-Value:

enum Status: string {
  case Draft = 'draft';
  case Published = 'published';
  case Archived = 'archived';
}

// Wert auslesen
Status::Draft->value;        // 'draft'

// Aus Wert zurückbauen
Status::from('draft');      // Status::Draft
Status::tryFrom('unknown'); // null (statt Exception)

Methoden in Enums

Enums dürfen Methoden haben. Damit packst du Verhalten direkt zum Wert, statt es in Helper-Funktionen zu verstecken:

enum Status: string {
  case Draft = 'draft';
  case Published = 'published';
  case Archived = 'archived';

  public function label(): string {
    return match($this) {
      self::Draft => 'Entwurf',
      self::Published => 'Veröffentlicht',
      self::Archived => 'Archiv',
    };
  }

  public function isPublic(): bool {
    return $this === self::Published;
  }
}

Status::Draft->label();      // 'Entwurf'
Status::Draft->isPublic();   // false

Alle Cases iterieren

foreach (Status::cases() as $status) {
  echo $status->label() . "\n";
}
// Entwurf
// Veröffentlicht
// Archiv

Praktisch für Dropdowns, Validierung oder Migration zwischen Status.

Statt String-Constants immer Enum Wenn du heute const STATUS_DRAFT = 'draft' schreibst, ist es fast immer besser, ein Enum zu nehmen. Typ-Sicherheit, Methoden direkt am Wert, IDE-Vervollständigung, exhaustive match-Checks.
Recall
  1. Was ist der Unterschied zwischen einfachem Enum und Backed Enum?
  2. Wie konvertierst du einen String sicher zu einem Enum-Case?
  3. Was gibt Status::cases() zurück?
04

Attribute (PHP 8)

Metadaten typensicher statt Annotations
Frage zum Einstieg Bevor PHP 8 nutzten Frameworks wie Symfony und Doctrine Doc-Block-Annotations für Metadaten: /** @Route("/users") */. Kommentare, die geparst werden – brüchig, ohne Typ-Check. PHP 8 brachte native Attribute. Wie schreibst und nutzt du sie?

Attribut definieren

Ein Attribut ist eine Klasse, die mit #[Attribute] markiert ist. Sie kann Konstruktor-Parameter haben wie jede andere Klasse:

#[\Attribute(\Attribute::TARGET_METHOD)]
class Route {
  public function __construct(
    public readonly string $path,
    public readonly string $method = 'GET',
  ) {}
}

Das TARGET_METHOD sagt: dieses Attribut darf nur auf Methoden. Andere Optionen: TARGET_CLASS, TARGET_PROPERTY, TARGET_PARAMETER.

Attribut anwenden

class UserController {
  #[Route('/users')]
  public function list(): Response { /* ... */ }

  #[Route('/users/{id}', method: 'GET')]
  public function show(int $id): Response { /* ... */ }

  #[Route('/users', method: 'POST')]
  public function create(): Response { /* ... */ }
}

Attribute zur Laufzeit auslesen

Mit der Reflection-API liest du Attribute aus Klassen, Methoden oder Properties aus – das ist die Grundlage für Frameworks wie Symfony, Doctrine oder ORM-Libraries:

$reflection = new \ReflectionClass(UserController::class);

foreach ($reflection->getMethods() as $method) {
  foreach ($method->getAttributes(Route::class) as $attr) {
    $route = $attr->newInstance();      // Route-Instanz
    echo "$route->method $route->path → " . $method->name . "\n";
  }
}
// Output:
// GET /users → list
// GET /users/{id} → show
// POST /users → create

Typische Anwendungsfälle

FrameworkBeispiel-Attribute
Symfony#[Route], #[AsCommand]
Doctrine#[ORM\Entity], #[ORM\Column]
PHPUnit#[DataProvider], #[Test]
Validator#[Assert\NotBlank]
i
Attribute statt Doc-Block-Annotations Alter Code mit /** @Route("/users") */ funktioniert noch, aber das Doctrine/Symfony-Ökosystem migriert auf native Attribute. Bei Neuentwicklungen: immer Attribute, nie Annotations.
Recall
  1. Was ist der Vorteil von Attributen gegenüber Doc-Block-Annotations?
  2. Wie liest du Attribute zur Laufzeit aus?
  3. Welche Frameworks nutzen Attribute intensiv?
05

Closures und Arrow Functions

First-Class Callables, use-Capture
Frage zum Einstieg Funktionen als Werte – das ist seit Jahren in JavaScript Standard. Auch PHP kann das, mit Closures, Arrow Functions und seit PHP 8.1 First-Class Callable Syntax. Wann nutzt du was?

Closures: anonyme Funktionen

Eine Closure ist eine Funktion ohne Namen, die in einer Variable lebt. Du übergibst sie als Argument oder gibst sie zurück:

$double = function(int $x): int {
  return $x * 2;
};

echo $double(5);                // 10

// Als Argument an array_map
$numbers = [1, 2, 3];
$doubled = array_map($double, $numbers); // [2, 4, 6]

Variablen aus dem Kontext: use

Im Gegensatz zu normalen Funktionen haben Closures Zugriff auf den umgebenden Scope – aber nur, wenn du es explizit mit use erlaubst:

$prefix = 'Mr. ';

$greet = function(string $name) use ($prefix): string {
  return $prefix . $name;
};

echo $greet('Marek');            // 'Mr. Marek'

// use kopiert standardmäßig: spätere Änderung wirkt nicht
$prefix = 'Dr. ';
echo $greet('Marek');            // immer noch 'Mr. Marek'

// use mit Referenz: spätere Änderung wirkt
$counter = 0;
$increment = function() use (&$counter): void {
  $counter++;
};
$increment();
$increment();
echo $counter;                  // 2

Arrow Functions: kompakt

Für einzeilige Closures gibt es seit PHP 7.4 die kürzere Arrow Function Syntax. Sie capturet Variablen automatisch (kein use nötig):

$multiplier = 3;
$multiply = fn(int $x): int => $x * $multiplier;

echo $multiply(5);              // 15

// Praktisch in array_map / array_filter
$names = ['marek', 'anna', 'tom'];
$upper = array_map(fn($n) => strtoupper($n), $names);

First-Class Callable Syntax

Seit PHP 8.1 kannst du jede Funktion oder Methode als Closure referenzieren – ohne sie aufzurufen:

// Globale Funktion
$upper = strtoupper(...);
$upper('hallo');                 // 'HALLO'

// Methode
$user = new User('Marek');
$greet = $user->greet(...);
$greet();                          // 'Hallo, Marek'

// Static Methode
$square = Math::square(...);
$square(5);                       // 25

// Direkt an array_map übergeben
$upper = array_map(strtoupper(...), $names);
Arrow für einfach, Closure für komplex Faustregel: Arrow Function (fn) wenn der Body ein einzelner Ausdruck ist. Volle Closure (function) wenn du mehrere Statements brauchst oder explizites Variablen-Capturing willst.
Recall
  1. Was ist der Unterschied zwischen use ($x) und use (&$x)?
  2. Was capturen Arrow Functions automatisch?
  3. Wofür nutzt du First-Class Callable Syntax strtoupper(...)?
06

Higher-Order Functions

array_map, array_filter, array_reduce in der Praxis
Frage zum Einstieg Du willst aus einer Liste von Bestellungen die Beträge aller bezahlten zusammenrechnen. Klassisch mit foreach, Zwischenvariable, Bedingung – fünf Zeilen. Mit Higher-Order Functions wird daraus ein lesbarer Einzeiler. Wie?

array_map: transformieren

array_map wendet eine Funktion auf jedes Element an und gibt ein neues Array zurück:

$prices = [10, 25, 99];

$withTax = array_map(fn($p) => $p * 1.19, $prices);
// [11.9, 29.75, 117.81]

// Objekte transformieren
$users = [new User('Marek'), new User('Anna')];
$names = array_map(fn(User $u) => $u->name, $users);
// ['Marek', 'Anna']

array_filter: filtern

array_filter behält nur Elemente, für die die Funktion true zurückgibt:

$numbers = [1, 2, 3, 4, 5, 6];

$even = array_filter($numbers, fn($n) => $n % 2 === 0);
// [2, 4, 6]

// Achtung: Keys werden beibehalten!
// $even ist [1 => 2, 3 => 4, 5 => 6]

// Re-indexen mit array_values
$even = array_values(array_filter($numbers, fn($n) => $n % 2 === 0));
// [2, 4, 6] mit Keys 0, 1, 2

array_reduce: zusammenfassen

array_reduce reduziert ein Array zu einem einzelnen Wert. Der Akkumulator startet mit einem Initialwert und wird mit jedem Element aktualisiert:

$prices = [10, 25, 99];

$total = array_reduce(
  $prices,
  fn(float $sum, int $price) => $sum + $price,
  0.0   // Initialwert
);
// 134.0

Verkettung in der Praxis

Das volle Potenzial entfaltet sich, wenn du mehrere Funktionen kombinierst – z.B. Beträge der bezahlten Bestellungen aufsummieren:

$paidTotal = array_reduce(
  array_filter($orders, fn($o) => $o->status === Status::Paid),
  fn($sum, $o) => $sum + $o->amount,
  0.0
);

// Mit Zwischen-Variablen leichter zu lesen
$paidOrders = array_filter($orders, fn($o) => $o->isPaid());
$amounts = array_map(fn($o) => $o->amount, $paidOrders);
$total = array_sum($amounts);

Letzteres ist oft lesbarer als verschachtelte Aufrufe – nicht immer ist die kompakteste Version die beste.

i
illuminate/collections für komplexere Fälle Wenn du oft mit verketteten Operationen arbeitest, lohnt sich Laravels Collection-Library (auch standalone nutzbar): collect($users)->filter(...)->map(...)->sum(). Lesbarer als verschachtelte PHP-Standardfunktionen.
Recall
  1. Was ist die Reihenfolge der Argumente bei array_map vs. array_filter?
  2. Welches Detail bei array_filter überrascht oft?
  3. Wann ist eine verschachtelte Verkettung weniger lesbar als Zwischenvariablen?
07

Iterators und Generators

Lazy Evaluation mit yield
Frage zum Einstieg Du sollst die Zeilen einer 5-GB-Logdatei verarbeiten. Komplett ins Array laden? Speicher reicht nicht. Klassische File-Funktionen mit Schleife? Funktioniert, aber Code wird unhandlich, wenn er weiterverarbeitet werden soll. Generators lösen das elegant.

yield: Werte häppchenweise produzieren

Ein Generator ist eine Funktion, die Werte mit yield ausgibt statt mit return. Sie pausiert zwischen den Werten – Speicher bleibt frei:

function readLines(string $path): \Generator {
  $handle = fopen($path, 'r');
  while (($line = fgets($handle)) !== false) {
    yield rtrim($line);
  }
  fclose($handle);
}

// Aufruf liefert sofort einen Generator – Datei wird noch nicht gelesen
$lines = readLines('/var/log/huge.log');

// Erst die Iteration liest tatsächlich
foreach ($lines as $line) {
  if (str_contains($line, 'ERROR')) {
    echo $line;
  }
}

Der Speicher-Vorteil: zu jedem Zeitpunkt ist nur eine Zeile geladen, egal wie groß die Datei ist.

yield with key

function readCsv(string $path): \Generator {
  $handle = fopen($path, 'r');
  $headers = fgetcsv($handle);
  $rowNumber = 0;

  while (($row = fgetcsv($handle)) !== false) {
    yield $rowNumber++ => array_combine($headers, $row);
  }
  fclose($handle);
}

foreach (readCsv('users.csv') as $num => $row) {
  echo "Zeile $num: " . $row['name'] . "\n";
}

Unendliche Sequenzen

Da Generators lazy sind, kannst du theoretisch unendliche Sequenzen erzeugen – solange du sie nicht komplett auswertest:

function fibonacci(): \Generator {
  $a = 0; $b = 1;
  while (true) {
    yield $a;
    [$a, $b] = [$b, $a + $b];
  }
}

// Erste 10 Fibonacci-Zahlen
$count = 0;
foreach (fibonacci() as $n) {
  echo $n . ' ';
  if (++$count >= 10) break;
}
// 0 1 1 2 3 5 8 13 21 34

yield from: Generators verschachteln

Mit yield from delegierst du an einen anderen Generator – nützlich, um Logik zu kapseln:

function range1to3(): \Generator {
  yield 1;
  yield 2;
  yield 3;
}

function range1to6(): \Generator {
  yield from range1to3();   // gibt 1, 2, 3
  yield 4;
  yield 5;
  yield 6;
}
!
Generator nur einmal durchlaufen Generatoren sind nicht rewindable. Wenn du sie zweimal mit foreach durchläufst, ist der zweite Durchlauf leer. Brauchst du Mehrfach-Zugriff, konvertiere mit iterator_to_array($gen) zu einem Array.
Recall
  1. Welcher Vorteil von Generators bei großen Datenmengen?
  2. Was passiert, wenn du einen Generator zweimal mit foreach durchläufst?
  3. Wofür ist yield from da?
08

Generics via PHPDoc

Templates ohne native Generics
Frage zum Einstieg PHP hat keine nativen Generics wie TypeScript oder Java. Wenn du eine Collection-Klasse schreibst, weiß die IDE nicht, ob da User oder Order drin sind – jeder Zugriff wird zu mixed. Die Lösung: PHPDoc-Generics mit PHPStan oder Psalm.

Das Problem ohne Generics

class Collection {
  private array $items = [];

  public function add(mixed $item): void {
    $this->items[] = $item;
  }

  public function first(): mixed {
    return $this->items[0] ?? null;
  }
}

$users = new Collection();
$users->add(new User('Marek'));

$user = $users->first();         // IDE: mixed, keine Autocompletion
$user->name;                       // keine Hilfe von IDE oder PHPStan

Generics in PHPDoc

Mit PHPDoc-Annotationen kannst du Templates definieren. PHPStan und Psalm verstehen sie und prüfen Typen statisch:

/**
 * @template T
 */
class Collection {
  /** @var array<T> */
  private array $items = [];

  /**
   * @param T $item
   */
  public function add(mixed $item): void {
    $this->items[] = $item;
  }

  /**
   * @return T|null
   */
  public function first(): mixed {
    return $this->items[0] ?? null;
  }
}

Konkreten Typ angeben

Bei der Verwendung gibst du den konkreten Typ als PHPDoc-Annotation an. PHPStan ersetzt dann T intern:

/** @var Collection<User> $users */
$users = new Collection();
$users->add(new User('Marek'));

$user = $users->first();         // PHPStan weiß: User|null
$user->name;                       // OK, IDE und PHPStan kennen User

$users->add('string');            // PHPStan-Fehler: erwartet User, nicht string

Constraints und Bounds

/**
 * @template T of \Throwable
 */
class ErrorCollection {
  /** @var array<T> */
  private array $errors = [];

  /**
   * @param T $error
   */
  public function add(\Throwable $error): void {
    $this->errors[] = $error;
  }
}

// Erlaubt nur Throwables und Subklassen
/** @var ErrorCollection<\RuntimeException> $errors */
$errors = new ErrorCollection();

array-shape für strukturierte Arrays

PHP-Arrays sind oft "halb-Objekte" mit festen Keys. PHPStan kennt Array-Shapes:

/**
 * @return array{name: string, age: int, email: string|null}
 */
function getUser(int $id): array {
  return ['name' => 'Marek', 'age' => 34, 'email' => null];
}

$user = getUser(42);
echo $user['name'];                // PHPStan weiß: string
echo $user['phone'];               // PHPStan-Fehler: Key existiert nicht
i
Native Generics kommen vielleicht Es gibt seit Jahren Diskussionen um native Generics in PHP. Ein RFC ist mehrfach gescheitert wegen Implementierungs-Komplexität (Runtime-Type-Erasure vs. Reified). Bis dahin sind PHPDoc-Generics mit PHPStan/Psalm der Standard.
Recall
  1. Welche Tools verstehen PHPDoc-Generics?
  2. Wie deklarierst du eine generische Klasse?
  3. Wofür ist array{name: string, age: int}?
09

PDO und sichere Datenbanken

Prepared Statements, Transaktionen
Frage zum Einstieg SQL-Injection ist seit 20 Jahren die häufigste Web-Sicherheitslücke. Sie ist trivial zu verhindern – mit Prepared Statements. PDO ist PHPs eingebaute, datenbank-agnostische Schnittstelle dafür. Wie nutzt du sie richtig?

PDO-Verbindung aufbauen

PDO unterstützt MySQL, PostgreSQL, SQLite und mehr über dieselbe API. Der DSN-String unterscheidet sich, der Rest ist gleich:

$dsn = 'mysql:host=localhost;dbname=myapp;charset=utf8mb4';
$pdo = new \PDO($dsn, 'username', 'password', [
  PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
  PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  PDO::ATTR_EMULATE_PREPARES => false,
]);

Die drei Options sind Pflicht in jedem Projekt: Exceptions bei Fehlern, assoziative Arrays beim Fetch, echte Prepared Statements (statt emulierter Client-Side).

Prepared Statements (immer!)

Niemals User-Input direkt in SQL-Strings konkatenieren. Stattdessen: Placeholder mit ? oder Named Parameters:

// Falsch: SQL-Injection-Lücke
$id = $_GET['id'];
$result = $pdo->query("SELECT * FROM users WHERE id = $id");
// $_GET['id'] = "1; DROP TABLE users" → katastrophal

// Richtig: Prepared Statement mit Positional Parameters
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([$id]);
$user = $stmt->fetch();

// Oder mit Named Parameters
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id AND active = :active');
$stmt->execute([':id' => $id, ':active' => true]);

Ergebnisse abrufen

// Einzelne Zeile
$user = $stmt->fetch();              // assoc array oder false

// Alle Zeilen
$users = $stmt->fetchAll();           // Array von Zeilen

// Iterativ für große Result-Sets
while ($row = $stmt->fetch()) {
  processRow($row);
}

// Direkt in Objekte mappen (PDO::FETCH_CLASS)
$stmt->setFetchMode(PDO::FETCH_CLASS, User::class);
$users = $stmt->fetchAll();

Transaktionen

Wenn mehrere Operationen atomar sein müssen (alle oder keine), nutzt du Transaktionen:

$pdo->beginTransaction();

try {
  $pdo->prepare('UPDATE accounts SET balance = balance - ? WHERE id = ?')
      ->execute([100, $fromId]);

  $pdo->prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?')
      ->execute([100, $toId]);

  $pdo->commit();
} catch (\PDOException $e) {
  $pdo->rollBack();
  throw $e;
}
!
Niemals mysql_* oder mysqli ohne Prepared Statements Die alten mysql_*-Funktionen sind seit PHP 7 entfernt. mysqli existiert noch, aber nur mit Prepared Statements nutzen. Standard heute: PDO für direkten DB-Zugriff, Doctrine DBAL für Query-Builder, Doctrine ORM für komplexe Domänen.
Recall
  1. Welche drei Options gehören in jeden PDO-Konstruktor?
  2. Warum sind Prepared Statements sicherer als String-Konkatenation?
  3. Wann nutzt du eine Transaktion?
10

HTTP-Requests mit Guzzle

APIs konsumieren, async Requests
Frage zum Einstieg Du musst eine externe API ansprechen – REST mit JSON, Authentifizierung, vielleicht Retry bei 503. Mit nativen Funktionen wie file_get_contents oder cURL geht das, aber wird schnell hässlich. Guzzle ist Standard-Library für HTTP in PHP.

Setup

composer require guzzlehttp/guzzle
use GuzzleHttp\Client;

$client = new Client([
  'base_uri' => 'https://api.example.com',
  'timeout' => 5,
  'headers' => [
    'Accept' => 'application/json',
    'Authorization' => 'Bearer ' . $token,
  ],
]);

GET und POST

// GET
$response = $client->get('/users/42');
$data = json_decode($response->getBody()->getContents(), true);

// GET mit Query-Parametern
$response = $client->get('/users', [
  'query' => ['page' => 2, 'limit' => 50],
]);

// POST mit JSON-Body
$response = $client->post('/users', [
  'json' => ['name' => 'Marek', 'email' => 'm@e.de'],
]);

// Status und Header
$response->getStatusCode();          // 201
$response->getHeader('Content-Type');

Fehlerbehandlung

Guzzle wirft bei HTTP-Status 4xx/5xx Exceptions. Die hierarchische Struktur erlaubt gezieltes Catchen:

use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ServerException;
use GuzzleHttp\Exception\ConnectException;

try {
  $response = $client->get('/users/42');
} catch (ClientException $e) {
  // 4xx Fehler – z.B. 404 Not Found, 401 Unauthorized
  $status = $e->getResponse()->getStatusCode();
} catch (ServerException $e) {
  // 5xx Fehler – z.B. 500, 503
} catch (ConnectException $e) {
  // Netzwerk-Fehler, Timeout, DNS
}

Async Requests

Mehrere Requests parallel statt seriell? Guzzle unterstützt Promises:

use GuzzleHttp\Promise;

$promises = [
  'users' => $client->getAsync('/users'),
  'orders' => $client->getAsync('/orders'),
  'stats' => $client->getAsync('/stats'),
];

// Warte bis alle fertig sind
$results = Promise\Utils::settle($promises)->wait();

foreach ($results as $key => $result) {
  if ($result['state'] === 'fulfilled') {
    $body = (string) $result['value']->getBody();
    // ...
  }
}

Drei API-Calls in der Zeit eines einzelnen – wenn die API es zulässt, ist das ein riesiger Performance-Gewinn.

PSR-18 als alternative Wenn du framework-agnostisch bleiben willst, programmiere gegen Psr\Http\Client\ClientInterface. Guzzle implementiert das, aber auch andere Clients (z.B. Symfony HttpClient). Du tauschst sie dann ohne Code-Änderung aus.
Recall
  1. Wofür ist base_uri in der Client-Konfiguration?
  2. Welche Exception fängst du für HTTP 4xx?
  3. Wann lohnen sich async Requests?
11

PHPStan und Static Analysis

Bugs vor der Ausführung finden
Frage zum Einstieg Ein Tippfehler im Property-Namen, ein null-Wert, der zu einer Method-Aufruf-Exception führt, eine Funktion mit falscher Argument-Anzahl – all das passiert in PHP zur Laufzeit, oft erst in Production. PHPStan findet diese Bugs ohne den Code auszuführen.

Setup

composer require --dev phpstan/phpstan
# phpstan.neon
parameters:
  level: 8           # strikteste Stufe
  paths:
    - src
  excludePaths:
    - vendor
# Analyse ausführen
vendor/bin/phpstan analyse

Die Level-Stufen

PHPStan kennt Stufen von 0 (loose) bis 9 (sehr strikt). Beim Einstieg in ein Projekt fängst du niedrig an und arbeitest dich hoch:

LevelWas geprüft wird
0Existenz von Klassen und Funktionen
2Falsche Typen in Operatoren
5Typen der Funktionsargumente
7Möglicherweise null-Werte
8Calls auf nullable-Typen
9mixed-Werte korrekt einschränken

Typische Bugs, die PHPStan findet

// 1. Tippfehler in Property
class User {
  public function __construct(public readonly string $name) {}
}

$user = new User('Marek');
echo $user->naem;                // PHPStan: Access to undefined property

// 2. null-Aufruf
function findUser(int $id): ?User { /* ... */ }

$user = findUser(42);
echo $user->name;               // PHPStan: $user might be null

// 3. Falsche Argument-Typen
function double(int $x): int { return $x * 2; }

double('5');                    // PHPStan: expects int, gets string

PHPDoc für Inferenz nutzen

Je präziser deine PHPDoc-Annotationen, desto mehr findet PHPStan. Besonders mächtig bei Arrays:

/**
 * @param array<User> $users
 * @return array<string>
 */
function getNames(array $users): array {
  return array_map(fn($u) => $u->name, $users);
}

// Ohne PHPDoc würde PHPStan nicht wissen, was $users enthält
// Mit PHPDoc kann es fehlerhafte Aufrufe finden:
getNames([new User('M'), 'string']);  // PHPStan: erwartet User, nicht string

Baseline für Legacy-Code

Wenn du PHPStan zu einem bestehenden Projekt hinzufügst, sind oft hunderte Fehler in altem Code. Eine Baseline ignoriert bestehende Fehler – neue müssen gefixt werden:

vendor/bin/phpstan analyse --generate-baseline

Das erzeugt phpstan-baseline.neon. Bestehende Fehler werden eingefroren, neuer Code wird streng geprüft. Über die Zeit räumst du die Baseline ab.

i
Psalm als Alternative Psalm ist ein vergleichbares Tool von Vimeo. Die Features überschneiden sich zu 95% – PHPStan ist verbreiteter, Psalm geht bei bestimmten Edge Cases tiefer. In Symfony und Laravel-Welt: meist PHPStan.
Recall
  1. Was tut PHPStan, was PHP-Runtime nicht tut?
  2. Welche Level-Stufe sollte langfristig das Ziel sein?
  3. Wofür ist eine PHPStan-Baseline?
12

Testen mit PHPUnit

Unit-Tests, Mocks, Data Providers
Frage zum Einstieg Tests fühlen sich als zusätzliche Arbeit an – bis du das erste Mal eine Änderung machst und in Sekunden weißt, dass nichts kaputt ist. PHPUnit ist Standard für PHP-Tests. Wie schreibst du erste Tests und welche Patterns lohnen sich?

Setup

composer require --dev phpunit/phpunit
// phpunit.xml
<?xml version="1.0"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <testsuites>
    <testsuite name="default">
      <directory>tests</directory>
    </testsuite>
  </testsuites>
</phpunit>

Erste Test-Klasse

// src/Calculator.php
namespace App;

class Calculator {
  public function add(int $a, int $b): int {
    return $a + $b;
  }

  public function divide(int $a, int $b): float {
    if ($b === 0) throw new \DivisionByZeroError();
    return $a / $b;
  }
}

// tests/CalculatorTest.php
namespace App\Tests;

use App\Calculator;
use PHPUnit\Framework\TestCase;

class CalculatorTest extends TestCase {
  public function testAdd(): void {
    $calc = new Calculator();
    $this->assertSame(5, $calc->add(2, 3));
  }

  public function testDivideByZero(): void {
    $this->expectException(\DivisionByZeroError::class);
    (new Calculator())->divide(10, 0);
  }
}

Ausführen: vendor/bin/phpunit. Du siehst grüne Punkte für jeden bestandenen Test.

Data Providers

Wenn du eine Methode mit vielen Input-Output-Paaren testen willst, ist ein Data Provider effizienter als einzelne Testmethoden:

use PHPUnit\Framework\Attributes\DataProvider;

class CalculatorTest extends TestCase {
  #[DataProvider('addCases')]
  public function testAdd(int $a, int $b, int $expected): void {
    $this->assertSame($expected, (new Calculator())->add($a, $b));
  }

  public static function addCases(): array {
    return [
      'positives' => [2, 3, 5],
      'negatives' => [-1, -2, -3],
      'mixed'     => [-5, 10, 5],
      'zero'      => [0, 0, 0],
    ];
  }
}

PHPUnit ruft testAdd einmal pro Datensatz auf. Bei Fehler zeigt es den Key (z.B. 'negatives'), du findest den problematischen Fall sofort.

Mocks für Abhängigkeiten

Wenn deine Klasse externe Services nutzt (DB, API), willst du im Test nicht wirklich zugreifen. Mit Mocks simulierst du das Verhalten:

public function testOrderServiceLogsCreation(): void {
  // Mock erstellen
  $logger = $this->createMock(LoggerInterface::class);

  // Erwarte: info wird genau 1x mit 'Order created' aufgerufen
  $logger->expects($this->once())
         ->method('info')
         ->with($this->stringContains('Order created'));

  $service = new OrderService($logger);
  $service->create(new Order(42));
}

Was zuerst testen?

Faustregel für Anfang:

Was du nicht testen musst: Getter/Setter, fremde Library-Funktionen, Framework-Code.

Test-Pyramid: viele Unit, wenige E2E Schreibe viele Unit-Tests (schnell, isoliert), weniger Integration-Tests (echte DB, API), und wenige End-to-End-Tests (gesamte App). Eine Pyramide, keine Eisbecher-Form. Schnelles Feedback ist wichtiger als 100% Test-Coverage.
Recall
  1. Was prüft assertSame im Gegensatz zu assertEquals?
  2. Wofür sind Data Providers?
  3. Warum nutzt du Mocks statt echter Abhängigkeiten in Tests?

Wie es weitergeht

Du hast PHP jetzt von OOP-Patterns über funktionale Werkzeuge bis zu Production-Tools durchlaufen. Damit baust du wartbare, getestete Applikationen. Aber Praxis schlägt Theorie – setze diese Patterns in echtem Code ein.

Spaced-Repetition-Plan

Heute

Guide gelesen, Recall-Fragen aus jedem Kapitel beantwortet.

+3 Tage

Drei beliebige Kapitel überfliegen, Recall-Fragen aus dem Kopf.

+14 Tage

Mini-Projekt: REST-API mit PDO, Tests und PHPStan Level 8.

+60 Tage

Bestehendes Projekt um Tests und Static Analysis erweitern.

Was als nächstes lernen

Mit diesen Werkzeugen kannst du in jede Spezialisierung tiefer einsteigen:

Begleitmaterial

Dieser Guide ist Teil eines Sets:

```