Eloquent Relationships: Making Models Talk
Users have posts. Posts have comments. Orders have products. Learn to define and query relationships in Eloquent — hasMany, belongsTo, many-to-many, and the magical eager loading that saves you from the N+1 problem.
The Problem: Data Lives in Multiple Tables
After getting comfortable with Laravel basics, I hit a wall. My app had:
- Users
- Posts (each user writes many posts)
- Comments (each post has many comments)
- Tags (posts can have multiple tags, tags can belong to multiple posts)
In raw PHP, I’d write JOIN queries and manually piece together objects. Messy, error-prone, and tedious.
Eloquent has a better way: relationships.
One-to-Many: Users and Posts
A user has many posts. A post belongs to one user.
Defining the Relationship
// app/User.php
class User extends Model
{
public function posts()
{
return $this->hasMany(Post::class);
}
}
// app/Post.php
class Post extends Model
{
public function user()
{
return $this->belongsTo(User::class);
}
}
The migration for posts needs a foreign key:
Schema::create('posts', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('user_id'); // Foreign key
$table->string('title');
$table->text('body');
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
Using the Relationship
// Get all posts for a user
$user = User::find(1);
$posts = $user->posts; // Collection of Post models
foreach ($posts as $post) {
echo $post->title;
}
// Get the user who wrote a post
$post = Post::find(1);
$author = $post->user; // Single User model
echo $author->name;
// Create a post for a user
$user->posts()->create([
'title' => 'My First Post',
'body' => 'Hello World!'
]);
Notice: $user->posts returns results (runs the query), while $user->posts() returns the relationship builder (for chaining).
// Get only published posts
$publishedPosts = $user->posts()->where('published', true)->get();
// Count posts without loading them
$postCount = $user->posts()->count();
One-to-Many: Posts and Comments
Same pattern:
// app/Post.php
class Post extends Model
{
public function comments()
{
return $this->hasMany(Comment::class);
}
}
// app/Comment.php
class Comment extends Model
{
public function post()
{
return $this->belongsTo(Post::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
}
Now I can chain through relationships:
// Get all comments on all posts by a user
$user = User::find(1);
foreach ($user->posts as $post) {
foreach ($post->comments as $comment) {
echo $comment->body;
}
}
The N+1 Problem (And How I Killed My Database)
My blog listing page showed posts with author names:
// Controller
$posts = Post::all();
// Blade
@foreach($posts as $post)
<h2>{{ $post->title }}</h2>
<p>By {{ $post->user->name }}</p> {{-- Database query here! --}}
@endforeach
With 100 posts, this runs:
- 1 query to get all posts
- 100 queries to get each post’s user
101 queries for one page. My database cried.
The Solution: Eager Loading
// Load users WITH the posts
$posts = Post::with('user')->get();
Now it’s just 2 queries:
SELECT * FROM postsSELECT * FROM users WHERE id IN (1, 2, 3, ...)
Eloquent automatically matches users to posts. Same result, 99% fewer queries.
Nested Eager Loading
// Load posts with users AND comments with their users
$posts = Post::with(['user', 'comments.user'])->get();
The dot notation loads nested relationships. Beautiful.
Eager Load by Default
If you ALWAYS need the relationship:
class Post extends Model
{
protected $with = ['user']; // Always eager load user
}
Now Post::all() automatically includes users.
Many-to-Many: Posts and Tags
A post can have many tags. A tag can belong to many posts. This needs a pivot table.
The Migration
// Create tags table
Schema::create('tags', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->timestamps();
});
// Create pivot table (naming convention: alphabetical, singular)
Schema::create('post_tag', function (Blueprint $table) {
$table->unsignedInteger('post_id');
$table->unsignedInteger('tag_id');
$table->foreign('post_id')->references('id')->on('posts')->onDelete('cascade');
$table->foreign('tag_id')->references('id')->on('tags')->onDelete('cascade');
$table->primary(['post_id', 'tag_id']);
});
Defining the Relationship
// app/Post.php
class Post extends Model
{
public function tags()
{
return $this->belongsToMany(Tag::class);
}
}
// app/Tag.php
class Tag extends Model
{
public function posts()
{
return $this->belongsToMany(Post::class);
}
}
Using Many-to-Many
// Get tags for a post
$post = Post::find(1);
$tags = $post->tags;
// Get posts with a tag
$tag = Tag::where('name', 'laravel')->first();
$posts = $tag->posts;
// Attach tags to a post
$post->tags()->attach($tagId);
$post->tags()->attach([1, 2, 3]); // Multiple tags
// Detach tags
$post->tags()->detach($tagId);
$post->tags()->detach(); // Detach all
// Sync (replace all with these)
$post->tags()->sync([1, 2, 3]); // Post now has exactly these tags
// Toggle (attach if missing, detach if present)
$post->tags()->toggle([1, 2]);
Pivot Table Data
Need extra data on the pivot table? Like when a tag was added?
Schema::create('post_tag', function (Blueprint $table) {
$table->unsignedInteger('post_id');
$table->unsignedInteger('tag_id');
$table->timestamps(); // Add timestamps
});
// app/Post.php
public function tags()
{
return $this->belongsToMany(Tag::class)->withTimestamps();
}
// Access pivot data
foreach ($post->tags as $tag) {
echo $tag->pivot->created_at;
}
One-to-One: User and Profile
Sometimes a relationship is strictly one-to-one:
// app/User.php
public function profile()
{
return $this->hasOne(Profile::class);
}
// app/Profile.php
public function user()
{
return $this->belongsTo(User::class);
}
// Usage
$user->profile->bio;
$profile->user->name;
Has Many Through: Countries, Users, Posts
A country has many users. Users have many posts. How do I get all posts from a country?
// app/Country.php
public function posts()
{
return $this->hasManyThrough(Post::class, User::class);
}
// Usage
$country = Country::find(1);
$posts = $country->posts; // All posts from users in this country
Eloquent handles the double join automatically.
Polymorphic Relations: Comments on Anything
What if comments can belong to posts AND videos AND photos?
// comments table
Schema::create('comments', function (Blueprint $table) {
$table->increments('id');
$table->text('body');
$table->unsignedInteger('commentable_id');
$table->string('commentable_type');
$table->timestamps();
});
// app/Comment.php
public function commentable()
{
return $this->morphTo();
}
// app/Post.php
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
}
// app/Video.php
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
}
Now comments work on any model:
$post->comments()->create(['body' => 'Great post!']);
$video->comments()->create(['body' => 'Great video!']);
// Get the parent
$comment = Comment::find(1);
$parent = $comment->commentable; // Could be Post, Video, etc.
Querying Relationships
Has
Find posts that have comments:
$postsWithComments = Post::has('comments')->get();
// At least 3 comments
$popularPosts = Post::has('comments', '>=', 3)->get();
WhereHas
Find posts with comments containing “Laravel”:
$posts = Post::whereHas('comments', function ($query) {
$query->where('body', 'like', '%Laravel%');
})->get();
WithCount
Get posts with comment count:
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
echo $post->title . ' has ' . $post->comments_count . ' comments';
}
Practical Example: Blog with Everything
// Controller
public function show($slug)
{
$post = Post::with([
'user',
'tags',
'comments' => function ($query) {
$query->latest()->with('user');
}
])->withCount('comments')
->where('slug', $slug)
->firstOrFail();
return view('posts.show', compact('post'));
}
{{-- Blade template --}}
<article>
<h1>{{ $post->title }}</h1>
<p>By {{ $post->user->name }} • {{ $post->comments_count }} comments</p>
<div class="tags">
@foreach($post->tags as $tag)
<span class="tag">{{ $tag->name }}</span>
@endforeach
</div>
<div class="content">
{!! $post->body !!}
</div>
<section class="comments">
<h3>Comments</h3>
@foreach($post->comments as $comment)
<div class="comment">
<strong>{{ $comment->user->name }}</strong>
<p>{{ $comment->body }}</p>
</div>
@endforeach
</section>
</article>
All data loaded in ONE controller method with optimized queries.
What I Wish I’d Known Earlier
-
Always eager load in lists. If you’re displaying relationships in a loop, use
with(). -
Use
withCount()for counts. Don’t load entire relationships just to count them. -
Relationship methods vs properties.
$user->posts()returns a builder.$user->postsreturns results. -
Pivot tables have conventions. Alphabetical order, singular names:
post_tag, nottags_posts. -
sync()is your friend for many-to-many. It handles attach/detach in one call.
The Journey Continues
Relationships transformed how I thought about data. Instead of writing JOINs, I defined relationships once and queried naturally.
But I was still writing a lot of repetitive code. Validation in controllers. Shared data in views. Laravel had patterns for those too.
P.S. — The first time I replaced a 20-line raw SQL query with Post::with('user', 'comments.user')->get(), I felt like I was cheating. Eloquent relationships aren’t magic, but they feel like it.
Saurav Sitaula
Software Architect • Nepal