Building Your Own Simple ORM: Understanding Object-Relational Mapping
AI generated
<?php
8.4
PHP · ORM · Design Patterns · Databases
Building Your Own Simple ORM
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.

19 min read ORM · Active Record · Data Mapper · Reflection PHP 8.4 · PDO

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.

11. FAQ: Building Your Own ORM

1Homemade ORM in production?
No, it is a learning exercise. Doctrine or Eloquent remain the right choice for production.
2Active Record vs. Data Mapper?
Active Record carries persistence logic in the entity, Data Mapper separates entity and database fully.
3Why bypass the constructor?
Different invariants when loading vs creating. newInstanceWithoutConstructor() allows direct setting.
4What is dirty checking?
Comparison against an original snapshot, only changed properties land in the UPDATE statement.
5Why reflection in an ORM?
Reads attributes for mapping and grants access to private properties without public setters.
6What is an identity map?
Stores loaded entities by class and ID, prevents duplicate inconsistent objects.
7Lazy loading without proxy classes?
Closure as a placeholder, only executed on actual access.
8What is the N+1 problem?
A main query plus one additional query per relation instead of one combined query.
9Does it need a query builder?
A minimal query builder with SELECT, WHERE, and ORDER BY suffices as a foundation.
10Attributes instead of XML for mapping?
Attributes sit directly on the class, versioned with it, no separate parser needed.