🔗 Model Relationships
Tables are rarely islands — users write posts, orders contain products, posts wear tags. Eloquent lets you declare these connections as methods on your models, then walk them like object properties. This lesson covers every relationship type Laravel 11 offers and the eager-loading habit that keeps them fast.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Define one-to-one, one-to-many, and their inverse
belongsTorelationships - Model many-to-many relations, including pivot tables with extra columns
- Use has-many-through and polymorphic relationships for advanced connections
- Query relationships with
has,whereHas, andwithCount - Defeat the N+1 query problem with eager loading
Estimated Time: 50–60 minutes • Difficulty: Intermediate
Hands-on: Wire up a blog's User → Post → Comment → Tag relationship graph.
In This Lesson
Why Relationships?
Relational databases split data across tables to avoid duplication, then rejoin it with foreign keys. Eloquent relationships put an object-oriented face on those joins: instead of hand-writing SQL, you call $user->posts and get a collection of Post models back.
💡 A useful analogy: Relationships are the roads in your data's road network. Just as there are highways, one-way streets, and roundabouts, Eloquent gives you a relationship type for each connection pattern — and you pick the road that matches the traffic.
Every relationship is just a method on the model that returns a relationship object. Read it as a property ($user->posts) to get results, or call it as a method ($user->posts()) to keep building a query.
One-to-One
The simplest relationship: one row links to exactly one row on the other side. A user has one profile; a customer has one primary address.
Define both sides — hasOne on the parent, belongsTo on the child. The child is whichever table holds the foreign key:
// User model (parent — no foreign key on its table)
public function profile()
{
return $this->hasOne(Profile::class);
}
// Profile model (child — holds the user_id foreign key)
public function user()
{
return $this->belongsTo(User::class);
}
Eloquent assumes the foreign key is user_id. Override the keys when your schema differs:
// hasOne(RelatedModel, foreignKeyOnRelated, localKeyOnThis)
return $this->hasOne(Profile::class, 'user_reference_id', 'uuid');
// Reading and creating through the relationship
$bio = $user->profile?->bio; // null-safe if no profile yet
$user->profile()->create([
'bio' => 'Laravel developer',
]);
One-to-Many
One row links to many rows, but each of those rows links back to just one parent. A user writes many posts; a category holds many products.
// User model
public function posts()
{
return $this->hasMany(Post::class);
}
// Post model
public function user()
{
return $this->belongsTo(User::class);
}
The hasMany side returns a collection; the belongsTo side returns a single model. Because the relationship method is also a query builder, you can constrain it:
$posts = $user->posts; // Collection of every post
$recent = $user->posts() // keep chaining as a query
->where('published', true)
->latest()
->take(5)
->get();
$count = $user->posts()->count(); // efficient COUNT, no hydration
// Create a child already linked to its parent
$user->posts()->create([
'title' => 'Learning Eloquent Relationships',
'content' => 'Relationships are a powerful feature...',
]);
💡 Analogy: A one-to-many relationship is a hub and spokes — one teacher, many students. Every spoke knows its hub (belongsTo), and the hub knows all its spokes (hasMany).
Many-to-Many & Pivots
Both sides can link to many of the other: a post has many tags, and a tag belongs to many posts. This needs a third pivot table to store the pairings.
Both models use belongsToMany. By convention Laravel expects a pivot table named from the two singular models in alphabetical order — here, post_tag:
// Post model
public function tags()
{
return $this->belongsToMany(Tag::class);
}
// Tag model
public function posts()
{
return $this->belongsToMany(Post::class);
}
Manage the associations with a rich set of pivot methods:
$post->tags()->attach([1, 2, 3]); // add pairings
$post->tags()->detach([2]); // remove one pairing
$post->tags()->sync([1, 4, 5]); // make the set exactly these
$post->tags()->toggle([1, 2]); // flip membership
Pivots with extra columns
The pivot can carry its own data — an enrollment date, a grade, a role. Declare the extra columns with withPivot():
// Migration
Schema::create('course_student', function (Blueprint $table) {
$table->id();
$table->foreignId('course_id')->constrained();
$table->foreignId('student_id')->constrained('users');
$table->date('enrolled_at');
$table->unsignedTinyInteger('grade')->nullable();
$table->timestamps();
});
// Model
public function courses()
{
return $this->belongsToMany(Course::class)
->withPivot('enrolled_at', 'grade')
->withTimestamps();
}
// Attach with pivot data, then read it back
$student->courses()->attach($course->id, ['enrolled_at' => now()]);
foreach ($student->courses as $course) {
echo $course->pivot->enrolled_at;
}
📖 Key Term
Pivot (junction) table: an intermediate table whose only job is to store pairs of foreign keys — one from each side of a many-to-many relationship — plus any attributes that describe the pairing itself.
Has-Many-Through
Sometimes you want to reach a distant table through an intermediate one without a pivot. A country has many posts through its users: countries don't own posts directly, but their users do.
class Country extends Model
{
// All posts written by users who belong to this country
public function posts()
{
return $this->hasManyThrough(
Post::class, // final model we want
User::class, // intermediate model
'country_id', // FK on the intermediate (users.country_id)
'user_id', // FK on the final model (posts.user_id)
'id', // local key on this model (countries.id)
'id' // local key on the intermediate (users.id)
);
}
}
$posts = $country->posts; // every post from that country's users
💡 Analogy: A "through" relationship is an express train — it skips the intermediate station (Users) and takes you straight from Country to Posts, even though the tracks physically pass through the middle.
Polymorphic Relationships
A polymorphic relationship lets one model belong to several different parent types through a single association. Comments that can attach to both posts and videos; images that belong to users, posts, or products. The trick is two columns on the child: a *_id and a *_type that records which model it points at.
// Comment model — the polymorphic child
class Comment extends Model
{
public function commentable()
{
return $this->morphTo();
}
}
// Post and Video each expose the inverse
class Post extends Model
{
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
}
}
class Video extends Model
{
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
}
}
$post->comments()->create(['body' => 'Great post!']);
$video->comments; // works the same way
$comment = Comment::find(1);
$parent = $comment->commentable; // a Post OR a Video
⚠️ Store type maps, not class names
By default the commentable_type column stores the full class string like App\Models\Post. If you ever rename or move a model, existing rows break. Register a morph map in AppServiceProvider::boot() with Relation::enforceMorphMap([...]) so the database stores stable aliases like 'post' instead.
Polymorphic relations extend naturally to many-to-many (morphToMany / morphedByMany) for shared features like tagging across post and video types.
Querying & Eager Loading
Two skills separate slow relationship code from fast: filtering by relationship existence, and eager loading.
Existence queries
// Users who have at least one post
$users = User::has('posts')->get();
// Users with 3 or more posts
$users = User::has('posts', '>=', 3)->get();
// Users who have a published post
$users = User::whereHas('posts', fn ($q) => $q->where('published', true))->get();
// Users with no posts at all
$users = User::doesntHave('posts')->get();
// Attach a count column without loading the posts themselves
$users = User::withCount('posts')->get();
echo $users->first()->posts_count;
The N+1 problem
This is the single most common Eloquent performance bug. Loop over 100 posts, touch $post->user inside the loop, and you fire 1 query for the posts plus 100 more for the authors — 101 queries where 2 would do.
Eager load with with() to collapse it to two queries:
// ❌ Lazy: 1 + N queries
$posts = Post::all();
foreach ($posts as $post) {
echo $post->user->name; // a fresh query every iteration
}
// ✅ Eager: 2 queries total
$posts = Post::with('user')->get();
foreach ($posts as $post) {
echo $post->user->name; // already loaded
}
Eager loading composes — load nested relations, constrain them, and select only the columns you need:
$posts = Post::with([
'user:id,name', // only two columns
'comments' => fn ($q) => $q->latest()->limit(5),
'comments.user', // nested
])->get();
✅ Pro tip: catch N+1 automatically
In your AppServiceProvider::boot(), call Model::preventLazyLoading(! app()->isProduction());. During development Laravel will now throw an exception the moment you access an un-eager-loaded relationship, so N+1 bugs surface immediately instead of silently in production.
Hands-on Exercise
🏋️ Wire Up a Blog
Objective: Define a realistic relationship graph and query it efficiently.
Requirements:
- A
Userhas manyPosts; each post belongs to a user. - A
Posthas manyComments; each comment belongs to both a post and a user. - A
Postand aTagare many-to-many. - Write one query that loads all published posts with their author and comment count, avoiding N+1.
💡 Hint
The comment needs two belongsTo methods (post() and user()). For the final query, combine with('user') for the author and withCount('comments') for the tally.
✅ Sample solution
class User extends Model
{
public function posts() { return $this->hasMany(Post::class); }
}
class Post extends Model
{
public function user() { return $this->belongsTo(User::class); }
public function comments() { return $this->hasMany(Comment::class); }
public function tags() { return $this->belongsToMany(Tag::class); }
}
class Comment extends Model
{
public function post() { return $this->belongsTo(Post::class); }
public function user() { return $this->belongsTo(User::class); }
}
// One efficient query — author eager loaded, comments counted
$posts = Post::where('published', true)
->with('user')
->withCount('comments')
->latest()
->get();
foreach ($posts as $post) {
echo "{$post->title} by {$post->user->name} "
. "({$post->comments_count} comments)";
}
🎯 Quick Quiz
Question 1: Which model holds the foreign key in a one-to-many relationship?
Question 2: What is the fix for the N+1 query problem?
Question 3: A many-to-many relationship requires what that one-to-many does not?
Summary & Quiz
🎉 Key Takeaways
- hasOne / belongsTo model one-to-one; hasMany / belongsTo model one-to-many. The foreign key always lives on the
belongsToside. - belongsToMany models many-to-many through a pivot table, which can carry its own columns via
withPivot(). - hasManyThrough reaches a distant table through an intermediate; polymorphic relations let one child belong to several parent types.
- Filter with
has/whereHas; count withwithCount. - Eager load with
with()to defeat N+1 — and enablepreventLazyLoadingin dev to catch it automatically.
📚 Further Reading
- Laravel 11 Docs — Eloquent Relationships
- Laravel 11 Docs — Eager Loading
- Laravel 11 Docs — Polymorphic Relationships
🚀 What's Next?
You can define and connect models — next you'll interrogate them. In Querying with Eloquent you'll master where clauses, ordering, aggregates, scopes, and conditional query building.
🎉 Connected!
Your data model now mirrors the real world. Let's learn to query it with precision.