Orm Concepts
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.
Part of the free Php course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
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.
That's the whole CRUD lifecycle — C reate ( create ), R ead ( find / where ), U pdate ( 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.
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.
With 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).
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.
One more — this time about performance . Make the eager strategy use exactly two queries, no matter how many users there are.
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 , add with(...) . 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
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.
🎉 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
Practice quiz
What does an ORM (Object-Relational Mapper) map?
- URLs to controller methods
- Functions to HTTP routes
- Tables to classes, rows to objects, and columns to properties
- Files to database backups
Answer: Tables to classes, rows to objects, and columns to properties. An ORM maps each table to a class, each row to an object, and each column to a property, so you work with $user->name instead of arrays.
In the Active Record pattern, who is responsible for persisting a model?
- The model itself — e.g. $user->save() writes the object to its table
- A separate mapper or repository class
- The database driver alone
- A migration file
Answer: The model itself — e.g. $user->save() writes the object to its table. In Active Record the model knows how to save and find itself — data and persistence live together (Eloquent's style).
How does the Data Mapper pattern differ from Active Record?
- It stores everything in JSON files
- It does not use classes at all
- It requires the entity to extend the database connection
- The entity is a plain object with no database code, and a separate mapper/repository handles persistence
Answer: The entity is a plain object with no database code, and a separate mapper/repository handles persistence. Data Mapper keeps the entity clean (no SQL); a separate mapper persists it — Doctrine's style, favoured for large domains.
Which PHP ORM uses the Active Record pattern?
- Doctrine
- Eloquent (Laravel)
- PDO
- Flysystem
Answer: Eloquent (Laravel). Eloquent (Laravel) is Active Record; Doctrine (Symfony) is Data Mapper.
What is the N+1 query problem?
- Loading N parent rows with one query, then firing one extra query per parent for its related rows — N+1 total
- Running one query that returns too many rows
- A query that fails after N retries
- Using N+1 database connections at once
Answer: Loading N parent rows with one query, then firing one extra query per parent for its related rows — N+1 total. Loading 100 users then reading each user's posts on demand becomes 101 round-trips — slow and wasteful.
How do you fix the N+1 problem?
- Add more database servers
- Cache the entire database in memory
- Use eager loading to fetch the related rows up front in one extra query, e.g. Eloquent's User::with('posts')->get()
- Switch from PDO to mysqli
Answer: Use eager loading to fetch the related rows up front in one extra query, e.g. Eloquent's User::with('posts')->get(). Eager loading turns 101 queries into 2 by fetching parents and all their related rows up front.
When should you prefer eager loading over lazy loading?
- Always, for every single record
- Whenever you iterate a collection and read a relationship inside the loop
- Only when the database is small
- Never — lazy loading is always faster
Answer: Whenever you iterate a collection and read a relationship inside the loop. Lazy-load single records by default, but eager-load (with(...)) whenever a loop touches a relationship, to avoid N+1.
How is a many-to-many relationship (e.g. Posts and Tags) stored?
- In a single table with comma-separated values
- By duplicating rows in both tables
- It cannot be modelled by an ORM
- Through a third 'pivot' table linking the two
Answer: Through a third 'pivot' table linking the two. A many-to-many relationship is stored through a pivot table that links the two sides.
What is a migration in the context of an ORM?
- Moving the database to a new server
- A versioned PHP file that describes a schema change, so the structure lives in code and replays on any machine in order
- Converting Active Record to Data Mapper
- A backup of all the data
Answer: A versioned PHP file that describes a schema change, so the structure lives in code and replays on any machine in order. Migrations keep schema changes in versioned, replayable code (php artisan migrate / doctrine:migrations:migrate), not hand-edits.
Why is hand-editing the production schema discouraged?
- It is slower than a migration
- PHP forbids direct schema edits
- Teammates and production can't reproduce the change; migrations make schema changes reviewable, shareable, and reversible
- It deletes existing data automatically
Answer: Teammates and production can't reproduce the change; migrations make schema changes reviewable, shareable, and reversible. Hand edits aren't reproducible; every change should go through a migration so others can replay it exactly.
Continue this course
- Previous: Advanced PDO
- Next: Authentication Deep Dive