Lesson 22 • Advanced
ORM Concepts in PHP 🗃️
By the end of this lesson you'll think about your database as PHP objects, not raw SQL — you'll build a tiny Active Record model, tell Active Record apart from Data Mapper, model relationships, and dodge the N+1 query trap that quietly slows real apps down.
What You'll Learn in This Lesson
- Explain what object-relational mapping is and when to use it
- Build a tiny Active Record model with create, find, where & save
- Tell Active Record apart from Data Mapper (entities & repositories)
- Model one-to-many and many-to-many relationships
- Spot and fix the N+1 problem with eager loading
- Describe migrations and where Eloquent & Doctrine fit
SELECT ... WHERE are still fuzzy, do Advanced PDO first — an ORM is a layer on top of exactly that.php file.php. The examples use in-memory arrays instead of a live database so they run anywhere — the Output panel under each shows exactly what to expect.1️⃣ What an ORM Is — and a Tiny Active Record
An ORM (Object-Relational Mapper) maps each database table to a PHP class, each row to an object, and each column to a property. Instead of writing SQL and unpacking arrays, you write User::find(1) and read $user->get("name"). The most common style is Active Record: the model class itself carries the methods to create, find, and save its rows — data and persistence live together. Here is a tiny but complete one, using an in-memory array in place of a real table so it runs anywhere.
<?php
// ACTIVE RECORD: the model class IS the table, and each object IS one row.
// The model knows how to save and find itself — data and persistence live together.
// A real ORM talks to a database via PDO; here an in-memory array stands in for
// the table so the whole CRUD lifecycle runs anywhere without a database.
abstract class Model
{
protected static array $rows = []; // the "table": id => attributes
protected static int $nextId = 1; // auto-increment primary key
public function __construct(protected array $attributes = []) {}
// CREATE: insert a new row and hand back a hydrated object.
public static function create(array $attrs): static
{
$attrs["id"] = static::$nextId++; // assign the next id
static::$rows[$attrs["id"]] = $attrs; // "INSERT" into the table
return new static($attrs); // return the saved object
}
// READ one: fetch a single row by primary key, or null if missing.
public static function find(int $id): ?static
{
return isset(static::$rows[$id]) ? new static(static::$rows[$id]) : null;
}
// READ many: a mini "WHERE field = value" filter.
/** @return static[] */
public static function where(string $field, mixed $value): array
{
$hits = array_filter(static::$rows, fn($r) => ($r[$field] ?? null) === $value);
return array_map(fn($r) => new static($r), $hits);
}
public function get(string $field): mixed { return $this->attributes[$field] ?? null; }
public function set(string $field, mixed $value): static
{
$this->attributes[$field] = $value;
return $this; // return $this so calls can chain
}
// UPDATE: write the current object's attributes back to the table.
public function save(): static
{
static::$rows[$this->attributes["id"]] = $this->attributes;
return $this;
}
}
final class User extends Model
{
// Each model needs its OWN storage, so re-declare these in the subclass.
protected static array $rows = [];
protected static int $nextId = 1;
}
// CREATE three users — create() inserts and returns the object.
User::create(["name" => "Alice", "role" => "admin"]);
User::create(["name" => "Bob", "role" => "user"]);
User::create(["name" => "Carol", "role" => "user"]);
// READ one by id.
$bob = User::find(2);
echo "find(2) -> " . $bob->get("name") . "\n"; // find(2) -> Bob
// READ many with a filter.
$users = User::where("role", "user");
$names = array_map(fn($u) => $u->get("name"), $users);
echo "role=user -> " . implode(", ", $names) . "\n"; // role=user -> Bob, Carol
// UPDATE: change an attribute, then save() writes it back.
$bob->set("name", "Bobby")->save();
echo "after save -> " . User::find(2)->get("name") . "\n"; // after save -> Bobby
?>find(2) -> Bob
role=user -> Bob, Carol
after save -> BobbyThat's the whole CRUD lifecycle — Create (create), Read (find / where), Update (set + save) — on a model. Swap the array for PDO and you've essentially got Laravel's Eloquent. Notice the static methods live on the class (the table) while get/save live on the object (the row): that split is the heart of Active Record.
2️⃣ Active Record vs Data Mapper
There are two ways to connect objects to rows. In Active Record the model saves itself — $user->save() — so business logic and database logic share one class (this is Eloquent's approach). In Data Mapper the model is a plain entity with no database code, and a separate mapper (or repository) does the persisting (this is Doctrine's approach). Active Record is quicker to write; Data Mapper keeps your domain objects clean and is favoured for large, complex domains. See both side by side.
<?php
// TWO PATTERNS for mapping objects to rows.
// 1) ACTIVE RECORD (Eloquent style): the model saves itself.
// Domain logic + database logic live in ONE class.
class ArArticle
{
public function __construct(public string $title, public int $id = 0) {}
public function save(): void
{
// Pretend SQL: the object writes itself to the DB.
echo "AR: INSERT INTO articles (title) VALUES ('{$this->title}')\n";
}
}
$a = new ArArticle("Hello");
$a->save(); // the object knows how to persist itself
// 2) DATA MAPPER (Doctrine style): a SEPARATE mapper persists a plain object.
// The Article is a pure "entity" — it has no idea a database exists.
class Article // a clean domain entity, no DB code at all
{
public function __construct(public string $title, public int $id = 0) {}
}
class ArticleMapper // the mapper is the ONLY thing that knows SQL
{
public function insert(Article $article): void
{
echo "DM: INSERT INTO articles (title) VALUES ('{$article->title}')\n";
}
}
$entity = new Article("Hello"); // entity stays clean...
(new ArticleMapper())->insert($entity); // ...mapper does the persistence
?>AR: INSERT INTO articles (title) VALUES ('Hello')
DM: INSERT INTO articles (title) VALUES ('Hello')An entity is just a plain object representing one thing in your domain (an Article, a User). A repository is the object you ask for entities — $repo->find(1), $repo->save($article) — so the entity never needs to know SQL exists. The trade-off in one line: Active Record optimises for speed of development, Data Mapper optimises for separation of concerns.
3️⃣ Relationships & the N+1 Problem
Tables relate to each other, and so do your objects. A one-to-many relationship (one User hasMany Posts; each Post belongsTo a User) is the everyday case. A many-to-many (a Post has many Tags, a Tag has many Posts) is stored through a third pivot table linking the two. ORMs let you walk these as properties: $user->posts. But how they fetch matters. Lazy loading fetches a relation only when you touch it; eager loading fetches it up front. Lazy loading inside a loop causes the dreaded N+1 problem — watch the query count.
<?php
// RELATIONSHIPS connect tables: one User hasMany Posts; each Post belongsTo a User.
// This example shows WHY eager loading matters — watch the query count.
$users = [1 => "Alice", 2 => "Bob"];
$posts = [ // post => owner's user_id
"Post A" => 1, "Post B" => 1, "Post C" => 2,
];
$queries = 0; // count "database trips"
// --- LAZY LOADING (the N+1 trap) ---
// You load N users (1 query), then load each user's posts ON DEMAND inside the
// loop. N users => N more queries => N + 1 total. With 100 users that's 101 trips.
$queries++; // 1 query: SELECT * FROM users
foreach ($users as $id => $name) {
$queries++; // +1 query PER user (the "N")
$owned = array_keys(array_filter($posts, fn($uid) => $uid === $id));
echo "$name lazily loaded: " . implode(", ", $owned) . "\n";
}
echo "Lazy total queries: $queries\n\n"; // Lazy total queries: 3
// --- EAGER LOADING (the fix) ---
// Load the users AND all their posts up front in just 2 queries, no matter how
// many users there are. Eloquent spells this User::with('posts')->get().
$queries = 0;
$queries++; // 1 query: SELECT * FROM users
$queries++; // 1 query: SELECT * FROM posts WHERE user_id IN (...)
foreach ($users as $id => $name) {
$owned = array_keys(array_filter($posts, fn($uid) => $uid === $id));
echo "$name eagerly loaded: " . implode(", ", $owned) . "\n";
}
echo "Eager total queries: $queries\n"; // Eager total queries: 2
?>Alice lazily loaded: Post A, Post B
Bob lazily loaded: Post C
Lazy total queries: 3
Alice eagerly loaded: Post A, Post B
Bob eagerly loaded: Post C
Eager total queries: 2With two users the lazy version is harmless (3 queries) — but with 100 users it becomes 101 queries, while the eager version stays at 2 no matter what. That's why real ORMs give you eager loading: Eloquent's User::with('posts')->get() or Doctrine's JOIN fetch in DQL. Reach for it the moment you read a relationship inside a loop.
4️⃣ Migrations, Eloquent & Doctrine
A migration is a versioned PHP file that describes a schema change — "create a users table", "add an email column" — so your database structure lives in code and replays on any machine in order. You run them with a command, and the tool records which have already been applied. The two dominant PHP ORMs sit on top of this idea: Eloquent (Laravel) uses Active Record; Doctrine (Symfony) uses Data Mapper. Here's the same model expressed in each, plus a migration — this snippet is for reading, not running (it needs a full framework).
<?php
// MIGRATION (Laravel): schema lives in code, runs with "php artisan migrate".
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamps();
});
// ELOQUENT (Active Record): the model declares its relationships.
class User extends Model {
public function posts() { return $this->hasMany(Post::class); }
}
$user = User::create(['name' => 'Alice']); // INSERT
$all = User::with('posts')->get(); // eager-load to avoid N+1
echo $user->posts->count(); // walk the relationship
// DOCTRINE (Data Mapper): a clean entity + the EntityManager persists it.
#[Entity]
class Product {
#[Id, GeneratedValue, Column] public int $id;
#[Column(type: 'string')] public string $name;
}
$product = new Product();
$product->name = 'Mouse';
$em->persist($product); // stage the entity...
$em->flush(); // ...then write all staged changes in one go
?>5️⃣ Your Turn: Finish the Model
Now you try. The Active Record model below is almost complete — fill in each ___ using the 👉 hint, then run it and check it against the Output panel.
<?php
// 🎯 YOUR TURN — finish this Active Record model, then run it.
// Two blanks marked ___ . Use the 👉 hints.
abstract class Model
{
protected static array $rows = [];
protected static int $nextId = 1;
public function __construct(protected array $attributes = []) {}
public static function create(array $attrs): static
{
$attrs["id"] = static::$nextId++;
static::$rows[$attrs["id"]] = $attrs;
return new static($attrs);
}
// 1) find() should look up a row by its id.
public static function find(int $id): ?static
{
// 👉 return a new static(...) built from static::$rows[$id]
// if it exists, otherwise return null.
return isset(static::$rows[$id]) ? new static(___) : null;
}
public function get(string $field): mixed { return $this->attributes[$field] ?? null; }
}
final class Product extends Model
{
protected static array $rows = [];
protected static int $nextId = 1;
}
Product::create(["name" => "Keyboard", "price" => 45]);
Product::create(["name" => "Mouse", "price" => 20]);
$p = Product::find(2); // grab the second product
echo $p->get("name") . " costs $" . ___ . "\n"; // 👉 get the "price" field
// ✅ Expected output:
// Mouse costs $20
?>Mouse costs $20___ with static::$rows[$id] and the second with $p->get("price"), then run it. Output should be one line.One more — this time about performance. Make the eager strategy use exactly two queries, no matter how many users there are.
<?php
// 🎯 YOUR TURN — avoid the N+1 problem by counting queries correctly.
// Fill in the two ___ blanks so the eager strategy uses exactly 2 queries.
$users = [1 => "Alice", 2 => "Bob", 3 => "Carol"];
$queries = 0;
// Eager loading: grab the users in ONE query, then ALL their posts in ONE more —
// regardless of how many users there are.
$queries += ___; // 👉 one query to SELECT the users
$queries += ___; // 👉 one query to SELECT every post for those users (IN clause)
foreach ($users as $name) {
echo "Loaded $name (no extra query)\n";
}
echo "Total queries: $queries\n";
// ✅ Expected output:
// Loaded Alice (no extra query)
// Loaded Bob (no extra query)
// Loaded Carol (no extra query)
// Total queries: 2
?>Loaded Alice (no extra query)
Loaded Bob (no extra query)
Loaded Carol (no extra query)
Total queries: 21 in each ___ — one query for the users, one for all their posts — so the total stays 2.Common Errors (and the fix)
- N+1 queries — the page is slow and the query log is huge. You're reading a relationship inside a loop with lazy loading, so each iteration fires its own query. Eager-load up front:
User::with('posts')->get(). Turn on query logging in development so you can actually see the count. - Fat models — one class doing everything. Piling validation, mailing, PDF export and business rules onto an Active Record model makes it impossible to test or reuse. Keep models about data and relationships; push other logic into service or action classes.
- No transactions around multi-step writes. If you insert an order, then its line items, and the second step fails, you're left with a half-saved order. Wrap related writes in a transaction (
DB::transaction(fn() => ...)/$em->wrapInTransaction(...)) so it's all-or-nothing. - Leaking the database into your domain. Passing raw ORM models or query builders into your business logic ties everything to the database shape and breaks the moment the schema changes. With Data Mapper, hand around clean entities and load them through a repository, so persistence stays at the edges.
Pro Tips
- 💡 Use the ORM for CRUD, raw SQL for reports. Heavy aggregations, sub-queries and unions are often clearer and faster in hand-written SQL — that's not cheating.
- 💡 Eager-load by default when iterating. The moment a loop touches
$row->relation, addwith(...). It's the single biggest ORM performance win. - 💡 Never hand-edit production schema. Every change goes through a migration so teammates and servers can reproduce it exactly.
📋 Quick Reference — Active Record vs Data Mapper
| Aspect | Active Record | Data Mapper |
|---|---|---|
| Who persists? | The model saves itself | A separate mapper / repository |
| The model is… | A row that knows SQL | A plain, DB-unaware entity |
| Save syntax | $user->save() | $em->persist($u); $em->flush(); |
| PHP example | Eloquent (Laravel) | Doctrine (Symfony) |
| Best for | Fast dev, typical apps | Large/complex domains |
| Trade-off | Logic + DB mixed | More classes, cleaner split |
Frequently Asked Questions
Q: What exactly is an ORM, and why not just write SQL?
An ORM (Object-Relational Mapper) maps database tables to PHP classes and rows to objects, so you work with $user->name instead of parsing result arrays. It saves you from writing repetitive CRUD SQL, handles prepared statements safely, and lets your code speak in domain terms (User, Order) rather than tables and columns. You still drop to raw SQL for complex reports — an ORM is for the 90% of everyday create/read/update/delete work, not a total replacement for SQL.
Q: What is the difference between Active Record and Data Mapper?
In Active Record the model class also knows how to persist itself: $user->save() writes the object straight to its table — fast to build, used by Laravel's Eloquent. In Data Mapper the entity is a plain object with no database code, and a separate mapper or repository handles persistence — used by Doctrine. Active Record is simpler for typical apps; Data Mapper keeps your domain objects clean and is favoured for large, complex domains where you want persistence concerns fully separated.
Q: What is the N+1 query problem and how do I fix it?
It happens when you load N parent rows with one query, then trigger one extra query for each parent's related rows — N + 1 queries total. Loading 100 users and then reading each user's posts becomes 101 database round-trips, which is slow. The fix is eager loading: tell the ORM to fetch the related rows up front in a single extra query, e.g. Eloquent's User::with('posts')->get(), which turns 101 queries into 2.
Q: When should I use lazy loading versus eager loading?
Lazy loading fetches related data only when you actually touch it — fine when you might not need the relation at all. Eager loading fetches it together with the parent in one go — best when you know you'll loop over the relation, because it prevents the N+1 problem. Rule of thumb: lazy-load by default for single records, eager-load (with(...)) whenever you iterate a collection and read a relationship inside the loop.
Q: What are migrations, and should I edit the database by hand instead?
A migration is a versioned PHP file that describes a schema change — create a table, add a column — so your database structure lives in code alongside your app and is replayable on any machine. You run them with a command (php artisan migrate in Laravel, doctrine:migrations:migrate in Symfony) and they run in order, tracking which have already been applied. Editing the database by hand is risky because teammates and production can't reproduce the change; migrations make schema changes reviewable, shareable, and reversible.
Mini-Challenge: A Repository
No code is filled in this time — just a brief and an outline. Build a Data Mapper-style repository yourself, run it on onecompiler.com/php or your own machine, then check your result against the expected output in the comments. This is exactly the write-run-check loop you'll use on every real model.
<?php
// 🎯 MINI-CHALLENGE: a Repository for the Data Mapper pattern.
// No code is filled in — work from the steps, then run it.
//
// 1. Make a plain entity class Task with public properties:
// string $title and bool $done = false (NO database code in it).
// 2. Make a TaskRepository class with:
// - a private array $tasks = []; // its in-memory store
// - add(Task $task): void // push the task onto $tasks
// - allDone(): array // return only tasks where $done is true
// 3. Create two Tasks, mark one $done = true, add both to the repository.
// 4. echo the title of every task returned by allDone().
//
// Why a repository? It keeps persistence OUT of the entity (Data Mapper style),
// so Task stays a clean domain object the rest of your app can trust.
//
// ✅ Expected output (example):
// Done: Ship release
// your code here
?>Task entity and a TaskRepository with add() and allDone(), then echo the done tasks. Keep all storage in the repository, not the entity.🎉 Lesson Complete!
- ✅ An ORM maps tables → classes, rows → objects, columns → properties
- ✅ Active Record: the model saves itself (
$user->save()) — Eloquent's style - ✅ Data Mapper: a clean entity + a repository/mapper persists it — Doctrine's style
- ✅ Relationships (one-to-many, many-to-many) let you walk data as objects
- ✅ Eager loading beats lazy loading in a loop — it kills the N+1 problem
- ✅ Migrations keep your schema in versioned, replayable code
- ✅ Next lesson: Authentication Deep Dive — sessions, password hashing, JWT and API tokens
Sign up for free to track which lessons you've completed and get learning reminders.