Enums serialisieren: JSON und Datenbank-Persistenz richtig lösen
AI generated
8.4
PHP · Enum Serialisierung · PHP 8.1
Enums serialisieren
JSON und Datenbank-Persistenz richtig lösen

Backed Enums lassen sich fast beiläufig in JSON umwandeln, doch sobald Doctrine, PDO oder komplexere API-Antworten ins Spiel kommen, reichen die eingebauten Mechanismen allein nicht mehr aus. Dieser Artikel zeigt, wie Enum-Werte zuverlässig durch JSON-Schnittstellen und relationale Datenbanken wandern, und wo Pure Enums an eine harte Grenze stoßen.

10 Min. Lesezeit Enums Persistenz

1. Backed Enums und json_encode: was automatisch funktioniert

Ein Backed Enum, das einen skalaren Typ wie string oder int trägt, wird von json_encode automatisch auf seinen Backing-Wert reduziert, ohne dass dafür zusätzlicher Code nötig ist. Ein Enum-Case wie Status::Active mit dem Wert active landet in der JSON-Ausgabe schlicht als der String active.

Diese eingebaute Unterstützung funktioniert allerdings nur direkt am obersten Level eines einzelnen Enum-Werts, sobald das Enum Teil eines größeren Objekts wird, dessen Serialisierung selbst über JsonSerializable gesteuert wird, muss die Kombination beider Mechanismen bewusst modelliert werden.

Wichtig dabei: Ein Pure Enum ohne Backing-Typ verhält sich hier fundamental anders, wie später in diesem Artikel im Detail gezeigt wird. Die automatische Umwandlung greift ausschließlich bei Backed Enums, da json_encode ohne skalaren Wert schlicht keine sinnvolle Darstellung ableiten kann.


enum Status: string
{
    case Active = 'active';
    case Archived = 'archived';
}

echo json_encode(Status::Active); // "active"

2. JsonSerializable für komplexe Ausgaben kombinieren

Sobald ein Value Object mit einer Enum-Property über json_encode serialisiert wird, funktioniert das automatisch korrekt, weil PHP jede Public Property rekursiv serialisiert und dabei für das Enum denselben Mechanismus wie im vorherigen Beispiel anwendet. Explizite Kontrolle über das Ausgabeformat gewinnt man aber erst mit JsonSerializable.

Implementiert die umgebende Klasse JsonSerializable, lässt sich präzise steuern, welche Felder erscheinen und in welcher Form, etwa um zusätzlich zum reinen Backing-Wert noch ein menschenlesbares Label mitzuliefern, ohne dass der Enum-Case selbst dafür verändert werden muss.


final readonly class Order implements JsonSerializable
{
    public function __construct(
        private string $id,
        private Status $status,
    ) {
    }

    public function jsonSerialize(): array
    {
        return [
            'id' => $this->id,
            'status' => $this->status->value,
            'statusLabel' => match ($this->status) {
                Status::Active => 'Active',
                Status::Archived => 'Archived',
            },
        ];
    }
}

3. Enum-Werte aus JSON deserialisieren

Beim umgekehrten Weg, von einer JSON-Nutzlast zurück zu einem Enum-Case, kommen die statischen Methoden from und tryFrom zum Einsatz. from wirft eine ValueError, sobald der übergebene Wert keinem Case entspricht, tryFrom liefert stattdessen still null zurück.

Für Eingaben aus vertrauenswürdigen internen Quellen ist from meist die richtige Wahl, weil ein ungültiger Wert dort auf einen echten Programmierfehler hindeutet, der laut auffallen soll. Für Eingaben aus externen API-Requests ist tryFrom in Kombination mit einer eigenen Validierungsfehlermeldung meist die robustere Variante.

Ein häufiger Anfängerfehler besteht darin, den rohen String direkt ohne Umweg über from oder tryFrom im weiteren Code zu verwenden, etwa in einem match-Ausdruck auf den String-Wert statt auf den Enum-Case selbst. Damit gehen sämtliche Vorteile der Typsicherheit verloren, die der Wechsel zu einem Enum eigentlich bringen sollte.


$payload = json_decode($request->getBody(), true);

$status = Status::tryFrom($payload['status'] ?? '');

if ($status === null) {
    throw new InvalidArgumentException('Unknown status value');
}

4. Doctrine ORM: Enum-Type-Mapping korrekt konfigurieren

Doctrine unterstützt Backed Enums seit ORM 2.11 direkt über das enumType-Argument des Column-Attributs, ohne dass ein eigener Custom Type registriert werden muss. Die Spalte in der Datenbank speichert weiterhin einen einfachen skalaren Wert, während Doctrine beim Lesen automatisch den passenden Enum-Case hydratisiert.

Wichtig ist dabei, dass das Enum stets einen backed Typ tragen muss, ein Pure Enum lässt sich mit diesem Mechanismus nicht direkt mappen, weil Doctrine für die Spalte einen konkreten skalaren Wert braucht, den es beim Speichern und Laden vergleichen kann.

Vor Doctrine ORM 2.11 musste diese Umwandlung noch über einen selbst geschriebenen Custom Type erfolgen, der convertToPHPValue und convertToDatabaseValue implementiert und dabei intern from beziehungsweise den Backing-Wert nutzt. Für neue Projekte lohnt sich dieser Mehraufwand heute nur noch in Sonderfällen, etwa bei komplexeren Enum-Hierarchien.


#[Entity]
final class Order
{
    #[Column(type: 'string', enumType: Status::class)]
    private Status $status;

    public function __construct(Status $status)
    {
        $this->status = $status;
    }
}

5. Reines PDO: Enum-Werte in Prepared Statements binden

Ohne ORM übernimmt man die Umwandlung zwischen Enum-Case und Datenbankwert selbst. Beim Schreiben reicht es, den Backing-Wert des Enums statt des Enum-Objekts direkt an ein Prepared Statement zu binden, da PDO keine native Vorstellung von Enums hat und nur Skalare akzeptiert.

Beim Lesen aus der Datenbank liefert PDO grundsätzlich nur den rohen skalaren Wert zurück, die Umwandlung zurück in einen Enum-Case muss explizit über tryFrom erfolgen, idealerweise direkt an der Stelle, an der die Datenbankzeile in ein Domänenobjekt übersetzt wird.


$stmt = $pdo->prepare('INSERT INTO orders (id, status) VALUES (:id, :status)');
$stmt->execute(['id' => $orderId, 'status' => $status->value]);

$row = $pdo->query('SELECT status FROM orders WHERE id = 1')->fetch();
$status = Status::from($row['status']); // throws ValueError on corrupted data

6. Pure Enums: keine automatische Serialisierung

Ein Pure Enum ohne Backing-Typ besitzt keinen skalaren Wert, den json_encode oder eine Datenbankspalte direkt nutzen könnte, deshalb serialisiert json_encode ein Pure Enum stets als leeres Objekt, was in fast allen praktischen Fällen nicht das gewünschte Ergebnis ist.

Der praktikable Workaround ist eine explizite Methode, etwa getValue() oder toArray(), die den Case manuell auf einen String oder ein Array abbildet, häufig über einen match-Ausdruck über alle vorhandenen Cases. Für Datenbank-Persistenz gilt dasselbe: Ein Pure Enum braucht immer eine manuell gepflegte Mapping-Tabelle zwischen Case und gespeichertem Wert.


enum Direction
{
    case North;
    case South;
    case East;
    case West;

    public function toCode(): string
    {
        // Pure enums need an explicit mapping, there is no implicit backing value
        return match ($this) {
            self::North => 'N',
            self::South => 'S',
            self::East => 'E',
            self::West => 'W',
        };
    }
}

7. Fallstrick: Enum-Wertänderung in der Datenbank

Ändert sich im Code der Backing-Wert eines Enum-Cases, etwa weil ein String von old_status auf legacy umbenannt wird, bleiben bereits gespeicherte Datensätze mit dem alten Wert in der Datenbank zurück. from schlägt für diese Zeilen anschließend mit einer ValueError fehl, tryFrom liefert still null.

Solche Änderungen erfordern deshalb immer eine begleitende Datenmigration, die bestehende Zeilen auf den neuen Wert aktualisiert, bevor der geänderte Code deployt wird. Ein Enum-Backing-Wert sollte deshalb aus Persistenzsicht ähnlich vorsichtig behandelt werden wie ein Datenbank-Spaltenname, eine Änderung ist selten kostenlos. In größeren Teams empfiehlt sich zusätzlich ein kurzer Blick in bestehende Reports oder Exporte, die den alten Backing-Wert unter Umständen ebenfalls fest verdrahtet erwarten.

8. Enum-Validierung beim Laden aus unsicheren Quellen

Bei jedem Enum-Wert, der aus einer nicht vollständig vertrauenswürdigen Quelle stammt, etwa einem externen API-Request oder einem Formularfeld, ist tryFrom die sicherere Wahl gegenüber from, weil eine kontrollierte Fehlermeldung mit passendem HTTP-Statuscode besser ist als eine ungefangene ValueError, die als 500er durchschlägt und dem Aufrufer keinerlei brauchbaren Hinweis auf die eigentliche Ursache liefert.

Für Datensätze aus der eigenen Datenbank ist die Lage differenzierter: Hier ist ein ungültiger Wert im Regelfall ein Zeichen für einen Bug oder eine fehlende Migration, weshalb from mit seinem lauten Fehlschlag dort oft die ehrlichere Wahl ist, weil das Problem sofort sichtbar wird statt sich als stilles null durch mehrere Schichten der Anwendung weiterzuschleppen.

9. Praxisbeispiel: kompletter Enum-Workflow von API bis Datenbank

Ein vollständiger Workflow beginnt beim eingehenden JSON-Request, wo tryFrom den rohen String validiert und in einen Status-Case umwandelt. Das resultierende Domänenobjekt trägt den Enum-Case als typisierte Property, nicht den rohen String, wodurch ungültige Zustände im weiteren Codeverlauf strukturell ausgeschlossen sind.

Beim Speichern liefert der Backing-Wert des Enums direkt den Wert für das Prepared Statement, beim Ausliefern der API-Antwort übernimmt entweder die automatische Backed-Enum-Serialisierung oder eine explizite JsonSerializable-Implementierung die Rückumwandlung. Der Enum-Case selbst bleibt dabei durchgängig die einzige Quelle der Wahrheit für den Status.


final class OrderService
{
    public function createFromRequest(array $payload, PDO $pdo): Order
    {
        $status = Status::tryFrom($payload['status'] ?? '')
            ?? throw new InvalidArgumentException('Unknown status value');

        $order = new Order($payload['id'], $status);

        $stmt = $pdo->prepare('INSERT INTO orders (id, status) VALUES (:id, :status)');
        $stmt->execute(['id' => $order->id, 'status' => $status->value]);

        return $order;
    }
}
Aspekt Backed Enum Pure Enum
json_encode Verhalten Automatisch der Backing-Wert Leeres Objekt, keine sinnvolle Ausgabe
Deserialisierung from() und tryFrom() aus skalarem Wert Nur über manuelles Mapping möglich
Doctrine-Mapping Direkt über enumType im Column-Attribut Nicht direkt unterstützt
PDO-Bindung Backing-Wert direkt bindbar Erfordert eigene Mapping-Methode
Fehlerfall bei ungültigem Wert ValueError bei from(), null bei tryFrom() Existiert nicht, da kein Parsing aus Skalar
Typischer Einsatz API-Status, Datenbank-Spalten Reine Programmlogik ohne Persistenz

Mironsoft

PHP-Modernisierung, Code-Qualität und Legacy-Refactoring

Gewachsener PHP-Code, der niemand mehr gern anfasst?

Wir modernisieren PHP-Codebasen auf aktuelle Sprachstandards, führen statische Analyse und Coding Standards ein und refactorn Legacy-Code Schritt für Schritt, ohne den laufenden Betrieb zu gefährden.

Legacy-Refactoring

Gewachsenen PHP-Code strukturiert und risikoarm modernisieren.

Code-Qualität etablieren

PHPStan, Coding Standards und CI-Checks nachhaltig im Team verankern.

Versions-Upgrade

PHP-Major-Version-Upgrades sicher planen und ohne Ausfallzeit umsetzen.

10. Zusammenfassung

Enum Serialisierung

Kernidee

Backed Enums serialisieren automatisch über ihren Backing-Wert, Pure Enums brauchen manuelles Mapping.

Doctrine

Das enumType-Argument im Column-Attribut mappt Backed Enums direkt, ohne Custom Type.

PDO

Immer den Backing-Wert binden und beim Lesen explizit über from oder tryFrom zurückwandeln.

Fallstrick

Geänderte Backing-Werte erfordern eine Datenmigration, sonst schlägt from für alte Zeilen fehl.

11. FAQ: Enum Serialisierung

1Werden Backed Enums automatisch zu JSON serialisiert?
Ja, json_encode wandelt einen Backed Enum Case automatisch in seinen Backing-Wert um, ohne dass zusätzlicher Code nötig ist.
2Was passiert bei json_encode mit einem Pure Enum?
Ein Pure Enum wird als leeres Objekt serialisiert, weil kein skalarer Backing-Wert existiert, den json_encode nutzen könnte.
3Wann sollte man from statt tryFrom verwenden?
from eignet sich für vertrauenswürdige interne Werte, bei denen ein ungültiger Wert einen echten Programmierfehler anzeigt, tryFrom für externe, unsichere Eingaben.
4Wie mappt man Backed Enums in Doctrine?
Über das enumType-Argument im Column-Attribut, seit Doctrine ORM 2.11 direkt unterstützt, ohne eigenen Custom Type registrieren zu müssen.
5Kann Doctrine auch Pure Enums mappen?
Nicht direkt, da Doctrine für die Spalte einen konkreten skalaren Wert braucht, den ein Pure Enum nicht besitzt.
6Wie bindet man einen Enum-Wert in ein PDO Prepared Statement?
Man bindet den Backing-Wert des Enums, also enum->value, da PDO keine native Vorstellung von Enum-Objekten hat.
7Was passiert, wenn sich ein Backing-Wert im Code ändert?
Bereits gespeicherte Datensätze mit dem alten Wert lassen sich ohne begleitende Datenmigration nicht mehr über from() zurückwandeln.
8Wie serialisiert man ein Enum als Teil eines komplexen Objekts?
Entweder verlässt man sich auf die automatische Public-Property-Serialisierung oder implementiert JsonSerializable für volle Kontrolle über das Ausgabeformat.
9Wie löst man Serialisierung bei Pure Enums?
Über eine explizite Methode wie toValue() oder toArray(), die jeden Case manuell über einen match-Ausdruck auf einen String oder ein Array abbildet.
10Ist tryFrom sicherer als from bei API-Eingaben?
Ja, tryFrom liefert bei ungültigem Wert still null statt eine ValueError zu werfen, was eine kontrollierte Fehlerantwort mit passendem Statuscode ermöglicht.