ORM and the persistence layer
An ORM maps table rows onto PHP objects — and that is exactly what creates the two problems interviews probe. First: reading a property stops being distinguishable from a database round trip. $post->comments looks like a field access, but it is a SELECT, and inside a loop over a hundred posts it is a hundred SELECTs. Second: $model->fill($data) looks like an assignment, but it writes whichever columns the client chose to send.
Hence the one claim that separates a candidate who has worked with an ORM from one who has merely called it: you must be able to see the generated SQL. Not guess at it — see it, in the query log. The topic's second thread is the persistence architecture itself: Active Record (Eloquent) fuses the model and its persistence into one class, Data Mapper (Doctrine) separates them, and that choice decides whether your domain can be tested without a database. The layer-by-layer breakdown is below.
Topic map
- Active Record — the
Eloquentmodel that is the table row: it finds itself, it saves itself — and it drags the database into every test. - Data Mapper — the
Doctrineentity as a plain PHP object, theEntityManagerwithpersist()/flush(), and the Unit of Work that computes the changes itself. - Migrations — schema as versioned code, the
migrationstable, an honestdown(), and expand/contract for a zero-downtime rollout. - Seeders and factories — the factory as a blueprint for one model, the seeder as a population script,
make()versuscreate(). - Lazy and eager loading — when the relation query actually fires, why
with()is not aJOINbut a secondSELECT ... IN (…), and the price of eager-loading "just in case". - The N+1 problem — how lazy loading inside a loop turns two queries into 1 + N, and how to prove it from the log rather than from a hunch.
- Mass assignment —
$fillable/$guardedas a security boundary, over-posting, and why the request body is written by the client, not by your form. - The Repository layer — what it actually buys, why the "we'll swap the ORM later" argument is fiction on
Active Record, and when it is nevertheless worth introducing.
Common Mistakes and Traps
| Mistake | Consequence |
|---|---|
Believing a Doctrine entity carries save() | That is Data Mapper: the entity knows nothing about the database, the EntityManager persists it |
Calling an Active Record model testable without a database | The model is the row: any test of it either hits a database or is not testing it |
Reducing Data Mapper versus Active Record to syntax | The difference is where persistence lives — and that decides whether the domain is testable |
Thinking with() adds a JOIN | Eager loading fires a second query, WHERE … IN (…), and stitches the result together in PHP |
| Assuming lazy loading is free because no query is visible | The query fires the moment you first touch the property — usually inside a loop |
Fixing N+1 on one relation while a nested one keeps firing | with('comments') does not cover $comment->author — you need with('comments.author') |
Hunting N+1 in the slow-query log | Each of the N queries is fast and never crosses the threshold — you must count the number of queries |
Declaring N+1 fixed based on timings on a dev dataset | With a hundred rows, N means nothing: measure the query count before and after |
| Eager-loading every relation you have | You pull megabytes the page never renders and trade N+1 for one enormous SELECT |
Treating $fillable as a mapping detail rather than a security boundary | create($request->all()) will write the is_admin=1 that was in no form you built |
Setting $guarded = [] to silence an exception | You just opened every column for writing — that is over-posting |
Writing a down() that does not reverse up() | The rollback breaks the schema worse than the failed migration would have |
| Shipping the schema and the code in one step | Some servers behind the load balancer run against a schema that does not exist yet, or no longer does |
| Populating production reference data with a factory | A factory generates random fake values — that is a seeder's job |
| Wrapping every model in a pass-through repository | A layer of indirection that mirrors the model method for method buys nothing |
Interview relevance
The ORM is where interviewers check whether you can read SQL you did not write. A candidate who answers the N+1 question with "the database is slow, we need an index" gives themselves away instantly: an index will not help, because each of those thousand queries is already fast — there are simply a thousand of them. A candidate who says "I turn on DB::listen() and count the queries per page" is already solving it.
Typical checks:
- How
Active Recorddiffers fromData Mapperand what each does to the testability of the domain. - What
N+1is, where the1 + Ncomes from, and whywith()is two queries rather than aJOIN. - How to prove an
N+1in production instead of guessing at it in review. - Why
$fillableis a security question, not a convenience one. - What the
migrationstable records and why a workingdown()matters. - Whether a repository over
Eloquentearns its keep, and what exactly it buys.
Common wrong answer: "with() does a JOIN, so it is one query." It is two — and that matters: a JOIN would multiply the parent across every child row, whereas WHERE post_id IN (…) fetches the relations in one separate query and stitches them together in PHP. The second classic failure is "$fillable is there to stop Laravel complaining". It is there to stop a client from writing a column that was never in your form: the request body is authored by the client, not by your HTML.