Understanding object-relational mapping from the ground up
Using Doctrine or Eloquent is easy, understanding why they work the way they do is not. Once you have built your own minimal ORM, hydration, unit of work, and identity map stop being abstract terms and become concrete decisions with clear trade-offs.
Table of Contents
- 1. Why understanding a homemade ORM helps
- 2. Active Record vs. Data Mapper: the two base patterns
- 3. Entity mapping with attributes and reflection
- 4. Hydration: turning database rows into objects
- 5. Unit of work: change tracking for objects
- 6. Lazy loading and mapping relations
- 7. A minimal query builder as the foundation
- 8. Identity map: avoiding duplicate objects
- 9. A homemade ORM compared to Doctrine and Eloquent
- 10. Summary
- 11. FAQ
1. Why understanding a homemade ORM helps
An ORM (Object-Relational Mapper) translates between the relational world of the database and the object-oriented world of PHP. Most developers use an existing ORM like Doctrine or Eloquent without ever understanding which problems that ORM solves internally. That very understanding is often missing when an ORM produces a surprising N+1 problem in production or an object appears to hold stale data even though the database was correctly updated.
Building your own, deliberately simple ORM is not a recommendation to write a production-ready ORM yourself, but an exercise to internalize the concepts behind every ORM: mapping between class and table, hydrating rows into objects, change tracking, and identity management. Once you have assembled these building blocks yourself, you read Doctrine error messages and Eloquent behavior with a completely different understanding.
2. Active Record vs. Data Mapper: the two base patterns
Every ORM follows one of two architectural patterns at its core. In the Active Record pattern, as used by Eloquent, the entity object itself is responsible for its own persistence: $user->save() stores the object in its own table. That is easy to understand and quick to be productive with, but it tightly couples domain logic to the database layer, which causes problems in complex domain models.
In the Data Mapper pattern, as used by Doctrine, the entity object does not know about the database at all. A separate mapper handles loading and saving, and the entity object stays a pure domain object without a save() method. For a homemade simple ORM, Data Mapper is the more instructive choice, since the separation between domain and persistence stays explicitly visible and is easier to extend later.
<?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 with attributes and reflection
For an ORM to know which table and which columns belong to a class, it needs metadata. PHP attributes, available since PHP 8.0, are the natural choice for this: a custom #[Table] attribute marks the class, a #[Column] attribute marks each property to be persisted. At runtime, the ORM reads these attributes via reflection and builds the mapping configuration from them, without needing separate XML or YAML files.
Reflection also grants access to private properties, which is central for an ORM: domain objects should not expose their properties publicly just so the ORM can set them. ReflectionProperty::setAccessible() is no longer needed since PHP 8.1, as reflection can access private properties by default, which significantly simplifies mapping code.
<?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: turning database rows into objects
Hydration is the process by which an ORM turns a database row, that is, an associative array from PDO, into a fully formed object without using the public constructor. The reason the constructor is bypassed: creating a new object follows different invariants than loading an already existing one. A new User object may require mandatory parameters in the constructor, while loading from the database must also cope with incomplete intermediate states.
PHP offers ReflectionClass::newInstanceWithoutConstructor() for exactly this, instantiating an object without calling the constructor. The ORM then sets each property individually via ReflectionProperty::setValue(). This approach is exactly what Doctrine uses internally for hydration, and explains why Doctrine entities are allowed to have a constructor with mandatory parameters without loading from the database ever failing.
<?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 for objects
A common misunderstanding when using an ORM is assuming every change to an object triggers an immediate UPDATE. In reality, an ORM using the unit of work pattern collects changes and only writes them to the database on an explicit flush(). For that, the ORM must know which properties changed since loading. The simplest approach: a snapshot of the original values is stored during hydration, and on flush the current state is compared against that snapshot.
Only properties whose value has actually changed end up in the generated UPDATE statement. That reduces the size of the SQL query and avoids unnecessary write load on columns that remain unchanged. This technique, called dirty checking, is the reason Doctrine gets by without explicit save() calls per object while still only transmitting the data that actually changed.
<?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 and mapping relations
Relations between entities, for example a User with many Order objects, are one of the most complex parts of any ORM. Eager loading pulls in the linked data immediately, which quickly leads to unnecessarily large queries for deep object graphs. Lazy loading loads the relation only on actual access, usually through a proxy object that mimics the real class via inheritance or an interface and fetches the actual data on first method call.
A simple homemade ORM can implement lazy loading without generated proxy classes by storing the relation as a closure that only executes when needed. That is less elegant than real proxies, but considerably easier to implement and entirely sufficient for smaller projects. The central pitfall with any relation, whether eager or lazy, remains the N+1 problem: one query for the main entity plus one additional query per linked record if no JOIN or separate batch load is used.
<?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. A minimal query builder as the foundation
Every ORM needs an underlying way to assemble SQL programmatically instead of writing raw strings for every query. A minimal query builder with a fluent interface already covers that: select(), where(), and orderBy() internally assemble SQL fragments and collect bound parameters in a separate array, so that a PDO prepared statement with correctly matched placeholders results at the end.
The query builder does not need to cover a full SQL grammar; for a simple ORM, SELECT, simple WHERE conditions joined with AND, and an ORDER BY clause are usually enough. More complex queries with multiple JOINs or subqueries are in practice often formulated as raw SQL anyway, even in projects that use a fully featured ORM, because the query builder reaches its limits there.
8. Identity map: avoiding duplicate objects
Without additional measures, every load of the same database row produces a new PHP object, even if the same entity was already loaded elsewhere in the current request. That leads to a subtle problem: two objects representing the same database row are not identical in PHP under ===, even though they should represent the same entity from a domain perspective. Changes to one of the two objects are invisible in the other.
The identity map solves this by caching loaded entities in a map indexed by class and primary key. Before a new database row is hydrated, the ORM first checks whether an object with this identity already exists and, if so, returns the existing reference instead of creating a new object. That guarantees that $user1 === $user2 holds within a request as long as both represent the same ID, and it is a central building block of any serious 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. A homemade ORM compared to Doctrine and Eloquent
Building your own ORM is a learning project, not a general recommendation for production. The table below shows where the homemade variant stands compared to established libraries.
| Criterion | Homemade ORM | Doctrine | Eloquent |
|---|---|---|---|
| Architecture pattern | Data Mapper (freely chosen) | Data Mapper | Active Record |
| Learning value | Very high | Low, just consumed | Low, just consumed |
| Migrations, caching, events | Must be built yourself | Fully integrated | Fully integrated |
| Production readiness | Not recommended | Yes | Yes |
| Community & documentation | None | Very large | Very large |
In practice, the value of a homemade ORM lies in understanding, not usage. Anyone who has written hydration, unit of work, and identity map themselves once understands Doctrine behavior like the notorious detached entity problem or unexpected dirty checking results considerably faster than someone who only ever used the library as a black box.
Mironsoft
PHP domain modeling and database architecture
Doctrine behavior that nobody can explain?
We analyze entity mapping, dirty checking, and lazy loading in existing ORM setups and solve the typical N+1 and detached entity problems in Doctrine and Eloquent projects.
ORM Audit
Reviewing mapping, relations, and query performance of existing ORM usage
Domain Modeling
Designing a clean separation between domain objects and the persistence layer
Performance
Identifying and fixing N+1 problems and inefficient eager loading
10. Summary
Building your own ORM makes visible the building blocks that stay hidden inside every production-ready library: reading metadata via attributes and reflection, hydration without the public constructor, dirty checking in the unit of work pattern, and an identity map that prevents the same row from existing as different objects multiple times. None of these building blocks is complex on its own, but combined they produce exactly the behavior an ORM like Doctrine or Eloquent shows day to day.
Once you have gone through this exercise, you read error messages about detached entities, unexpected lazy loading queries, and dirty checking anomalies with a completely different understanding. For production, an established ORM remains the right choice, but the knowledge from building your own pays off in every debugging session in an existing project.
Building Your Own ORM — The Essentials at a Glance
Architecture
Data Mapper separates domain and persistence more clearly than Active Record, especially instructive for a homemade ORM.
Hydration
newInstanceWithoutConstructor() plus ReflectionProperty::setValue() bypasses constructor invariants when loading.
Change tracking
Unit of work compares current state against an original snapshot, only changed columns land in the UPDATE.
Identity
Identity map guarantees the same database row always yields the same PHP object within a request.