ORM & Persistence
Persistence with Eloquent and Doctrine — Active Record versus Data Mapper, migrations, the N+1 problem and how to detect it in production, lazy versus eager loading, mass assignment, seeders and factories, and whether a repository layer earns its keep.
10 questions
JuniorTheoryVery commonWhat does it mean that Laravel's ORM Eloquent implements Active Record?
What does it mean that Laravel's ORM Eloquent implements Active Record?
Active Record means a model class is a table row: User maps to the users table, an instance holds one row, and that same object performs its own persistence — User::find(1), $user->save(), $user->delete(). Data and database access live together in one class.
Common mistakes
- ✗Confusing
Active RecordwithData Mapper, where a separate manager persists a plain object - ✗Thinking the model only describes the table and something else performs the saving
- ✗Assuming an
Active Recordmodel can be unit-tested cleanly without a database
Follow-up questions
- →What does coupling the model to persistence cost you when you unit-test business rules?
- →How would you keep domain logic out of an
Eloquentmodel that already carries persistence?
JuniorTheoryVery commonWhat is the difference between lazy and eager loading of an ORM relation?
What is the difference between lazy and eager loading of an ORM relation?
Lazy loading fires a query the first time you touch the relation: reading $post->comments hits the database right there. Eager loading fetches the relation up front alongside the parent — Post::with('comments')->get() costs two queries in total, however many posts come back.
Common mistakes
- ✗Believing eager loading always adds a
JOINrather than a secondWHERE … IN (…)query - ✗Thinking lazy loading is free because no query appears until the property is read
- ✗Eager-loading every relation by default, pulling megabytes the page never renders
Follow-up questions
- →What does
Model::preventLazyLoading()change, and when would you switch it on? - →When is lazy loading the right choice — what does eager loading cost you there?
JuniorTheoryCommonWhat is a database migration, and why are schema changes kept in the repository?
What is a database migration, and why are schema changes kept in the repository?
A migration is a versioned class that changes the schema — up() applies the change, down() reverts it. The framework records which files already ran in a migrations table, so php artisan migrate replays only the new ones and brings any database to the exact shape the shipped code expects.
Common mistakes
- ✗Thinking the ORM diffs models against the live schema instead of replaying ordered files
- ✗Forgetting that the
migrationstable is what makes a re-run skip everything already applied - ✗Writing a
down()that does not actually revertup(), so a rollback corrupts the schema
Follow-up questions
- →Why must a migration never import an application model class to read or write data?
- →What breaks when two developers merge migrations created with interleaved timestamps?
JuniorTheoryCommonWhat is the difference between a seeder and a model factory in Laravel?
What is the difference between a seeder and a model factory in Laravel?
A factory is a per-model blueprint that builds one instance with plausible fake attributes — User::factory()->count(10)->create(). A seeder is a runnable class that fills the database, usually by calling factories. The factory is the recipe for one model; the seeder decides what gets inserted and how much.
Common mistakes
- ✗Swapping the roles — calling the fake-attribute blueprint a seeder
- ✗Not knowing
make()builds an unsaved instance whilecreate()persists it - ✗Seeding production reference data with a factory full of random fake values
Follow-up questions
- →When would you seed real reference data instead of generating it with a factory?
- →How do factory states let one blueprint produce an admin user and a banned user?
MiddleTheoryCommonHow does the ORM approach Data Mapper in Doctrine differ from Active Record in Eloquent?
How does the ORM approach Data Mapper in Doctrine differ from Active Record in Eloquent?
Data Mapper separates the entity from persistence: a Doctrine entity is a plain PHP object with no database code, and a separate EntityManager maps it to a table on persist() and flush(). Eloquent fuses the two. The trade is writing speed against a domain model you can unit-test with no database.
Common mistakes
- ✗Thinking a
Doctrineentity carriessave(), so persistence still lives on the object - ✗Reducing the difference to syntax and missing that
Data Mapperbuys domain testability - ✗Assuming
Data Mapperis strictly better, ignoring how much more ceremony it costs
Follow-up questions
- →What does the unit-of-work in
Doctrinedo betweenpersist()andflush()? - →On a CRUD-heavy admin panel, what does the ORM approach
Data Mappercost you for no gain?
MiddleTheoryCommonWhy does Eloquent demand $fillable or $guarded before Model::create($request->all())?
Why does Eloquent demand $fillable or $guarded before Model::create($request->all())?
Because create($request->all()) writes whatever keys the request happens to carry. With no whitelist, an attacker POSTs an extra is_admin=1 and mass-assigns a column you never exposed — over-posting, a genuine privilege-escalation bug. $fillable whitelists assignable columns; $guarded blacklists them.
Common mistakes
- ✗Treating
$fillableas a mapping or validation detail rather than a security boundary - ✗Setting
$guarded = []to silence the exception, which re-opens every column - ✗Assuming a column absent from the form cannot be sent — the client controls the body
Follow-up questions
- →Why is
$guardedriskier than$fillablewhen someone later adds a new column? - →How does a form request or explicit DTO remove the need to trust
$request->all()?
MiddleTheoryCommonWhat is the N+1 query problem, and how does eager loading eliminate it?
What is the N+1 query problem, and how does eager loading eliminate it?
One query loads N parents; then touching a lazy relation inside the loop fires one more query per parent — 1 + N round-trips. Eager loading collapses that: Post::with('comments')->get() runs one query for the posts and one WHERE post_id IN (…) for all comments, so the cost stays at two.
Common mistakes
- ✗Blaming the database or the connection pool instead of lazy loading inside a loop
- ✗Thinking
with()adds aJOINrather than issuing a secondWHERE … IN (…)query - ✗Fixing one relation with
with()while a nested relation keeps firing per row
Follow-up questions
- →How do you eager-load a nested relation, and what does
with('comments.author')cost? - →When is a projection with
select()a better fix than eager-loading the whole relation?
MiddlePerformanceOccasionalA list page slows down as it grows. How do you prove the repeated-query problem N+1 is the cause?
A list page slows down as it grows. How do you prove the repeated-query problem N+1 is the cause?
You measure, not guess. Enable the query log — Laravel Debugbar, Telescope, or DB::listen() — and count queries per request: an N+1 shows as the same SELECT repeated with only the bound id changing, its count rising with the row count. Confirm the fix by re-measuring the query count, not the clock.
Common mistakes
- ✗Reasoning from the code review alone instead of counting the queries an actual request fires
- ✗Looking in the slow-query log, where N fast queries never individually cross the threshold
- ✗Declaring victory on wall-clock time on a dev dataset too small for N to matter
Follow-up questions
- →How would you catch an
N+1in CI before it ships, rather than in production? - →Why is the query count a better regression signal here than the request's wall-clock time?
MiddleDesignOccasionalYour team has a Laravel application where controllers and jobs call Eloquent models directly — User::where(...)->get() appears in dozens of places. A colleague proposes wrapping every model behind the data-access abstraction Repository: an interface such as UserRepositoryInterface with methods like findActive(), bound in a service provider to an EloquentUserRepository. The stated goals are testability and being able to swap Eloquent for Doctrine later. Argue for or against adopting this layer. Address what it genuinely buys, whether the swap-the-ORM justification holds for an Active Record codebase, what it costs in indirection and lost query-builder expressiveness, and under what conditions you would introduce it anyway.
Your team has a Laravel application where controllers and jobs call Eloquent models directly — User::where(...)->get() appears in dozens of places. A colleague proposes wrapping every model behind the data-access abstraction Repository: an interface such as UserRepositoryInterface with methods like findActive(), bound in a service provider to an EloquentUserRepository. The stated goals are testability and being able to swap Eloquent for Doctrine later. Argue for or against adopting this layer. Address what it genuinely buys, whether the swap-the-ORM justification holds for an Active Record codebase, what it costs in indirection and lost query-builder expressiveness, and under what conditions you would introduce it anyway.
The swap-the-ORM goal is close to fiction: Eloquent models leak through return types, so the abstraction is never clean. What a repository does buy is a named, testable seam — query intent lives in one place and can be faked in tests. The honest rule: add it where query logic is genuinely reused or complex, not as a blanket layer.
Common mistakes
- ✗Selling the layer on a hypothetical ORM swap that an
Active Recordmodel makes impossible anyway - ✗Writing pass-through repositories that mirror the model method-for-method and buy nothing
- ✗Claiming
Eloquentis untestable — a database-backed test or an in-memory fake already works
Follow-up questions
- →If a repository returns
Eloquentmodels, in what sense is the ORM actually abstracted away? - →How would a query-object or read-model approach solve the same problem with less indirection?
SeniorDesignOccasionalA users table with 40 million rows must lose its full_name column and gain first_name and last_name. The application runs on several servers behind a load balancer, deploys are rolling, and the table is written to continuously. A single migration that renames and backfills would lock the table and would also break whichever servers still run the old code. Design a migration sequence that never takes the service down. Cover the ordering of schema and code releases, how old and new code coexist against one schema, how you backfill 40 million rows without locking, and how you finally retire the old column safely — including what you would do if the backfill has to be aborted midway.
A users table with 40 million rows must lose its full_name column and gain first_name and last_name. The application runs on several servers behind a load balancer, deploys are rolling, and the table is written to continuously. A single migration that renames and backfills would lock the table and would also break whichever servers still run the old code. Design a migration sequence that never takes the service down. Cover the ordering of schema and code releases, how old and new code coexist against one schema, how you backfill 40 million rows without locking, and how you finally retire the old column safely — including what you would do if the backfill has to be aborted midway.
Expand, then contract. Ship an additive migration first: add the new nullable columns, nothing else. Then deploy code that writes both shapes and reads the old one — every server is now safe on either schema. Backfill in throttled batches, flip reads to the new columns, and only then ship the contract migration dropping full_name.
Common mistakes
- ✗Shipping schema and code in one step, so rolling servers run code that mismatches the schema
- ✗Backfilling 40 million rows in a single
UPDATE, which locks the table for minutes - ✗Dropping the old column in the same release that stops writing to it, leaving no rollback
Follow-up questions
- →Why must the backfill run as a job or command rather than inside the migration file itself?
- →How do you make the batched backfill resumable after it is aborted halfway through?