Eloquent Relationships: Making Models Talk

SS Saurav Sitaula

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:

  1. SELECT * FROM posts
  2. SELECT * 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

  1. Always eager load in lists. If you’re displaying relationships in a loop, use with().

  2. Use withCount() for counts. Don’t load entire relationships just to count them.

  3. Relationship methods vs properties. $user->posts() returns a builder. $user->posts returns results.

  4. Pivot tables have conventions. Alphabetical order, singular names: post_tag, not tags_posts.

  5. 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.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism