Vom primitiven Spaltentyp zum domänenspezifischen Werttyp
Wie du mit einem eigenen Doctrine DBAL Type ein Geld-Objekt oder eine E-Mail-Value-Object direkt in Entities nutzt, inklusive convertToPHPValue, convertToDatabaseValue und Registrierung.
Inhaltsverzeichnis
- 1. Warum primitive Spaltentypen an ihre Grenzen stoßen
- 2. Type::class erweitern: Grundgerüst eines eigenen DBAL Types
- 3. convertToPHPValue und convertToDatabaseValue im Detail
- 4. Registrierung in doctrine.yaml und Nutzung in der Entity
- 5. Ein zweites Beispiel: EmailAddress als Custom Type
- 6. Der konkrete Vorteil gegenüber primitiven Spaltentypen
- 7. Abgrenzung: Custom Type vs. Doctrine Embeddable
- 8. Eigene DBAL Types testen
- 9. Häufige Fallstricke bei eigenen Feldtypen
- 10. Zusammenfassung
- 11. FAQ
1. Warum primitive Spaltentypen an ihre Grenzen stoßen
Die meisten Entities beginnen mit string, int und decimal für alles, was nicht in eine der eingebauten Kategorien passt. Ein Geldbetrag wird dann als decimal(10,2) gespeichert, eine E-Mail-Adresse als string(255), obwohl beide fachlich weit mehr Bedeutung tragen als eine reine Zahl oder Zeichenkette. Das Problem zeigt sich spätestens dann, wenn an mehreren Stellen im Code dieselbe Validierungs- oder Formatierungslogik dupliziert werden muss, weil der Typ selbst keine Garantien gibt.
Ein Geldbetrag ohne Währung ist fachlich unvollständig, eine E-Mail-Adresse ohne Validierung beim Erzeugen kann jederzeit ungültige Werte enthalten. Ein eigener DBAL Type löst dieses Problem an der Wurzel: Er bildet einen domänenspezifischen PHP-Werttyp, etwa ein unveränderliches Money- oder EmailAddress-Objekt, direkt auf eine Datenbankspalte ab und stellt sicher, dass in der Anwendung niemals ein ungültiger oder unvollständiger Wert existieren kann.
2. Type::class erweitern: Grundgerüst eines eigenen DBAL Types
Jeder eigene Feldtyp erbt von Doctrine\DBAL\Types\Type und muss mindestens drei Methoden implementieren: getSQLDeclaration() beschreibt, wie die Spalte in der Datenbank tatsächlich angelegt wird, convertToPHPValue() wandelt den Rohwert aus der Datenbank in das PHP-Objekt um, und convertToDatabaseValue() macht den umgekehrten Schritt beim Speichern. Zusätzlich liefert getName() den eindeutigen Bezeichner, unter dem der Typ später in Entities referenziert wird.
Für ein Money-Value-Object, das intern einen Integer-Betrag in Cent sowie einen ISO-4217-Währungscode hält, bietet sich als Datenbankdarstellung ein einfacher VARCHAR an, der beide Werte kombiniert speichert, etwa im Format 1999:EUR. Diese String-Repräsentation ist datenbankunabhängig, lässt sich problemlos in MySQL, PostgreSQL oder SQLite gleich behandeln und vermeidet zusätzliche Spalten für Betrag und Währung.
3. convertToPHPValue und convertToDatabaseValue im Detail
convertToPHPValue() wird von Doctrine bei jedem Laden einer Entity aufgerufen und erhält den rohen Datenbankwert als ersten Parameter sowie die AbstractPlatform als zweiten. Hier findet die eigentliche Objekterzeugung statt, inklusive Validierung: Ist der gespeicherte String fehlerhaft formatiert, sollte hier eine ConversionException geworfen werden, damit fehlerhafte Daten nicht unbemerkt als kaputtes Objekt im System landen.
convertToDatabaseValue() macht den umgekehrten Weg beim Speichern und erhält das PHP-Objekt sowie ebenfalls die Plattform. Da das Money-Objekt als unveränderlich (immutable) designt ist, reicht hier ein einfacher Aufruf der eigenen __toString()- oder toPersistenceString()-Methode. Das folgende Beispiel zeigt die vollständige Implementierung eines MoneyType für Symfony 7 mit PHP 8.4.
<?php
declare(strict_types=1);
namespace App\Doctrine\Type;
use App\ValueObject\Money;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\Type;
final class MoneyType extends Type
{
public const NAME = 'money';
public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
{
return 'VARCHAR(32)';
}
public function convertToPHPValue($value, AbstractPlatform $platform): ?Money
{
if ($value === null) {
return null;
}
[$amount, $currency] = explode(':', (string) $value);
if (!is_numeric($amount) || $currency === '') {
throw ConversionException::conversionFailed((string) $value, self::NAME);
}
return new Money((int) $amount, $currency);
}
public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
{
if ($value === null) {
return null;
}
if (!$value instanceof Money) {
throw ConversionException::conversionFailedInvalidType($value, self::NAME, ['null', Money::class]);
}
return sprintf('%d:%s', $value->amountInCents(), $value->currency());
}
public function getName(): string
{
return self::NAME;
}
}
4. Registrierung in doctrine.yaml und Nutzung in der Entity
Ein eigener Type muss Doctrine bekannt gemacht werden, bevor er in einer Entity verwendet werden kann. Das geschieht in doctrine.yaml unter dbal.types, wo der Name money auf die Klasse App\Doctrine\Type\MoneyType abgebildet wird. Erst nach dieser Registrierung kann in einer Entity das Attribut #[ORM\Column(type: 'money')] verwendet werden, ohne dass Doctrine mit einer Exception über einen unbekannten Typ reagiert.
In der Entity selbst reicht danach eine einzige Spalten-Deklaration, um ein vollständiges Money-Objekt mit Betrag und Währung zu erhalten, statt zwei separater Felder für amountInCents und currency. Das Property auf der Entity ist typisiert als ?Money, sodass PHPStan und die IDE bereits beim Schreiben des Codes auf Typfehler hinweisen können, lange bevor ein Test oder die Laufzeit den Fehler aufdeckt.
5. Ein zweites Beispiel: EmailAddress als Custom Type
Neben Geldbeträgen eignen sich auch E-Mail-Adressen hervorragend für einen eigenen DBAL Type, weil sie ebenfalls eine klare Validierungsregel besitzen, nämlich ein gültiges E-Mail-Format nach RFC 5322. Ein EmailAddressType speichert die Adresse zwar weiterhin als einfachen VARCHAR in der Datenbank, stellt aber sicher, dass beim Laden aus der Datenbank ausschließlich ein validiertes EmailAddress-Objekt entsteht, niemals ein roher, potenziell ungültiger String.
Besonders wertvoll wird das, wenn die Adresse zusätzliche Methoden trägt, etwa getDomain() oder eine Normalisierung der Groß- und Kleinschreibung im Konstruktor. Jede Stelle im Code, die mit dieser Entity arbeitet, profitiert automatisch von dieser Garantie, ohne dass an jeder einzelnen Verwendungsstelle erneut validiert oder normalisiert werden muss.
6. Der konkrete Vorteil gegenüber primitiven Spaltentypen
Der zentrale Gewinn eines Custom DBAL Type ist, dass Validierung und Konvertierung an genau einer Stelle im Code stattfinden, statt über Dutzende Controller, Services und Formulare verstreut zu sein. Ein Money-Objekt kann nach der Konstruktion niemals eine negative Zahl mit ungültigem Währungscode enthalten, weil der Konstruktor des Value-Objects das bereits verhindert, lange bevor Doctrine überhaupt ins Spiel kommt.
Außerdem wird die Entity selbst lesbarer: Ein Property vom Typ Money statt zwei rohen int- und string-Feldern kommuniziert die fachliche Absicht viel klarer und macht Zusammengehörigkeit explizit, statt sie implizit über Namenskonventionen wie amountCents und amountCurrency auszudrücken, die leicht auseinanderdriften können, wenn nur eines der beiden Felder aktualisiert wird.
7. Abgrenzung: Custom Type vs. Doctrine Embeddable
Doctrine bietet mit Embeddables eine alternative Lösung für ähnliche Probleme, bei der ein Value-Object auf mehrere echte Spalten abgebildet wird, statt in eine einzelne Spalte serialisiert zu werden. Für ein Money-Objekt würde ein Embeddable also zwei separate Spalten amount_cents und currency erzeugen, während ein Custom Type beides in einer Spalte kombiniert.
Die Wahl hängt vom Anwendungsfall ab: Sollen Betrag und Währung einzeln in SQL-Queries gefiltert oder aggregiert werden können, etwa SUM(amount_cents) GROUP BY currency, ist ein Embeddable mit echten Spalten meist praktischer. Geht es dagegen vor allem um Typsicherheit und Kapselung ohne direkten SQL-Zugriff auf die Einzelwerte, ist ein Custom Type mit kombinierter Spalte oft die einfachere und wartbarere Lösung.
8. Eigene DBAL Types testen
Ein Custom Type lässt sich ohne echte Datenbankverbindung testen, indem man convertToPHPValue() und convertToDatabaseValue() direkt mit einer Instanz einer konkreten AbstractPlatform-Implementierung wie MySQLPlatform aufruft. Wichtig ist, sowohl den Erfolgsfall als auch fehlerhafte Eingaben zu testen, etwa einen String ohne Doppelpunkt-Trenner, der eine ConversionException auslösen muss.
Zusätzlich lohnt sich ein Integrationstest mit einer echten (Test-)Datenbank, der eine Entity mit dem neuen Type speichert, den EntityManager leert und die Entity anschließend frisch lädt. So wird sichergestellt, dass der komplette Roundtrip, von PHP-Objekt über Datenbank zurück zu PHP-Objekt, tatsächlich verlustfrei funktioniert und keine Rundungs- oder Encoding-Probleme auftreten.
9. Häufige Fallstricke bei eigenen Feldtypen
Ein oft übersehener Punkt ist, dass requiresSQLCommentHint() auf true gesetzt werden sollte, wenn der Typ auf einem bestehenden nativen Typ wie VARCHAR basiert. Ohne diesen Hinweis erkennt Doctrine bei doctrine:schema:update oder doctrine:migrations:diff den Spaltentyp nicht zuverlässig wieder und schlägt fälschlicherweise wiederholt dieselbe Migration vor, obwohl sich am Schema nichts geändert hat.
Ein weiterer Fallstrick ist Typ-Caching: Doctrine registriert Types global und statisch, weshalb ein einmal registrierter Type nicht ohne Weiteres in Tests mit unterschiedlicher Konfiguration neu registriert werden kann. In Testsuiten empfiehlt sich deshalb eine zentrale Bootstrap-Datei, die alle Custom Types genau einmal vor Ausführung der Tests registriert, statt dies in jedem einzelnen Testfall zu wiederholen.
| Methode | Aufgabe | Aufrufzeitpunkt | Wichtiger Hinweis |
|---|---|---|---|
| getSQLDeclaration() | Definiert die Spalten-DDL | Bei Schema-Erzeugung/-Migration | requiresSQLCommentHint() bei Basis auf nativem Typ |
| convertToPHPValue() | Rohwert zu PHP-Objekt | Beim Laden der Entity | ConversionException bei ungültigen Daten werfen |
| convertToDatabaseValue() | PHP-Objekt zu Rohwert | Beim Speichern/Flush | Typprüfung mit instanceof vor Konvertierung |
| getName() | Eindeutiger Bezeichner des Typs | Bei Registrierung | Muss mit dbal.types-Key übereinstimmen |
Mironsoft
Symfony-Architektur, saubere Domänenlogik und Legacy-Modernisierung
Symfony-Anwendungen, die nach zwei Jahren noch wartbar sind?
Wir prüfen bestehende Symfony-Projekte auf aufgeblähte Controller, fehlende Serviceabstraktionen und ungetestete Kernlogik und bauen daraus eine Architektur, die neue Features aufnimmt, ohne bei jedem Release brüchiger zu werden.
Architektur-Review
Bundle-Struktur, Dependency Injection und Serviceabstraktionen auf Wartbarkeit prüfen.
Legacy-Modernisierung
Schrittweise Migration veralteter Symfony-Versionen ohne kompletten Neuschrieb.
Test- und Qualitätssicherung
PHPUnit, PHPStan und CI-Pipelines für nachhaltige Codequalität aufbauen.
10. Zusammenfassung
Custom DBAL Types
Type::class
Basisklasse für eigene Feldtypen mit vier zentralen Methoden
Konvertierung
convertToPHPValue/convertToDatabaseValue kapseln Validierung an einer Stelle
Registrierung
Eintrag unter dbal.types in doctrine.yaml, danach in Entity nutzbar
Abgrenzung
Embeddable für mehrere Spalten, Custom Type für eine kombinierte Spalte