PHP-8.3-Features, die jedes Backend-Team nutzen sollte

#PHP 8.3 Features
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

Führen Sie php --version auf einer modernen Symfony-Anwendung aus, und Sie sehen wahrscheinlich PHP 8.2 oder 8.3. Öffnen Sie die Quelldateien, und Sie sehen oft Code im Stil von PHP 7.4: nullable Parameter, die ohne Typen herumgereicht werden, Konstruktoren, die manuell acht Eigenschaften zuweisen, Konstanten ohne Typen deklariert, Switch-Statements für Logik, die in einen Match-Ausdruck gehört. Die Laufzeit hat sich weiterentwickelt. Der Code nicht.

Diese Lücke ist verbreitet und sie ist teuer. Jedes ungenutzte PHP-8.3-Feature ist eine verpasste Gelegenheit, eine Klasse von Laufzeitfehlern zu eliminieren, Boilerplate zu reduzieren oder Performance zu verbessern. Backend-Teams, die in Symfony, Laravel oder eigenen PHP-Anwendungen arbeiten, profitieren direkt von der Übernahme dieser Features – nicht als stilistische Übung, sondern weil sie Korrektheit in das Typsystem kodieren und Fehler aus der Produktion in den Editor verschieben.

Die PHP-8.3-Features mit dem höchsten praktischen Wert fallen in fünf Kategorien: Enums für Domänenmodellierung, Readonly-Eigenschaften und -Klassen für Unveränderlichkeit, typisierte Klassenkonstanten für Sicherheit, Match-Ausdrücke für erschöpfende Logik und Fibers für kooperative Nebenläufigkeit. Dieser Beitrag behandelt jedes davon mit realistischen Vorher-Nachher-Beispielen aus Symfony-Anwendungen.

Enums: String-Konstanten durch echte Typen ersetzen

Bevor PHP 8.1 Enums einführte, modellierten Teams endliche Wertemengen über Klassenkonstanten oder freischwebende Strings. Das funktionierte, war aber schwach typisiert – nichts hinderte einen Entwickler daran, 'ACTIVE' zu übergeben, wo der Code 'active' erwartete, und Typ-Hints konnten den Parameter nur auf string einengen.

Ein typisches PHP-7-Muster für Bestellstatus sieht so aus:

class Order
{
    public const STATUS_PENDING = 'pending';
    public const STATUS_PAID = 'paid';
    public const STATUS_SHIPPED = 'shipped';
    public const STATUS_CANCELLED = 'cancelled';

    public function setStatus(string $status): void
    {
        $this->status = $status; // Akzeptiert jeden String, inklusive Tippfehler
    }
}

Mit einem Backed Enum umgeschrieben, wird derselbe Code selbstdokumentierend und unmöglich falsch zu verwenden:

enum OrderStatus: string
{
    case Pending = 'pending';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Cancelled = 'cancelled';

    public function isTerminal(): bool
    {
        return match ($this) {
            self::Shipped, self::Cancelled => true,
            self::Pending, self::Paid => false,
        };
    }
}

class Order
{
    public function setStatus(OrderStatus $status): void
    {
        $this->status = $status;
    }
}

Das Typsystem erzwingt nun, dass nur gültige Status setStatus() erreichen. Die Methode isTerminal() kapselt Domänenlogik direkt neben den Daten, die sie betrifft. Doctrine unterstützt Enum-Typen nativ, sodass die Persistenz transparent ist. API Platform serialisiert Enums ohne eigene Normalizer. Das ist das Muster, auf das jede moderne PHP-Codebasis für jede endliche Domänenmenge standardmäßig setzen sollte – Rollen, Berechtigungen, Zustände, Event-Typen, Feature-Flags.

Pure Enums (ohne Backing-Wert) sind angemessen, wenn die Werte selbst Implementierungsdetails sind. Backed Enums sind die richtige Wahl, wenn die Werte in Datenbanken, APIs oder Konfiguration auftauchen.

Readonly-Eigenschaften und Readonly-Klassen: Unveränderlichkeit standardmäßig

Die in PHP 8.0 eingeführte Constructor Property Promotion reduzierte Konstruktor-Boilerplate bereits erheblich. PHP 8.1 fügte readonly-Eigenschaften hinzu. PHP 8.2 fügte readonly-Klassen hinzu, die den Modifikator automatisch auf jede Eigenschaft anwenden.

Value Objects, DTOs, Command-Payloads und Query-Ergebnisse sind fast immer sicherer als unveränderliche Typen. Ein unveränderliches Money-Objekt kann nicht in einem Service mutiert werden und diese Änderung unerwartet in einem anderen widerspiegeln. Ein Readonly-Command-DTO kann nach der Validierung nicht mehr verändert werden.

Vorher:

class CreateInvoiceCommand
{
    private string $customerId;
    private int $amountInCents;
    private string $currency;
    private \DateTimeImmutable $issuedAt;

    public function __construct(
        string $customerId,
        int $amountInCents,
        string $currency,
        \DateTimeImmutable $issuedAt,
    ) {
        $this->customerId = $customerId;
        $this->amountInCents = $amountInCents;
        $this->currency = $currency;
        $this->issuedAt = $issuedAt;
    }

    public function getCustomerId(): string { return $this->customerId; }
    public function getAmountInCents(): int { return $this->amountInCents; }
    public function getCurrency(): string { return $this->currency; }
    public function getIssuedAt(): \DateTimeImmutable { return $this->issuedAt; }
}

Nachher, mit einer Readonly-Klasse und promoteten Konstruktorparametern:

readonly class CreateInvoiceCommand
{
    public function __construct(
        public string $customerId,
        public int $amountInCents,
        public string $currency,
        public \DateTimeImmutable $issuedAt,
    ) {}
}

Das Verhalten ist für Konsumenten identisch, aber die Absicht ist explizit: Dieses Objekt kann nach der Konstruktion nicht mutiert werden. Jeder Versuch, eine Eigenschaft neu zuzuweisen, wirft zur Laufzeit einen Error, und statische Analysatoren fangen es ab, bevor der Code läuft. Kombiniert mit Constructor Property Promotion wird aus einer 25-zeiligen Klasse eine achtzeilige mit stärkeren Garantien.

Der erwähnenswerte Vorbehalt: Tiefes Klonen eines Readonly-Objekts erfordert das with-Muster, implementiert als Methoden, die neue Instanzen mit veränderten Werten zurückgeben. Das ist ein kleiner Preis im Austausch für Unveränderlichkeit, und Symfonys UrlGenerator, Laravels Query Builder und Doctrines NativeQuery nutzen dieses Muster bereits.

Typisierte Klassenkonstanten: ein Signatur-Feature von PHP 8.3

PHP 8.3 führte typisierte Klassenkonstanten ein und schloss damit eine langjährige Lücke im Typsystem. Vor 8.3 waren Klassenkonstanten der letzte Ort in einer typisierten Codebasis, an dem der Typ stillschweigend driften konnte:

class ConnectionPool
{
    public const MAX_CONNECTIONS = 50; // Keine Typ-Durchsetzung

    // Eine Subklasse kann dies als String, Array oder beliebig überschreiben
}

class BrokenPool extends ConnectionPool
{
    public const MAX_CONNECTIONS = 'fifty'; // Akzeptiert bis zur Laufzeit
}

In PHP 8.3 deklarieren Konstanten ihren Typ, und Subklassen-Überschreibungen müssen ihm entsprechen:

class ConnectionPool
{
    public const int MAX_CONNECTIONS = 50;
    public const string DEFAULT_DRIVER = 'pdo_mysql';
    public const array SUPPORTED_DRIVERS = ['pdo_mysql', 'pdo_pgsql'];
}

Das ist am wichtigsten in Bibliothekscode mit abstrakten Basisklassen, in denen von Subklassen erwartet wird, Konstanten zu überschreiben. Typisierte Konstanten verhindern subtile Bugs, bei denen eine Subklasse eine Typabweichung einführt, die erst auftaucht, wenn diese Konstante einen typsensitiven Aufrufer erreicht.

Match-Ausdrücke: erschöpfende und typsichere Verzweigung

Match-Ausdrücke, in PHP 8.0 eingeführt und seitdem verfeinert, ersetzen die meisten Verwendungen von switch durch strengere Semantik: standardmäßig strikter Vergleich, kein Fall-Through, erforderliche Erschöpfung für den Default-Fall und Verwendung auf Ausdrucksebene (sie geben Werte zurück).

Ein gängiges Legacy-Muster, das enorm von match profitiert, ist das Dispatchen von Logik auf Basis eines Typs oder Status:

// Vorher: switch mit implizitem losem Vergleich und fehlendem default
switch ($event->getType()) {
    case 'order.created':
        $handler = $this->orderCreatedHandler;
        break;
    case 'order.paid':
        $handler = $this->orderPaidHandler;
        break;
    case 'order.shipped':
        $handler = $this->orderShippedHandler;
        break;
}

$handler->handle($event); // Undefiniert, wenn der Typ nicht passte
// Nachher: match mit von PHPStan/Psalm erzwungener Erschöpfung
$handler = match ($event->getType()) {
    EventType::OrderCreated => $this->orderCreatedHandler,
    EventType::OrderPaid => $this->orderPaidHandler,
    EventType::OrderShipped => $this->orderShippedHandler,
};

$handler->handle($event);

Kombiniert mit Backed Enums werden statische Analysatoren (PHPStan Level 9 oder Psalm strict) jeden Fall markieren, den Sie zu behandeln vergaßen. Das verwandelt eine Kategorie von „wir haben vergessen, diesen Event-Typ zu behandeln“-Bugs von Laufzeitfehlern in Build-Fehler.

Fibers: kooperative Nebenläufigkeit ohne Extensions

Fibers, in PHP 8.1 eingeführt, sind die Grundlage für asynchrones PHP ohne externe Extensions wie Swoole oder die Userland-Event-Loop von ReactPHP. Die meisten Anwendungsentwickler werden keine Fibers direkt schreiben – sie werden fiber-gestützte Bibliotheken wie AMPHP v3, ReactPHP 3.x oder kommende Symfony-Komponenten nutzen, die sie unter der Haube einsetzen.

Die praktische Auswirkung: Nebenläufige HTTP-Requests, parallele Datenbankabfragen und Streaming-Responses werden in reinem PHP möglich, ohne die Anwendung um eine Event-Loop herum umzuschreiben. Ein Symfony-Console-Command, der Daten von vier Upstream-APIs sequenziell abruft, kann mit AMPHP umgeschrieben werden, um alle vier nebenläufig abzurufen und so die Wall-Clock-Zeit von der Summe der Latenzen auf die maximale Einzellatenz zu reduzieren.

use Amp\Future;
use function Amp\async;

$futures = [
    async(fn() => $this->orderService->fetchOrders($customerId)),
    async(fn() => $this->invoiceService->fetchInvoices($customerId)),
    async(fn() => $this->subscriptionService->fetchSubscriptions($customerId)),
    async(fn() => $this->supportService->fetchTickets($customerId)),
];

[$orders, $invoices, $subscriptions, $tickets] = Future\await($futures);

Das ist kein Drop-in-Ersatz für synchronen Code überall, aber für I/O-gebundene Workloads – Report-Generierung, Batch-Verarbeitung, Webhook-Fan-out – machen Fibers plus eine moderne Async-Bibliothek PHP beim Durchsatz pro Prozess wirklich konkurrenzfähig zu Node.js.

Was zuerst übernehmen

Für Teams, die eine bestehende Codebasis auditieren, ist die praktische Reihenfolge: Führen Sie Enums für jede endliche Domänenmenge ein, die derzeit als String-Konstanten lebt, wenden Sie dann readonly auf Value Objects und DTOs an, migrieren Sie dann switch-Statements zu match, wo die Verzweigung einen Wert zurückgibt, und upgraden Sie dann auf typisierte Klassenkonstanten überall dort, wo eine Basisklasse Konstanten deklariert, die überschrieben werden sollen. Fibers kommen zuletzt – sie sind wirkungsvoll, erfordern aber die Wahl einer Nebenläufigkeitsbibliothek und das Umschreiben der konkreten Codepfade, die davon profitieren.

Ein pragmatischer Weg, diese Übernahme voranzutreiben, ist die Kombination eines Upgrades des statischen Analysators (PHPStan Level 8 oder höher, Psalm strict) mit einem kurzen internen Styleguide. Die statische Analyse markiert die Lücken; der Styleguide macht die neuen Muster zum erwarteten Standard im Code-Review. Nach unserer Erfahrung mit Code-Quality-Audits auf Symfony-Codebasen eliminiert allein die Anwendung dieser vier Muster typischerweise eine ganze Kategorie typbezogener Bugs und reduziert die Konstruktor-/DTO-Boilerplate im Repository um 30–50 %.

Teams, die Code im PHP-7-Stil auf einer PHP-8.3-Laufzeit mit sich tragen, zahlen die Kosten des älteren Stils, ohne die Vorteile des neueren einzusammeln. Modernisieren ist kein Rewrite – es ist ein schrittweiser, testgetriebener Refactor, der Datei für Datei, PR für PR passieren kann, ohne die Feature-Arbeit zu stoppen. Wenn Ihr Team auf eine Codebasis blickt, die sich moderner anfühlen sollte, als sie es tut, oder eine breitere Legacy-Code-Optimierung plant, helfen wir gerne. Kontaktieren Sie uns unter hello@wolf-tech.io oder besuchen Sie wolf-tech.io für eine kostenlose Beratung.