One Class, One Table: How a Model Turns a Row Into an Object
An Eloquent model is a PHP class that knows its table. The templates from Blade Templates and Layouts render whatever the controller hands them; a model is how the row behind that data reaches the view as an object. Extend Model, put the class in app/Models, and by convention App\Models\Order maps to the orders table — plural, snake-cased, no configuration needed. Override the guess with protected $table = 'sales_orders'; when the schema says otherwise.
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
protected $fillable = ['user_id', 'reference', 'total_cents', 'status'];
protected $casts = [
'total_cents' => 'integer',
'metadata' => 'array',
'placed_at' => 'datetime',
];
}
Reading is one-liners:
$order = Order::find(42); // Order|null
$order = Order::findOrFail(42); // throws ModelNotFoundException
$shipped = Order::where('status', 'shipped')->latest()->take(20)->get(); // Collection
$first = Order::where('total_cents', '>', 10000)->first();
get() returns a Collection — chainable, iterable, with map, filter, groupBy, and lazy collections for large sets. first() returns one model or null. find() takes a primary key; findOrFail throws, which route model binding converts to a 404 as you saw in Routing and Controllers.
Writing brings mass assignment into focus:
$order = Order::create([
'user_id' => $userId,
'reference' => 'ORD-' . now()->format('YmdHis'),
'total_cents' => 4999,
'status' => 'pending',
]);
$order->status = 'paid';
$order->save();
Order::where('status', 'pending')->where('placed_at', '<', now()->subDays(30))->update(['status' => 'expired']);
$fillable is the security boundary for create() and update(): only listed keys pass through, so a request that smuggles user_id or is_admin into the payload is silently dropped rather than written. The alternative, protected $guarded = [];, opens everything — useful in seeds and tests, dangerous in anything that touches request data. The default is a silent drop — enable Model::preventSilentlyDiscardingAttributes() (or fully guard with $guarded = ['*']) and the same payload throws MassAssignmentException instead, which is how an interviewer probes whether you know the difference.
$casts converts at the model layer: JSON columns become arrays you can index, datetime columns become Carbon instances with ->diffInDays() and ->format(), and encrypted casts write ciphertext. The $hidden property keeps sensitive columns out of toArray() and JSON responses — put remember_token and password hashes there by default. And the timestamps columns created_at/updated_at are maintained automatically unless you set public $timestamps = false.
One caution on now() and Carbon: they honor the app.timezone config. Store UTC in the database and convert for display — mixing timezones inside the database is a data corruption you cannot easily undo.
Query scopes pick up where find() and where() leave off. A local scope on the model names a reusable constraint and starts with scope:
public function scopeShipped($query)
{
return $query->where('status', 'shipped');
}
public function scopeBigSpenders($query, int $cents = 10000)
{
return $query->where('total_cents', '>=', $cents);
}
Order::shipped()->bigSpenders(50000)->get();
Scopes compose with any other builder call, take parameters, and — the real win — keep business filters out of controllers. When the definition of "shipped" changes, it changes in one place. Global scopes go further: they apply to every query on the model, which is how SoftDeletes works (a WHERE deleted_at IS NULL clause you stop thinking about) and how you would enforce tenant isolation on a multi-tenant app. The trait SoftDeletes adds the deleted_at column handling, restore() to undo, and withTrashed() / onlyTrashed() to opt back in — just remember that unique constraints in the database still see soft-deleted rows, so a unique email column needs a compound index including deleted_at or you will fight constraint violations on re-registration.
Factory-generated models deserve a mention here because they show up in tests and seeds: Order::factory()->count(10)->create() builds rows from database/factories/OrderFactory.php, and you can point relationships at other factories (User::factory()->hasOrders(3)->create()) so the fixtures mirror real data shapes instead of hand-built arrays drifting apart from the schema.
hasMany, belongsTo, belongsToMany: Describing Your Schema in Code
Relationships are methods on models that return a relationship instance. Each method names the related model, and Eloquent infers the foreign key from the class names — Order gets user_id, Post gets category_id. Declare both sides so reads work in either direction:
// app/Models/User.php
class User extends Model
{
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
}
// app/Models/Order.php
class Order extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
Calling them as properties runs the query and returns results — this is lazy loading, one query per access:
$user = User::find(1);
echo $user->orders->count(); // SELECT * FROM orders WHERE user_id = 1
The common relationship families:
| Method | Shape | Example |
|---|---|---|
hasOne |
one related row | User::hasOne(Profile::class) |
hasMany |
many rows | User::hasMany(Order::class) |
belongsTo |
the inverse side | Order::belongsTo(User::class) |
belongsToMany |
many-to-many via pivot | User::belongsToMany(Role::class) |
hasManyThrough |
grandchild rows | Country::hasManyThrough(User::class, Order::class) |
morphMany |
polymorphic one-to-many | Post::morphMany(Comment::class, 'commentable') |
Many-to-many needs a pivot table whose name is the two model names alphabetized — role_user for Role/User — with the two foreign keys. Extra columns on the pivot come through withPivot:
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class)->withPivot('expires_at')->withTimestamps();
}
$user->roles; // Collection of Role, pivot attached
$user->roles()->attach($roleId); // add
$user->roles()->detach($roleId); // remove
$user->roles()->sync($roleIds); // make the list exactly this
sync is the one to remember: it diffs the current state against the desired list, attaching and detaching as needed, which replaces the manual loop-and-compare code that invites bugs.
Customize the inferred keys when your schema deviates:
return $this->hasMany(Order::class, 'customer_id'); // non-standard FK
return $this->belongsTo(User::class, 'creator_id'); // custom FK on this side
Accessing a relationship that does not exist returns null (for belongsTo/hasOne) or an empty Collection (for hasMany), so template code like {{ $order->user?->name }} degrades gracefully. Relationship methods must be methods, not properties holding a query — the method form is what lets Eloquent return a Builder so you can chain constraints: $user->orders()->where('status', 'shipped')->count() runs a COUNT query instead of loading every row.
Accessing a relationship as a property loads the full result set into memory; when you only need to check existence or count, the builder form keeps the work in SQL:
$user->orders()->exists(); // SELECT EXISTS(SELECT 1 ...)
$user->orders()->count(); // SELECT COUNT(*)
$user->orders()->max('total_cents');
$user->orders()->where('status', 'refunded')->exists();
The difference between $user->orders (Collection, all rows hydrated) and $user->orders()->count() (integer, no hydration) is the same N+1 discipline you will meet in the next chapter, applied one call at a time.
Collection methods on a loaded relation still have their place for post-processing you cannot express in SQL — groupBy, keyBy, sum over a transformed attribute — but each one holds every row in PHP memory, so on large result sets the SQL form is not just faster, it is the difference between a request that fits in a fixed memory limit and one that does not. Related: when a relationship query grows a WHERE clause you reuse, move it into a scope on the related model and call the scope through the relation ($user->orders()->shipped()) — the constraint stays declared in one place and still executes in the database.
Eloquent also lets you save with the relationship in one call: $order->user()->associate($user); $order->save(); sets the foreign key for you, and dissociate() clears it. For children, $order->items()->save($item) fills the foreign key on the item. These small helpers eliminate the class of bug where you forget to set the foreign key manually and store an orphaned row.
The N+1 Problem: Spotting Lazy Loading and Fixing It with with()
This is the performance question every Laravel interview asks. Render a list of 25 orders, each showing the buyer's name:
foreach (Order::all() as $order) {
echo $order->user->name; // lazy load: one extra query PER ORDER
}
The list query is one; the $order->user access fires one more per iteration — 26 queries for what should be 2. That is the N+1 problem: 1 query for the list plus N queries for the relation. It hides in templates ({{ $post->author->name }} inside a @foreach) and grows invisible until a page that worked with 10 rows crawls with 10,000.
Eager loading collapses it — declare the relations up front and Eloquent batches them into one query each:
$orders = Order::with('user')->get(); // 1 orders query + 1 users query (WHERE id IN (...))
foreach ($orders as $order) {
echo $order->user->name; // no query — already loaded
}
Nest for deeper graphs and constrain while you are there:
$orders = Order::with([
'user',
'items.product',
'payments' => fn ($q) => $q->where('status', 'captured'),
])->get();
Four queries total regardless of how many orders you fetched. When you need a count instead of the rows, do not load the relation at all:
$users = User::withCount('orders')->get();
$users[0]->orders_count; // COUNT(*) computed in SQL, zero extra models hydrated
withSum, withAvg, withMax follow the same pattern — aggregation belongs in the database, not in a PHP ->count() over a loaded Collection.
Detection is the interview answer that separates candidates. Laravel can fail loudly on lazy loads in development:
// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());
Every accidental lazy load then throws a LazyLoadingViolationException naming the model and relation, with a stack trace pointing at the offending line — instead of a silently slow page. To see the queries without throwing, log them:
DB::listen(function (QueryExecuted $query) {
logger()->debug($query->sql.' — '.$query->time.'ms', ['bindings' => $query->bindings]);
});
or install a package with a query sidebar. The working loop is: enable preventLazyLoading in development, fix every violation with with() or withCount, and confirm with the query log that a page issues a flat number of queries independent of row count. That last property — queries that do not scale with N — is what interviewers are listening for.
Eager loading and lazy loading are different decisions. "Lazy" here does not refer to Laravel's lazy collections; it means the relation query fires the moment you touch the property, long after the parent query finished. Prefetching with with() is the default choice whenever you know you will read the relation — which, in a list view, is nearly always. Laravel even helps you remember: strict mode's preventLazyLoading exception fires at the access site, so the stack trace lands on the exact line to edit.
Watch a related trap when eager loading inside loops of separate queries. This reintroduces N+1 in disguise:
foreach ($userIds as $id) {
Order::with('user')->where('user_id', $id)->get(); // still N queries
}
The with() is doing real work per iteration; batch the parents first (one query with a WHERE id IN) and eager load once. The query log makes this visible immediately — you see the same SQL repeated with a single bound parameter.
Interviewers often extend the question to queues and serialization: a queued job receives the model's ID, not the instance, so relations loaded at dispatch time are not carried along — the worker re-queries. That is usually what you want (fresh state when the job runs), and it is why eager loading belongs in the job's handle() method, at the moment the data is needed, rather than before dispatch. The habit generalizes: load relations where you use them, batch them with with(), and measure with the query log rather than trusting your intuition about how many queries a page issues. Once rows move reliably through relationships into views, the remaining exposure is what happens when a browser sends data back — Validating Requests puts a gate in front of every write.
Quiz
1. Order::all() is rendered in a Blade loop that prints {{ $order->user->name }} for 50 orders. How many queries run, and what is the fix?
2. In a non-production environment, Model::preventLazyLoading() is enabled. A controller accesses $post->author->name on a single post loaded via Post::find(1). What happens?
3. You need each user row plus the COUNT of their orders, but no order rows. Which approach is correct and efficient?
4. A model has protected $fillable = ['name', 'email'] and the controller calls $user->update($request->all()). A request sends name, email, and is_admin=1. What happens to is_admin?
Flashcards
Question
How does Eloquent decide which table a model uses, and how do you override it?
Click to reveal answer
Answer
By convention it pluralizes and snake-cases the class basename: App\Models\Order maps to orders, OrderItem to order_items. Override with protected $table = 'sales_orders' when the schema differs; the primary key defaults to id and timestamps to created_at/updated_at.
Question
What does $fillable protect against, and what does $guarded = [] do?
Click to reveal answer
Answer
$fillable is an allowlist for mass assignment (create/update/fill): keys outside it are silently dropped, blocking request payloads that smuggle user_id or is_admin. $guarded = [] disables the protection entirely — acceptable in seeds and tests, unsafe anywhere request data flows in.
Question
Name the four most-used Eloquent relationships and the foreign-key convention each assumes.
Click to reveal answer
Answer
hasOne and hasMany on the parent (FK on the child table: user_id for User->orders); belongsTo on the child (same FK, read upward); belongsToMany via an alphabetical pivot table (role_user) holding both foreign keys. Override the inferred key with a second argument: belongsTo(User::class, 'creator_id').
Question
What is the N+1 query problem and how do you both fix and prevent it?
Click to reveal answer
Answer
N+1 = one query for a list plus one lazy query per row when accessing a relation (51 queries for 50 rows). Fix with eager loading: Order::with('user')->get() batches into WHERE id IN (...). Prevent it in development with Model::preventLazyLoading(! app()->isProduction()), which throws on every lazy access.
Question
When do you use withCount instead of with, and what does it return?
Click to reveal answer
Answer
withCount('orders') when you need the number of related rows but not the rows themselves: it adds a COUNT subquery in SQL and exposes $user->orders_count, hydrating zero extra models. withSum/withAvg/withMax follow the same pattern for aggregates — keep computation in the database.
Revision Notes
Key Takeaways
- 1. A model maps to a pluralized snake_case table by convention; find/findOrFail/get/build the queries, and Collections give you map/filter/groupBy on the results
- 2. $fillable is a mass-assignment allowlist and the defense against request payloads smuggling privileged keys; casts convert JSON columns, datetimes, and encrypted values at the model layer
- 3. Relationship methods return builders — declare both sides, override inferred foreign keys with a second argument, and use sync() for many-to-many lists
- 4. N+1 is 1 query for the list plus 1 per row for each relation; with() batches relations into WHERE id IN (...) queries and nested/constrained eager loading keeps deeper graphs flat
- 5. preventLazyLoading turns silent slow pages into exceptions in development, withCount/withSum keep aggregates in SQL, and DB::listen shows exactly what your code sends
Interview Tips
- • "What is the N+1 problem?" — define it with numbers (50 rows → 51 queries), show with('user') reducing it to 2, then name withCount for the count-only case and eager loading for nested graphs
- • "How does Eloquent know which table and foreign key?" — plural snake_case class name and snake_case singular parent + _id; then show you can override both, which proves the convention is knowledge not magic
- • "Explain mass assignment protection" — $fillable allowlist on create/update, MassAssignmentException when totally guarded, and why $request->all() into update() should be $request->validated()
- • "How would you find N+1s in an existing app?" — enable preventLazyLoading in development, or watch the query log/Debugbar; a page is healthy when its query count is constant as row count grows
Cheat Sheet
Eloquent ORM — Cheat Sheet
Model basics:
class Order extends Model {
protected $fillable = ['user_id', 'reference', 'total_cents'];
protected $casts = ['total_cents' => 'integer', 'metadata' => 'array', 'placed_at' => 'datetime'];
protected $hidden = ['remember_token'];
}
Order::find(42); Order::findOrFail(42); // 404 via ModelNotFoundException
Order::where('status','shipped')->latest()->take(20)->get();
Order::create([...]); $order->save();
Relationships:
| Method | FK assumed | Notes |
|---|---|---|
hasOne / hasMany |
user_id on child |
pass 2nd arg to override |
belongsTo |
user_id on this model |
inverse side |
belongsToMany |
pivot role_user |
->withPivot(...)->withTimestamps() |
hasManyThrough |
via intermediate | grandchild access |
morphMany |
*_type + *_id |
polymorphic |
$user->roles()->sync($ids) diffs and attaches/detaches; attach/detach do exactly one op.
N+1:
Order::with('user')->get(); // 2 queries total
Order::with(['user', 'items.product', 'payments' => fn ($q) => $q->where('status','captured')])->get();
User::withCount('orders')->get(); // COUNT in SQL
Model::preventLazyLoading(! app()->isProduction()); // throw in dev
DB::listen(fn (QueryExecuted $q) => logger()->debug($q->sql, $q->bindings));
Rule: a page's query count must not grow with the number of rows it renders.