Lesson 116·M15 Eloquent & the Database
Eloquent Relationships
OpenLearning Objectives
By the end of this lesson you should be able to:
- Apply the "which side has the foreign key" rule to any pair of models
- Tell
hasOnefrombelongsTo— and explain why the ownership side differs - Model a many-to-many with a pivot table, and name the pivot's default columns
- Explain
hasManyThroughwith a diagram, and when it beats a join - Use polymorphic
morphMany/morphTofor comments on posts and videos - Say what a pivot table is, what a polymorphic relationship is, and when to reach for
hasManyThrough
What are Eloquent Relationships?
Relationships define how rows in one table reference rows in another — the foreign keys —
and then wrap that in methods so $user->posts is the query.
class User extends Model
{
public function posts(): HasMany
{
return $this->hasMany(Post::class); // posts.user_id = users.id
}
}
$posts = $user->posts; // lazy-loads ALL of that user's posts
The method return type names the relationship class (HasMany, BelongsTo, BelongsToMany,
…). Eloquent's convention for the foreign key is the owner's table name + _id —
posts.user_id, comments.post_id — which is why a single method usually needs no
arguments at all.
users posts
┌─────┐ ┌──────────┐
│ id │◄───────│ user_id │ foreign key lives on posts
└─────┘ └──────────┘
1 … 1 …
2 … 1 … two posts, one user → hasManyMental Model
Relationships are just the foreign key, read from the correct side. One rule decides almost everything:
The table that owns the foreign key is the
belongsToside; the other side ishasOne/hasMany.
posts.user_id points at users.id, so Post belongsTo User and User hasMany Post.
That single rule gets you through one-to-one, one-to-many, and most of many-to-many.
| Shape | Methods | Where the FK lives |
|---|---|---|
| One-to-one | hasOne / belongsTo | On the dependent table (e.g. profiles.user_id) |
| One-to-many | hasMany / belongsTo | On the many-side table (posts.user_id) |
| Many-to-many | belongsToMany ×2 | Nowhere — a pivot table holds both keys |
| Through | hasManyThrough | On the middle table, then on the far table |
| Polymorphic | morphMany / morphTo / morphToMany | On the child, as *_type + *_id |
Visual Flow
$user->posts
│
▼
Eloquent reads the relationship definition
┌────────────────────────────────────────┐
│ hasMany(Post::class) │
│ → FK: posts.user_id │
│ → own key: users.id │
└────────────────────────────────────────┘
│
▼
query builder ┌───────────────┐
posts WHERE user_id = ?│ lazy → runs NOW │
└──────────────────────┴───────────────┘
│
▼
Collection<Post> ← the one user's posts
Accessing the property runs the query at that moment — that's lazy loading, and it's exactly what Lesson 117 turns into the N+1 problem.
How it Works: One-to-One — hasOne / belongsTo
Profile and User: a user has one profile, a profile belongs to one user.
class User extends Model
{
public function profile(): HasOne
{
return $this->hasOne(Profile::class); // profiles.user_id
}
}
class Profile extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class); // profiles.user_id
}
}
Migration — the foreign key lives on the profile table:
Schema::create('profiles', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('avatar_url')->nullable();
$table->timestamps();
});
Generated SQL when you access it:
SELECT * FROM profiles WHERE profiles.user_id = 1 LIMIT 1
Tip
Which side is hasOne, which is belongsTo? The side that holds the foreign key
(profiles) is belongsTo. The side that merely references it (users) is hasOne.
"Has" always means "I don't carry the key — the other table points at me."
How it Works: One-to-Many — hasMany / belongsTo
The most common shape in an interview: posts on a blog.
class User extends Model
{
public function posts(): HasMany
{
return $this->hasMany(Post::class); // posts.user_id
}
}
class Post extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class); // posts.user_id
}
}
Migration — many-side table posts carries the key:
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->text('body');
$table->timestamps();
});
Generated SQL:
SELECT * FROM posts WHERE posts.user_id = 1
cascadeOnDelete is the interview-worthy detail: deleting the user deletes their posts —
the database enforces it, Eloquent never has to.
How it Works: Many-to-Many — belongsToMany and the Pivot
User and Role: many users, many roles, a user can hold several roles and a role can be held by several users. The foreign key can't live on either table — it lives on a pivot table that pairs the two primary keys.
class User extends Model
{
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class); // role_user table
}
}
class Role extends Model
{
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class);
}
}
Pivot migration — a table whose name is the two table names, alphabetically, singular,
joined by an underscore: role_user. Its only job is to hold both foreign keys:
Schema::create('role_user', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('role_id')->constrained()->cascadeOnDelete();
$table->timestamps();
});
users role_user (pivot) roles
┌─────┐ ┌───────────────┐ ┌──────┐
│ id │◄─────────│ user_id role_id│────────►│ id │
└─────┘ └───────────────┘ └──────┘
1 (1, 2) (2, 3) 2 users 1 & 2 share role 2
Generated SQL for $user->roles — a two-table join:
SELECT roles.*, role_user.user_id AS pivot_user_id,
role_user.role_id AS pivot_role_id
FROM roles
INNER JOIN role_user ON roles.id = role_user.role_id
WHERE role_user.user_id = 1
The extra pivot_* columns are Eloquent attaching the pivot row to each role, available as
$role->pivot. Custom pivot columns (assigned_at, level) go in a third argument and
withPivot():
return $this->belongsToMany(Role::class)
->withPivot('level')
->withTimestamps();
$user->roles->first()->pivot->level; // reads the pivot's extra column
Note
Every relationship question about "users can have many roles" or "students and courses" is this one. Say the words pivot table and you've answered the model half; say composite foreign keys on the pivot and you've answered the schema half.
How it Works: hasManyThrough — the Middle Table
A country has many posts, through the users who wrote them. Country → User → Post, where the Country table never touches the Post table at all.
class Country extends Model
{
public function posts(): HasManyThrough
{
return $this->hasManyThrough(
Post::class, // the far model
User::class, // the middle model
'country_id', // FK on users
'user_id', // FK on posts
);
}
}
countries users posts
┌─────────┐ ┌────────────┐ ┌──────────┐
│ id │ │ id │ │ id │
└─────────┘ │ country_id │◄───│ user_id │
1 └────────────┘ └──────────┘
└───────── users in country 1 ─────────┘
└── posts by those users ──┘
Generated SQL — Eloquent joins through the middle table for you:
SELECT posts.*
FROM posts
INNER JOIN users ON users.id = posts.user_id
WHERE users.country_id = 1
When does hasManyThrough win? Whenever the relationship reads "X has many Y through Z",
and you'd otherwise write two queries or a hand-rolled join in a controller. It keeps the
relationship in the model, where it belongs.
Pitfall
The middle table (users) can't soft-delete with the default setup — the join in
hasManyThrough is a plain INNER JOIN, so a soft-deleted user drops their posts from the
result even though they still exist. That's a classic subtle bug in report queries.
How it Works: Polymorphic Relationships — morphMany / morphTo
One comments table serving many different parents — posts and videos. A normal foreign
key can't point at two tables, so the child stores two columns: the parent's table
(commentable_type) and the parent's id (commentable_id).
class Comment extends Model
{
public function commentable(): MorphTo
{
return $this->morphTo();
}
}
class Post extends Model
{
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable');
}
}
class Video extends Model
{
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable');
}
}
Migration — no post_id, no video_id; instead the two morph columns, plus an index on both
together:
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->morphs('commentable'); // commentable_type + commentable_id
$table->text('body');
$table->timestamps();
});
posts comments videos
┌─────┐ ┌──────────────────────┐ ┌──────┐
│ id │◄───│ commentable_type │───►│ id │
└─────┘ │ commentable_id │ └──────┘
2 └──────────────────────┘
(App\Models\Post, 2) ← row 2 of posts
(App\Models\Video, 5) ← row 5 of videos
Generated SQL for $post->comments — a WHERE on both morph columns:
SELECT * FROM comments
WHERE commentable_type = 'App\Models\Post'
AND commentable_id = 2
morphToMany extends the idea to many-to-many with a polymorphic pivot — e.g. tags
shared by posts and videos via a taggables pivot that stores tag_id plus
taggable_type/taggable_id. And morphOne is the one-to-one version of the same shape: a
user has one image (morphOne), while the image table stores imageable_type +
imageable_id so it can also serve products — same two-column trick, LIMIT 1 on read.
Tip
Interview phrasing to have ready: "A polymorphic relationship lets one child table belong to several parent models by storing the parent's class and id instead of a fixed foreign key." The trade-off — no FK constraint and no join through a single parent — is the senior caveat to add.
Real Project Usage
A typical Laravel app uses four of these in one screen — and this is a realistic answer to "how do you model that?":
class User extends Model
{
public function posts(): HasMany { return $this->hasMany(Post::class); }
}
class Post extends Model
{
public function user(): BelongsTo { return $this->belongsTo(User::class); }
public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class); }
public function comments(): MorphMany { return $this->morphMany(Comment::class, 'commentable'); }
}
class Comment extends Model
{
public function commentable(): MorphTo { return $this->morphTo(); }
}Interview Explanation
Eloquent relationships turn foreign keys into methods, so
$user->postsis a query. The rule that decides which method to use is: the table that holds the foreign key isbelongsTo, the other side ishasOneorhasMany. For many-to-many, neither table can hold a single foreign key, so a pivot table stores both — that'sbelongsToMany.
hasManyThroughmodels "X has many Y through Z" with a single query and a join, and polymorphicmorphMany/morphTolet one child table (comments) belong to many different parents (posts, videos) by storing the parent's class and id. Each one is a different shape of the same idea: let the foreign key tell you which relation it is.
Senior-Level Insights
- The foreign key is the source of truth. Seniors don't memorise
hasOnevsbelongsTo— they look at the schema and say "the FK is on profiles, so profile belongsTo user". Name the rule, then apply it to the interviewer's tables. cascadeOnDeletevs null-on-delete is a data decision. Deleting a user should delete their comments (cascade) but keep the order history with a nulleduser_id(nullOnDelete). Choosing correctly is a senior signal.- Pivots can be first-class models. When a pivot carries real data (
role_user.level,order_product.quantity),belongsToManywithwithPivot()is fine — when the pivot becomes a thing of its own (an enrolment with a grade and a date), it should become its own model withhasManyon both sides. That's the "extend the pivot" answer. - Polymorphic morphs have a real cost. No foreign-key constraint means orphaned
commentable_ids, and every query scans the_typestring. Only use polymorphic when the parent set is genuinely open — for a fixed parent, use a real FK. - The difference between relationships is SQL, not magic.
hasManyisWHERE user_id =,belongsToManyis a join,hasManyThroughis a join through a middle table,morphManyisWHERE type AND id. Say the SQL out loud and the abstraction stops being mysterious.
Common Mistakes
- ❌ Getting
hasOne/belongsTobackwards — the FK lives on thebelongsToside;hasOnenever carries the key. - ❌ Naming the pivot table wrong — it's the two singular table names in alphabetical
order:
role_user, notroles_users. Eloquent expectsrole_userunless you pass a table name. - ❌ Forgetting the foreign key in the migration — the relationship method compiles, but
every query returns empty because
posts.user_iddoesn't exist. - ❌ A polymorphic child with a single FK column — one
commentable_idcan't point at two parents; you needcommentable_typetoo (that's whatmorphs()creates). - ❌ Loading relationships in a loop —
foreach ($users as $user) { $user->posts }is the N+1 problem. Lesson 117 is the fix. - ❌ Using
hasManyThroughwhen the middle table has no real role — a two-table relationship via a coincidental join is clearer as a normalhasManyplus abelongsTo.
Best Practices
✅ Name methods in the plural for collections (posts, roles), singular for one (profile)
✅ Let the FK convention work — only pass the key arguments when they're custom
✅ Add ->constrained()->cascadeOnDelete() (or nullOnDelete()) to every foreignId
✅ Use withPivot() for real pivot data; promote the pivot to a model when it grows up
✅ Prefer a real foreign key over a polymorphic morph when the parent set is fixed
✅ Keep relationship definitions one-per-method and put the query logic (scopes) on the model
Interview Questions
Q1. What is the difference between hasOne and belongsTo?
Which side holds the foreign key. A Profile has
user_id, so ProfilebelongsToUser and UserhasOneProfile.hasOnemeans "I'm referenced by that table";belongsTomeans "I carry the key that references you".
Q2. What is a pivot table?
The table that resolves a many-to-many relationship. Neither side can hold a single foreign key — a user has many roles and a role has many users — so a third table stores pairs of both keys:
role_userwithuser_idandrole_id. Eloquent calls it the pivot, and exposes it on each model as->pivot.
Q3. What is a polymorphic relationship?
One child table that can belong to several different parent tables. Instead of a fixed foreign key, the child stores the parent's class (
commentable_type) and id (commentable_id) — so comments can attach to posts and videos without separate tables.morphTois the child side,morphManythe parent side.
Q4. When would you use hasManyThrough?
When the relationship reads "X has many Y through Z" and the two tables aren't directly linked — a Country has many Posts through its Users. It produces one query with a join through the middle table, so I don't hand-write the join in a controller.
Q5. What's the difference between one-to-many and many-to-many?
One-to-many: the child holds a single foreign key to the parent (
posts.user_id) — one user, many posts. Many-to-many: neither side can hold it, so a pivot table pairs both keys (role_user). The schema difference is where the keys live.
Q6. How do you add data to a pivot table?
$user->roles()->attach($roleId)orsync([$roleId1, $roleId2])for a whole set at once. With extra pivot columns,attach($roleId, ['level' => 3])and the relationship needswithPivot('level')so the column is loaded.
Q7. What is the default foreign key Eloquent guesses, and when must you override it?
The owner's model name, snake_cased, plus
_id—User→user_id,Post→post_id. You override it whenever the column doesn't follow the convention, like aCountryusingnation_idon the users table.
Q8. Can a table be involved in two relationships to the same model?
Yes — and that's when you must give the relationship a name and specify the foreign key.
Useras both author and editor of aPostneedsauthor()andeditor()methods, each with its ownbelongsTo(User::class, 'author_id')/('editor_id').
Senior follow-up: A User hasOne Profile, but you now need a Profile to belong to a
Team as well. What changes in the schema?
The Profile table gains a
team_idforeign key, and Profile gets a secondbelongsTo(Team::class). The User side is untouched —hasOne/belongsToreads the key on the Profile row. The lesson to state out loud: adding a relationship is usually adding a column plus a method, and the existing relations don't move.
Follow-Up Questions
Q1. What happens if you delete a user with posts?
With
cascadeOnDeleteon theuser_idFK, the database deletes the posts automatically. Without it, the delete fails with a foreign-key constraint error (or leaves orphaned rows, depending on the constraint). Eloquent doesn't clean children up by itself — that's the migration's job.
Q2. Why might you want a pivot table as its own model?
When the pivot carries enough data to be a domain object — an enrolment with a grade and an enrolled-at date. Then
belongsToManywithwithPivot()gets clumsy, and a real model (Enrolment) withhasManyon both sides gives you relationships, scopes, and events on the pivot itself.
Q3. When is a polymorphic relationship a mistake?
When the parent set is small and fixed. Two parent types could just be two real columns or two child tables, and you'd keep the foreign-key constraint and the cleaner joins. Polymorph is for genuinely open parent sets — "anything can be commented on".
Q4. sync() vs attach() vs detach()?
attach($id)adds one pivot row;detach($id)removes one;sync([ids])replaces the whole set in one call — it attaches the new ids and detaches anything not in the list. For saving a "choose your roles" form,syncis the one you want.
Comparison Table
| One-to-one | One-to-many | Many-to-many | Through | Polymorphic | |
|---|---|---|---|---|---|
| Methods | hasOne / belongsTo | hasMany / belongsTo | belongsToMany ×2 | hasManyThrough | morphOne / morphMany / morphTo / morphToMany |
| FK location | Dependent table | Many-side table | Pivot table | Middle, then far table | Child stores type + id |
| Example | User–Profile | User–Post | User–Role | Country–Post via User | Comments on Post & Video |
| SQL shape | WHERE user_id = ? LIMIT 1 | WHERE user_id = ? | INNER JOIN pivot | JOIN users ON … WHERE country_id | WHERE type = ? AND id = ? |
| Covers | 1:1 attribute-ish | 1:N, most common | N:N roles/tags/courses | Chains through a middle table | One child, many parents |
| Question | Method |
|---|---|
| "I carry the key that points at you" | belongsTo |
| "I'm pointed at by that table" | hasOne / hasMany |
| "Both sides have many" | belongsToMany |
| "Many, but through a middle table" | hasManyThrough |
| "Belongs to one of several kinds of parent" | morphTo / morphMany |
Code Example
use App\Models\User;
use App\Models\Role;
$user = User::find(1);
$user->roles()->attach(2); // INSERT INTO role_user (user_id, role_id) VALUES (1, 2)
$user->roles()->sync([2, 3, 5]); // one call: attach 2,3,5, detach the rest
foreach ($user->roles as $role) {
echo "{$role->name} (level {$role->pivot->level})\n";
}
Output (the SQL behind $user->roles, then the loop):
SELECT roles.*, role_user.user_id AS pivot_user_id,
role_user.role_id AS pivot_role_id,
role_user.level AS pivot_level
FROM roles
INNER JOIN role_user ON roles.id = role_user.role_id
WHERE role_user.user_id = 1
Admin (level 3)
Editor (level 2)Performance Notes
- The property triggers a query.
$user->postsruns SQL on first access and caches it on the model for the rest of the request — useful, but inside a loop it's N queries (Lesson 117's core). - Use
with()/load()for anything more than one model. Two users' posts = two extra queries; two hundred = two hundred. Eager loading turns that into one. - Only fetch what you traverse. A relationship you never touch is a query you never
needed —
with()on unused relations is wasted SQL. - Count without hydrating.
$user->posts()->count()doesSELECT COUNT(*)instead of loading every post; the relationship method (not the property) gives you the builder. constraints+ eager loading interplay comes next — Lesson 117 covers whywith('posts')usesWHERE user_id IN (...).
Debugging Scenarios
- "
$user->postsreturns an empty collection but the rows are there." — The foreign key column doesn't match the convention:posts.author_idinstead ofposts.user_id. Pass it explicitly:$this->hasMany(Post::class, 'author_id'). - "
$user->rolesreturnsnullinstead of a collection." — You're calling a method that returns aBelongsToManyand treating it as a property, or the pivot table is missing / misnamed (roles_userinstead ofrole_user). - "A polymorphic comment is null on one parent but works on the other." — The
commentable_typestring must match the actual class —App\Models\Postwith the right case and namespace. A wrong string silently returns no rows. - "Deleting a user deleted nothing, and now the app errors on the post count." — The FK
has no
cascadeOnDelete, so the constraint blocks the delete (or orphans the posts). Fix in the migration, not in a controller.
Quick Revision Notes
- One rule: the table with the FK is
belongsTo; the other side ishasOne/hasMany. hasOne= "referenced by",belongsTo= "carries the key". The words mean the direction of the pointer, not importance.- Pivot: neither side can hold the key, so a third table pairs them —
role_user. hasManyThrough: Country → Posts via Users; one query,INNER JOINthrough the middle.- Polymorphic: child stores
_type+_id; comments on posts and videos;morphOne/morphMany/morphTo/morphToMany; no FK constraint — the trade-off to name. attach/detach/syncadd, remove, and replace pivot rows.- Property access lazily runs SQL — the door Lesson 117 walks through.
Cheat Sheet
hasOne → referenced by one row (profiles.user_id → users.id)
hasMany → referenced by many rows (posts.user_id)
belongsTo → I hold the FK (profiles.user_id, posts.user_id)
belongsToMany → pivot table pairs both keys (role_user)
hasManyThrough→ join through a middle table (country → users → posts)
morphMany → child holds type + id (comments.commentable_*)
morphOne → one-to-one morph (image.imageable_*, LIMIT 1)
morphTo → the child side of a morph (comment.commentable)
morphToMany → polymorphic pivot (taggables)
FK rule → "the key decides the side, the schema decides the shape"Key Takeaways
Recap
- A relationship is a foreign key expressed as a method:
$user->postsis a query - The FK's location decides everything —
belongsTocarries it,hasOne/hasManydoesn't - Many-to-many needs a pivot table (
role_user) holding both keys —belongsToMany hasManyThroughmodels chains (Country → Posts via Users) with one joined query- Polymorphic
morphMany/morphTolet one child serve many parent types viatype+id cascadeOnDeleteandnullOnDeleteare data decisions, made in the migration- Property access runs SQL lazily — the exact behaviour Lesson 117 turns into N+1
Check your understanding
Answer each one out loud. Then rate yourself honestly.
0/8State the foreign-key rule that picks hasOne vs belongsTo.
For a Post that belongs to a User, which table holds the FK and which method goes on which model?
Draw the role_user pivot between users and roles, and write the belongsToMany method for one side.
When do you use hasManyThrough? Sketch Country → User → Post with its two FK columns.
A comments table serves both posts and videos — what two columns does the migration need, and what are the two methods on the child?
attach, detach, sync: what does each do, and which saves a "choose roles" form?
What does cascadeOnDelete() actually enforce, and where does it live?
When is a polymorphic relationship the wrong choice?
What's Next
Lesson 117 — Eager Loading & the N+1 Problem. The most-asked Laravel performance question:
why User::all() with $user->posts in a loop is O(N+1) queries, and how with('posts')
collapses it to two.