Laravel Eloquent Relationships Explained with Real-World Examples

๐Ÿ‘๏ธ 43 Views
|
๐Ÿ“… Aug 12, 2026
|
โฑ๏ธ 15 min read
Laravel Eloquent Relationships Explained with Real-World Examples

One of the most powerful features in Laravel is Eloquent ORM โ€” and at the heart of Eloquent is its relationship system. Once you understand how relationships work, querying related data becomes natural and elegant. Instead of writing complex JOIN queries, you define how your models relate to each other and let Eloquent handle the SQL.

In this guide we will cover every type of Eloquent relationship with real-world examples, common mistakes, and practical tips you will actually use in production projects.

What Are Eloquent Relationships?

Eloquent relationships define how database tables connect to each other through your model classes. Instead of writing raw SQL JOINs every time you need related data, you define the relationship once in your model and access it like a property or method.

// Without relationships โ€” raw SQL every time
$posts = DB::table('posts')
    ->join('users', 'posts.user_id', '=', 'users.id')
    ->where('users.id', $userId)
    ->get();

// With Eloquent relationships โ€” clean and readable
$posts = User::find($userId)->posts;

Laravel supports six types of relationships. We will cover all of them with real-world scenarios:

  • hasOne
  • hasMany
  • belongsTo
  • belongsToMany
  • hasOneThrough
  • hasManyThrough

1. hasOne โ€” One to One

A hasOne relationship means one model owns exactly one instance of another model. Real-world example: a User has one Profile.

// Database tables:
// users:    id, name, email
// profiles: id, user_id, bio, avatar, website

// User model
class User extends Model
{
    public function profile(): HasOne
    {
        return $this->hasOne(Profile::class);
        // Laravel assumes foreign key is user_id in profiles table
    }
}

// Profile model
class Profile extends Model
{
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}
// Usage
$user = User::find(1);

// Access the related profile
$profile = $user->profile; // SELECT * FROM profiles WHERE user_id = 1 LIMIT 1

echo $profile->bio;
echo $profile->avatar;

// Create a profile for the user
$user->profile()->create([
    'bio'     => 'Full stack developer from Hazaribagh',
    'avatar'  => 'avatar.jpg',
    'website' => 'https://tipsandtricks.dev',
]);

// Update the profile
$user->profile()->update(['bio' => 'Updated bio here']);

// Custom foreign key โ€” if your column is not user_id
return $this->hasOne(Profile::class, 'profile_user_id', 'id');

Tip: Use hasOne when the foreign key lives on the other table. If a user has one profile, the user_id column is on the profiles table โ€” not on users.

2. hasMany โ€” One to Many

The most commonly used relationship. One model has multiple related records. Real-world example: a User has many Posts. A Post has many Comments.

// Database tables:
// users: id, name, email
// posts: id, user_id, title, body, published_at

// User model
class User extends Model
{
    public function posts(): HasMany
    {
        return $this->hasMany(Post::class);
        // Assumes post.user_id as foreign key
    }
}

// Post model
class Post extends Model
{
    public function comments(): HasMany
    {
        return $this->hasMany(Comment::class);
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}
// Usage
$user = User::find(1);

// Get all posts by this user
$posts = $user->posts; // returns Collection

// Get only published posts โ€” add constraints
$published = $user->posts()
    ->where('published_at', '!=', null)
    ->orderBy('published_at', 'desc')
    ->get();

// Count posts without loading them
$count = $user->posts()->count();

// Create a new post for the user
$user->posts()->create([
    'title' => 'My New Post',
    'body'  => 'Post content here...',
]);

// Get posts with their comments (eager loading)
$posts = $user->posts()->with('comments')->get();

// Access nested relationship
foreach ($posts as $post) {
    foreach ($post->comments as $comment) {
        echo $comment->body;
    }
}

3. belongsTo โ€” Inverse of hasOne and hasMany

belongsTo is the inverse relationship. If User hasMany Posts, then Post belongsTo User. The foreign key lives on the model that defines belongsTo.

// Post model
class Post extends Model
{
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
        // Assumes foreign key is user_id on posts table
    }

    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }
}

// Comment model
class Comment extends Model
{
    public function post(): BelongsTo
    {
        return $this->belongsTo(Post::class);
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}
// Usage
$post = Post::find(1);

// Get the author of the post
$author = $post->user;
echo $author->name;
echo $author->email;

// Get the category
echo $post->category->name;

// Eager load user with posts (prevents N+1 problem)
$posts = Post::with('user', 'category')->get();

foreach ($posts as $post) {
    echo $post->user->name;     // no extra query โ€” already loaded
    echo $post->category->name; // no extra query โ€” already loaded
}

// Custom foreign key
return $this->belongsTo(User::class, 'author_id', 'id');

4. belongsToMany โ€” Many to Many

A many-to-many relationship requires a pivot table. Real-world example: a Post can have many Tags, and a Tag can belong to many Posts. A User can have many Roles, and a Role can belong to many Users.

// Database tables:
// posts:      id, title, body
// tags:       id, name, slug
// post_tag:   post_id, tag_id  โ† pivot table (alphabetical order by convention)

// Post model
class Post extends Model
{
    public function tags(): BelongsToMany
    {
        return $this->belongsToMany(Tag::class);
        // Laravel looks for post_tag pivot table automatically
    }
}

// Tag model
class Tag extends Model
{
    public function posts(): BelongsToMany
    {
        return $this->belongsToMany(Post::class);
    }
}
// Usage
$post = Post::find(1);

// Get all tags for this post
$tags = $post->tags;

// Attach tags to a post
$post->tags()->attach([1, 2, 3]);       // attach by ID
$post->tags()->attach($tag->id);

// Detach tags
$post->tags()->detach([1, 2]);          // remove specific
$post->tags()->detach();                // remove all

// Sync โ€” attach new, detach removed (perfect for checkboxes)
$post->tags()->sync([1, 3, 5]);
// Attaches 1, 3, 5 and detaches any others

// Toggle โ€” attach if not attached, detach if attached
$post->tags()->toggle([1, 2]);

// Access pivot table data
$tagsWithPivot = $post->tags()->withPivot('created_at')->get();

foreach ($tagsWithPivot as $tag) {
    echo $tag->pivot->created_at; // access pivot column
}
// Adding extra columns to pivot table
// Migration for pivot with extra column:
Schema::create('post_tag', function (Blueprint $table) {
    $table->foreignId('post_id')->constrained()->onDelete('cascade');
    $table->foreignId('tag_id')->constrained()->onDelete('cascade');
    $table->string('added_by')->nullable(); // extra pivot column
    $table->timestamps();
});

// Model โ€” expose extra pivot columns
public function tags(): BelongsToMany
{
    return $this->belongsToMany(Tag::class)
                ->withPivot('added_by')
                ->withTimestamps();
}

// Attach with extra data
$post->tags()->attach($tagId, ['added_by' => auth()->user()->name]);

5. hasOneThrough โ€” One Through a Middle Model

Access a model through an intermediate model. Real-world example: a Country has many Users, and a User has one Profile. Through User, Country can access Profile directly.

// Database tables:
// countries: id, name
// users:     id, country_id, name
// profiles:  id, user_id, bio

// Country model
class Country extends Model
{
    // Access profile through user
    public function userProfile(): HasOneThrough
    {
        return $this->hasOneThrough(
            Profile::class,  // final model
            User::class,     // intermediate model
            'country_id',    // foreign key on User
            'user_id',       // foreign key on Profile
            'id',            // local key on Country
            'id'             // local key on User
        );
    }
}

// Usage
$country = Country::find(1);
$profile = $country->userProfile;

6. hasManyThrough โ€” Many Through a Middle Model

The most useful "through" relationship. Real-world example: a Country has many Users. Each User has many Posts. So a Country has many Posts through Users.

// Database tables:
// countries: id, name
// users:     id, country_id, name
// posts:     id, user_id, title

// Country model
class Country extends Model
{
    public function posts(): HasManyThrough
    {
        return $this->hasManyThrough(
            Post::class,   // final model we want
            User::class,   // intermediate model
            'country_id',  // foreign key on User table
            'user_id',     // foreign key on Post table
            'id',          // local key on Country table
            'id'           // local key on User table
        );
    }
}

// Usage
$india = Country::where('name', 'India')->first();
$allPostsFromIndia = $india->posts; // all posts by Indian users

Eager Loading โ€” Solving the N+1 Problem

The N+1 problem is the most common performance issue in Eloquent. It happens when you load a collection and then access a relationship on each item โ€” causing one extra query per item.

// โŒ N+1 problem โ€” 1 query for posts + 1 query per post for user
$posts = Post::all(); // 1 query

foreach ($posts as $post) {
    echo $post->user->name; // 1 query per post = N queries
}
// If you have 100 posts = 101 total queries

// โœ… Eager loading โ€” 2 queries total regardless of count
$posts = Post::with('user')->get();
// Query 1: SELECT * FROM posts
// Query 2: SELECT * FROM users WHERE id IN (1, 2, 3, ...)

foreach ($posts as $post) {
    echo $post->user->name; // no extra query โ€” already loaded
}

// Eager load multiple relationships
$posts = Post::with('user', 'category', 'tags')->get();

// Nested eager loading
$posts = Post::with('user.profile', 'comments.user')->get();

// Conditional eager loading
$posts = Post::with(['comments' => function ($query) {
    $query->where('approved', true)
          ->orderBy('created_at', 'desc');
}])->get();

// withCount โ€” get count without loading records
$users = User::withCount('posts')->get();
foreach ($users as $user) {
    echo $user->posts_count; // no need to load posts collection
}

Lazy Eager Loading

// Load relationships after the fact โ€” when you already have a collection
$posts = Post::all();

// Later in the code:
$posts->load('user', 'tags');

// Useful in controllers when you conditionally need relationships
$posts = Post::all();

if ($request->has('include_author')) {
    $posts->load('user');
}

return response()->json($posts);

Relationship Query Methods

// Check if relationship exists
$user->posts()->exists(); // returns true/false

// whereHas โ€” filter by relationship condition
// Get users who have at least one published post
$users = User::whereHas('posts', function ($query) {
    $query->where('published_at', '!=', null);
})->get();

// Get users who have more than 5 posts
$users = User::whereHas('posts', function ($query) {
}, '>=', 5)->get();

// Get users who have NO posts
$users = User::doesntHave('posts')->get();

// whereDoesntHave โ€” with condition
$users = User::whereDoesntHave('posts', function ($query) {
    $query->where('published_at', '!=', null);
})->get();

// has() โ€” shorthand for whereHas without callback
$users = User::has('posts')->get(); // users with at least 1 post
$users = User::has('posts', '>=', 10)->get(); // users with 10+ posts

Real World Example โ€” Blog Application

Let us put it all together with a complete blog application showing how all relationships work in a real scenario:

// Models and their relationships:

// User hasMany Posts, hasMany Comments, belongsToMany Roles
class User extends Model
{
    public function posts(): HasMany      { return $this->hasMany(Post::class); }
    public function comments(): HasMany   { return $this->hasMany(Comment::class); }
    public function roles(): BelongsToMany { return $this->belongsToMany(Role::class); }
    public function profile(): HasOne     { return $this->hasOne(Profile::class); }
}

// Post belongsTo User, belongsTo Category, hasMany Comments, belongsToMany Tags
class Post extends Model
{
    public function user(): BelongsTo     { return $this->belongsTo(User::class); }
    public function category(): BelongsTo { return $this->belongsTo(Category::class); }
    public function comments(): HasMany   { return $this->hasMany(Comment::class); }
    public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class); }
}

// Category hasMany Posts
class Category extends Model
{
    public function posts(): HasMany { return $this->hasMany(Post::class); }
}

// Comment belongsTo Post, belongsTo User
class Comment extends Model
{
    public function post(): BelongsTo { return $this->belongsTo(Post::class); }
    public function user(): BelongsTo { return $this->belongsTo(User::class); }
}

// Tag belongsToMany Posts
class Tag extends Model
{
    public function posts(): BelongsToMany { return $this->belongsToMany(Post::class); }
}
// PostController โ€” loading data efficiently
public function show($slug)
{
    $post = Post::with([
        'user.profile',      // post author + their profile
        'category',          // post category
        'tags',              // all tags
        'comments' => function ($q) {
            $q->with('user')
              ->where('approved', true)
              ->latest();    // approved comments with their authors
        }
    ])->where('slug', $slug)->firstOrFail();

    return inertia('Post/Show', compact('post'));
}

public function index()
{
    $posts = Post::with('user', 'category', 'tags')
        ->withCount('comments')
        ->where('published_at', '!=', null)
        ->latest('published_at')
        ->paginate(10);

    return inertia('Post/Index', compact('posts'));
}

Common Mistakes to Avoid

1. Forgetting to eager load โ€” N+1 queries

// โŒ Always causes N+1
$posts = Post::all();
foreach ($posts as $post) { echo $post->user->name; }

// โœ… Always eager load when looping
$posts = Post::with('user')->get();

2. Using get() when you should use first()

// hasOne and belongsTo return a single model โ€” use ->first() or property access
// โŒ Wrong โ€” returns Collection, not a model
$profile = $user->profile()->get();

// โœ… Correct
$profile = $user->profile;           // property access (auto calls first())
$profile = $user->profile()->first(); // explicit first()

3. Pivot table naming convention

// Laravel expects alphabetical order for pivot table names
// Post and Tag โ†’ post_tag (not tag_post)
// Role and User โ†’ role_user (not user_role)

// If your pivot table has a different name โ€” specify it explicitly
return $this->belongsToMany(Tag::class, 'article_tags');

4. Using attach instead of sync for form updates

// โŒ attach() adds without removing โ€” duplicates on repeat save
$post->tags()->attach($request->tag_ids);

// โœ… sync() for form updates โ€” adds new, removes unchecked
$post->tags()->sync($request->tag_ids ?? []);

Quick Reference โ€” All Relationship Types

Relationship Method Real Example Foreign Key Location
One to One hasOne User has one Profile profiles.user_id
Inverse One to One belongsTo Profile belongs to User profiles.user_id
One to Many hasMany User has many Posts posts.user_id
Inverse One to Many belongsTo Post belongs to User posts.user_id
Many to Many belongsToMany Post has many Tags post_tag pivot table
Has One Through hasOneThrough Country โ†’ User โ†’ Profile Intermediate model
Has Many Through hasManyThrough Country โ†’ Users โ†’ Posts Intermediate model

Final Thought

Eloquent relationships are one of the features that make Laravel a joy to work with. Once they click, you stop thinking about JOINs and start thinking about your domain model โ€” how your data relates in the real world โ€” and let Eloquent handle the SQL.

The most important habit to build early is eager loading. Every time you write a loop that accesses a relationship, ask yourself โ€” did I eager load this? If the answer is no, add with() before you continue. That single habit will prevent the most common performance problem in Laravel applications.

Have a specific relationship scenario in your project you are not sure how to model? Drop us a message on our contact page - we will help you figure out the right approach.

Subscribe to Our Newsletter

Join Our Developer Community!

Unsubscribe Anytime | No Spam