Object-Relational Mapping von Grund auf verstehen
Doctrine oder Eloquent zu benutzen ist einfach, zu verstehen, warum sie so funktionieren, wie sie funktionieren, ist es nicht. Wer einmal ein eigenes minimales ORM gebaut hat, versteht Hydration, Unit of Work und Identity Map nicht mehr abstrakt, sondern als konkrete Entscheidungen mit klaren Kompromissen.
Inhaltsverzeichnis
- 1. Warum ein eigenes ORM verstehen hilft
- 2. Active Record vs. Data Mapper: die zwei Grundmuster
- 3. Entity-Mapping mit Attributen und Reflection
- 4. Hydration: Datenbankzeilen in Objekte verwandeln
- 5. Unit of Work: Change-Tracking für Objekte
- 6. Lazy Loading und Relationen abbilden
- 7. Ein minimaler Query Builder als Fundament
- 8. Identity Map: doppelte Objekte vermeiden
- 9. Eigenes ORM im Vergleich zu Doctrine und Eloquent
- 10. Zusammenfassung
- 11. FAQ
1. Warum ein eigenes ORM verstehen hilft
Ein ORM (Object-Relational Mapper) übersetzt zwischen der relationalen Welt der Datenbank und der objektorientierten Welt von PHP. Die meisten Entwickler nutzen ein bestehendes ORM wie Doctrine oder Eloquent, ohne je verstanden zu haben, welche Probleme dieses ORM intern löst. Genau dieses Verständnis fehlt oft, wenn ein ORM in der Produktion ein überraschendes N+1-Problem erzeugt oder ein Objekt scheinbar falsche Daten enthält, obwohl die Datenbank korrekt aktualisiert wurde.
Ein eigenes, bewusst einfaches ORM zu bauen ist keine Empfehlung, ein produktionsreifes ORM selbst zu schreiben, sondern eine Übung, um die Konzepte hinter jedem ORM zu verinnerlichen: Mapping zwischen Klasse und Tabelle, Hydration von Zeilen zu Objekten, Change-Tracking und Identitätsverwaltung. Wer diese Bausteine einmal selbst zusammengesetzt hat, liest Doctrine-Fehlermeldungen und Eloquent-Verhalten mit einem völlig anderen Verständnis.
2. Active Record vs. Data Mapper: die zwei Grundmuster
Jedes ORM folgt im Kern einem von zwei Architekturmustern. Beim Active-Record-Muster, wie es Eloquent verwendet, trägt das Entity-Objekt selbst die Verantwortung für seine Persistenz: $user->save() speichert das Objekt in seiner eigenen Tabelle. Das ist einfach zu verstehen und schnell produktiv, koppelt aber die Domänenlogik eng an die Datenbankschicht, was in komplexen Domänenmodellen zu Problemen führt.
Beim Data-Mapper-Muster, wie es Doctrine verwendet, kennt das Entity-Objekt die Datenbank überhaupt nicht. Ein separater Mapper übernimmt das Laden und Speichern, das Entity-Objekt bleibt ein reines Domänenobjekt ohne save()-Methode. Für ein eigenes einfaches ORM ist Data Mapper die lehrreichere Wahl, weil die Trennung zwischen Domäne und Persistenz explizit sichtbar bleibt und sich später leichter erweitern lässt.
<?php
declare(strict_types=1);
// Pure domain object, no database awareness (Data Mapper style)
final class User
{
private ?int $id = null;
public function __construct(
private string $email,
private string $name,
private bool $isActive = true,
) {
}
public function id(): ?int
{
return $this->id;
}
public function email(): string
{
return $this->email;
}
public function deactivate(): void
{
$this->isActive = false;
}
// Only the mapper is allowed to set the identity after INSERT
public function assignId(int $id): void
{
$this->id ??= $id;
}
}
3. Entity-Mapping mit Attributen und Reflection
Damit ein ORM weiß, welche Tabelle und welche Spalten zu einer Klasse gehören, braucht es Metadaten. PHP-Attribute, verfügbar seit PHP 8.0, sind dafür die natürliche Wahl: Ein eigenes #[Table]-Attribut markiert die Klasse, ein #[Column]-Attribut markiert jede zu persistierende Eigenschaft. Zur Laufzeit liest das ORM diese Attribute per Reflection aus und baut daraus die Mapping-Konfiguration, ohne dass separate XML- oder YAML-Dateien nötig sind.
Reflection erlaubt außerdem den Zugriff auf private Eigenschaften, was für ein ORM zentral ist: Domänenobjekte sollten ihre Eigenschaften nicht öffentlich exponieren, nur damit das ORM sie setzen kann. ReflectionProperty::setAccessible() ist seit PHP 8.1 nicht mehr nötig, da Reflection standardmäßig auf private Eigenschaften zugreifen kann, was den Mapping-Code deutlich vereinfacht.
<?php
declare(strict_types=1);
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class Table
{
public function __construct(public string $name)
{
}
}
#[Attribute(Attribute::TARGET_PROPERTY)]
final readonly class Column
{
public function __construct(public ?string $name = null)
{
}
}
final class MetadataReader
{
/**
* @return array{table: string, columns: array<string, string>}
*/
public function readMetadata(string $className): array
{
$reflectionClass = new ReflectionClass($className);
$tableAttribute = $reflectionClass->getAttributes(Table::class)[0]
?? throw new LogicException("Missing #[Table] on {$className}");
/** @var Table $table */
$table = $tableAttribute->newInstance();
$columns = [];
foreach ($reflectionClass->getProperties() as $property) {
$columnAttributes = $property->getAttributes(Column::class);
if ($columnAttributes === []) {
continue;
}
/** @var Column $column */
$column = $columnAttributes[0]->newInstance();
$columns[$property->getName()] = $column->name ?? $property->getName();
}
return ['table' => $table->name, 'columns' => $columns];
}
}
4. Hydration: Datenbankzeilen in Objekte verwandeln
Hydration ist der Prozess, bei dem ein ORM eine Datenbankzeile, also ein assoziatives Array aus PDO, in ein vollständiges Objekt verwandelt, ohne den öffentlichen Konstruktor zu benutzen. Der Grund, warum der Konstruktor umgangen wird: Beim Erstellen eines neuen Objekts gelten andere Invarianten als beim Laden eines bereits existierenden. Ein neues User-Objekt braucht vielleicht Pflichtfelder im Konstruktor, während das Laden aus der Datenbank auch mit unvollständigen Zwischenzuständen umgehen können muss.
PHP bietet dafür ReflectionClass::newInstanceWithoutConstructor(), das ein Objekt ohne Konstruktoraufruf instanziiert. Anschließend setzt das ORM jede Eigenschaft einzeln über ReflectionProperty::setValue(). Dieser Ansatz ist genau das, was Doctrine intern für Hydration nutzt, und erklärt, warum in Doctrine-Entities durchaus ein Konstruktor mit Pflichtparametern stehen darf, ohne dass das Laden aus der Datenbank fehlschlägt.
<?php
declare(strict_types=1);
final class Hydrator
{
public function __construct(private MetadataReader $metadataReader)
{
}
/**
* @param array<string, mixed> $row
*/
public function hydrate(string $className, array $row): object
{
$metadata = $this->metadataReader->readMetadata($className);
$reflectionClass = new ReflectionClass($className);
// Bypass the constructor: loading has different invariants than creating
$entity = $reflectionClass->newInstanceWithoutConstructor();
foreach ($metadata['columns'] as $propertyName => $columnName) {
if (!array_key_exists($columnName, $row)) {
continue;
}
$property = $reflectionClass->getProperty($propertyName);
$property->setValue($entity, $row[$columnName]);
}
return $entity;
}
}
5. Unit of Work: Change-Tracking für Objekte
Ein häufiges Missverständnis bei ORM-Nutzung ist die Annahme, jede Änderung an einem Objekt löse sofort ein UPDATE aus. Tatsächlich sammelt ein ORM mit Unit-of-Work-Muster Änderungen und schreibt sie erst beim expliziten flush() in die Datenbank. Dafür muss das ORM wissen, welche Eigenschaften sich seit dem Laden geändert haben. Der einfachste Ansatz: Beim Hydrieren wird ein Snapshot der Originalwerte gespeichert, beim Flush wird der aktuelle Zustand mit diesem Snapshot verglichen.
Nur Eigenschaften, deren Wert sich tatsächlich geändert hat, landen im generierten UPDATE-Statement. Das reduziert die Größe der SQL-Abfrage und vermeidet unnötige Schreiblast auf Spalten, die unverändert geblieben sind. Diese Technik, Dirty Checking genannt, ist der Grund, warum Doctrine ohne explizite save()-Aufrufe pro Objekt auskommt und trotzdem nur die tatsächlich geänderten Daten überträgt.
<?php
declare(strict_types=1);
final class UnitOfWork
{
/** @var array<int, object> */
private array $managedEntities = [];
/** @var array<int, array<string, mixed>> */
private array $originalData = [];
public function __construct(
private PDO $pdo,
private MetadataReader $metadataReader,
) {
}
public function manage(object $entity, array $originalRow): void
{
$objectId = spl_object_id($entity);
$this->managedEntities[$objectId] = $entity;
$this->originalData[$objectId] = $originalRow;
}
// Dirty checking: only changed columns end up in the UPDATE statement
public function flush(): void
{
foreach ($this->managedEntities as $objectId => $entity) {
$metadata = $this->metadataReader->readMetadata($entity::class);
$reflectionClass = new ReflectionClass($entity);
$changes = [];
foreach ($metadata['columns'] as $propertyName => $columnName) {
$currentValue = $reflectionClass->getProperty($propertyName)->getValue($entity);
$originalValue = $this->originalData[$objectId][$columnName] ?? null;
if ($currentValue !== $originalValue) {
$changes[$columnName] = $currentValue;
}
}
if ($changes !== []) {
$this->applyUpdate($metadata['table'], $changes, $entity);
}
}
}
private function applyUpdate(string $table, array $changes, object $entity): void
{
$setClause = implode(', ', array_map(
static fn (string $column): string => "{$column} = :{$column}",
array_keys($changes),
));
$statement = $this->pdo->prepare(
"UPDATE {$table} SET {$setClause} WHERE id = :id"
);
$statement->execute([...$changes, 'id' => $entity->id()]);
}
}
6. Lazy Loading und Relationen abbilden
Relationen zwischen Entities, etwa ein User mit vielen Order-Objekten, sind einer der komplexesten Teile jedes ORM. Eager Loading lädt die verknüpften Daten sofort mit, was bei tiefen Objektgraphen schnell zu unnötig großen Abfragen führt. Lazy Loading lädt die Relation erst beim tatsächlichen Zugriff, üblicherweise über ein Proxy-Objekt, das die echte Klasse per Vererbung oder Interface imitiert und beim ersten Methodenaufruf die tatsächlichen Daten nachlädt.
Ein einfaches eigenes ORM kann Lazy Loading ohne generierte Proxy-Klassen umsetzen, indem die Relation als Closure gespeichert wird, die erst bei Bedarf ausgeführt wird. Das ist weniger elegant als echte Proxies, aber deutlich einfacher zu implementieren und für kleinere Projekte völlig ausreichend. Der zentrale Fallstrick bei jeder Relation, egal ob Eager oder Lazy, bleibt das N+1-Problem: eine Abfrage für die Hauptentität plus eine zusätzliche Abfrage pro verknüpftem Datensatz, wenn nicht mit JOIN oder einem separaten Batch-Load gearbeitet wird.
<?php
declare(strict_types=1);
final class LazyCollection
{
private ?array $items = null;
public function __construct(private Closure $loader)
{
}
// Data is fetched only on first actual access, not at hydration time
public function toArray(): array
{
return $this->items ??= ($this->loader)();
}
}
final class OrderRepository
{
public function __construct(private PDO $pdo)
{
}
public function findOrdersForUser(int $userId): LazyCollection
{
return new LazyCollection(function () use ($userId): array {
$statement = $this->pdo->prepare(
'SELECT * FROM orders WHERE user_id = :user_id'
);
$statement->execute(['user_id' => $userId]);
return $statement->fetchAll();
});
}
}
7. Ein minimaler Query Builder als Fundament
Jedes ORM braucht darunterliegend eine Möglichkeit, SQL programmatisch zusammenzusetzen, statt für jede Abfrage rohe Strings zu schreiben. Ein minimaler Query Builder mit Fluent Interface reicht dafür bereits aus: select(), where() und orderBy() bauen intern SQL-Fragmente zusammen und sammeln gebundene Parameter in einem separaten Array, damit am Ende ein PDO-Prepared-Statement mit korrekt zugeordneten Platzhaltern entsteht.
Der Query Builder muss dabei keine vollständige SQL-Grammatik abbilden, für ein einfaches ORM reichen SELECT, einfache WHERE-Bedingungen mit AND-Verknüpfung und eine ORDER BY-Klausel meist aus. Komplexere Abfragen mit mehreren JOINs oder Subqueries werden in der Praxis ohnehin oft als rohes SQL formuliert, selbst wenn ein vollwertiges ORM im Projekt eingesetzt wird, weil der Query Builder dort an seine Grenzen stößt.
8. Identity Map: doppelte Objekte vermeiden
Ohne zusätzliche Maßnahme erzeugt jedes Laden derselben Datenbankzeile ein neues PHP-Objekt, selbst wenn dieselbe Entität bereits an anderer Stelle im aktuellen Request geladen wurde. Das führt zu einem subtilen Problem: Zwei Objekte, die dieselbe Datenbankzeile repräsentieren, sind in PHP mit === nicht identisch, obwohl sie fachlich dieselbe Entität sein sollten. Änderungen an einem der beiden Objekte werden im anderen nicht sichtbar.
Die Identity Map löst das, indem sie geladene Entities in einer Map, indiziert nach Klasse und Primärschlüssel, zwischenspeichert. Bevor eine neue Datenbankzeile hydriert wird, prüft das ORM zuerst, ob bereits ein Objekt mit dieser Identität existiert, und gibt in diesem Fall die vorhandene Referenz zurück, statt ein neues Objekt zu erzeugen. Das garantiert, dass innerhalb eines Requests $user1 === $user2 gilt, sofern beide dieselbe ID repräsentieren, und ist ein zentraler Baustein jedes ernstzunehmenden ORM.
<?php
declare(strict_types=1);
final class IdentityMap
{
/** @var array<string, object> */
private array $entities = [];
public function get(string $className, int|string $id): ?object
{
return $this->entities[$this->key($className, $id)] ?? null;
}
public function set(string $className, int|string $id, object $entity): void
{
$this->entities[$this->key($className, $id)] = $entity;
}
private function key(string $className, int|string $id): string
{
return $className . '#' . $id;
}
}
final class EntityManager
{
public function __construct(
private PDO $pdo,
private Hydrator $hydrator,
private IdentityMap $identityMap,
) {
}
public function find(string $className, int $id): ?object
{
// Return the same object reference if it was already loaded once
$existing = $this->identityMap->get($className, $id);
if ($existing !== null) {
return $existing;
}
$statement = $this->pdo->prepare("SELECT * FROM users WHERE id = :id");
$statement->execute(['id' => $id]);
$row = $statement->fetch();
if ($row === false) {
return null;
}
$entity = $this->hydrator->hydrate($className, $row);
$this->identityMap->set($className, $id, $entity);
return $entity;
}
}
9. Eigenes ORM im Vergleich zu Doctrine und Eloquent
Ein eigenes ORM zu bauen ist ein Lernprojekt, keine generelle Empfehlung für die Produktion. Die folgende Tabelle zeigt, wo die selbstgebaute Variante gegenüber etablierten Bibliotheken steht.
| Kriterium | Eigenes ORM | Doctrine | Eloquent |
|---|---|---|---|
| Architekturmuster | Data Mapper (frei wählbar) | Data Mapper | Active Record |
| Lernwert | Sehr hoch | Gering, wird nur benutzt | Gering, wird nur benutzt |
| Migrationen, Caching, Events | Muss selbst gebaut werden | Vollständig integriert | Vollständig integriert |
| Produktionsreife | Nicht empfohlen | Ja | Ja |
| Community & Dokumentation | Keine | Sehr groß | Sehr groß |
In der Praxis bleibt der Wert eines selbstgebauten ORM im Verständnis, nicht im Einsatz. Wer Hydration, Unit of Work und Identity Map einmal selbst geschrieben hat, versteht Doctrine-Verhalten wie das berüchtigte "detached entity"-Problem oder unerwartete Dirty-Checking-Ergebnisse deutlich schneller als jemand, der die Bibliothek nur als Black Box benutzt.
Mironsoft
PHP-Domänenmodellierung und Datenbank-Architektur
Doctrine-Verhalten, das sich nicht erklären lässt?
Wir analysieren Entity-Mapping, Dirty Checking und Lazy Loading in bestehenden ORM-Setups und lösen die typischen N+1- und Detached-Entity-Probleme in Doctrine- und Eloquent-Projekten.
ORM-Audit
Mapping, Relationen und Query-Performance bestehender ORM-Nutzung prüfen
Domänenmodellierung
Saubere Trennung zwischen Domänenobjekten und Persistenzschicht entwerfen
Performance
N+1-Probleme und ineffizientes Eager Loading identifizieren und beheben
10. Zusammenfassung
Ein eigenes ORM zu bauen macht die Bausteine sichtbar, die in jeder produktionsreifen Bibliothek versteckt bleiben: Metadaten per Attribute und Reflection auslesen, Hydration ohne öffentlichen Konstruktor, Dirty Checking im Unit-of-Work-Muster und eine Identity Map, die verhindert, dass dieselbe Zeile mehrfach als unterschiedliche Objekte existiert. Keiner dieser Bausteine ist für sich komplex, in Kombination ergeben sie aber genau das Verhalten, das ein ORM wie Doctrine oder Eloquent im Alltag zeigt.
Wer diese Übung einmal durchgeführt hat, liest Fehlermeldungen zu detached Entities, unerwartete Lazy-Loading-Abfragen und Dirty-Checking-Anomalien mit einem völlig anderen Verständnis. Für die Produktion bleibt ein etabliertes ORM die richtige Wahl, aber das Wissen aus dem eigenen Nachbau zahlt sich bei jedem Debugging-Fall in einem bestehenden Projekt aus.
Ein eigenes ORM bauen — Das Wichtigste auf einen Blick
Architektur
Data Mapper trennt Domäne und Persistenz klarer als Active Record, besonders lehrreich für den Eigenbau.
Hydration
newInstanceWithoutConstructor() plus ReflectionProperty::setValue() umgeht Konstruktor-Invarianten beim Laden.
Change-Tracking
Unit of Work vergleicht aktuellen Zustand mit Original-Snapshot, nur geänderte Spalten landen im UPDATE.
Identität
Identity Map garantiert, dass dieselbe Datenbankzeile im Request immer dasselbe PHP-Objekt liefert.