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.
Inhaltsverzeichnis
- 1. Backed Enums und json_encode: was automatisch funktioniert
- 2. JsonSerializable für komplexe Ausgaben kombinieren
- 3. Enum-Werte aus JSON deserialisieren
- 4. Doctrine ORM: Enum-Type-Mapping korrekt konfigurieren
- 5. Reines PDO: Enum-Werte in Prepared Statements binden
- 6. Pure Enums: keine automatische Serialisierung
- 7. Fallstrick: Enum-Wertänderung in der Datenbank
- 8. Enum-Validierung beim Laden aus unsicheren Quellen
- 9. Praxisbeispiel: kompletter Enum-Workflow von API bis Datenbank
- 10. Zusammenfassung
- 11. FAQ
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.