Back to Blogs
2026-07-05 Abdelrhman Yasser

Eloquent Relationships: The Complete Guide

Eloquent Relationships: From Basics to Advanced Patterns

Reading Time: 35 minutes

Introduction

Eloquent ORM is Laravel's crown jewel — a beautifully expressive Active Record implementation that makes database interactions feel like natural PHP. At its heart are relationships, which transform raw foreign keys and join tables into intuitive, chainable object graphs.

Understanding Eloquent relationships deeply is the difference between writing clean, eager-loaded queries that scale and writing N+1 nightmares that bring databases to their knees.

The Problem

Without an ORM, relational data requires manual joins and hydration:

$orders = DB::table('orders')->get();
foreach ($orders as $order) {
    $user = DB::table('users')->where('id', $order->user_id)->first();
    // ...
}

This is tedious, error-prone, and impossible to optimize centrally. Eloquent relationships solve this with a declarative, cacheable, and chainable API.

Relationship Types

graph TD
    A[Eloquent Relationships] --> B[One-to-One]
    A --> C[One-to-Many]
    A --> D[Many-to-Many]
    A --> E[Has-Many-Through]
    A --> F[Polymorphic]
    A --> G[Many-to-Many Polymorphic]

    B --> B1[hasOne / belongsTo]
    C --> C1[hasMany / belongsTo]
    D --> D1[belongsToMany]
    E --> E1[hasManyThrough]
    F --> F1[morphOne / morphMany / morphTo]
    G --> G1[morphToMany / morphedByMany]

One-to-One

The simplest relationship — one record owns exactly one related record.

// User has one Profile
class User extends Model
{
    public function profile(): HasOne
    {
        return $this->hasOne(Profile::class);
    }
}

class Profile extends Model
{
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

Database Schema:

users
    id
    name
    email

profiles
    id
    user_id (FK)
    bio
    avatar_url

Usage:

$user = User::find(1);
$profile = $user->profile; // HasOne — eager or lazy loaded
$user = $profile->user;    // BelongsTo — inverse

Customizing the foreign key:

return $this->hasOne(Profile::class, 'foreign_key', 'local_key');
return $this->belongsTo(User::class, 'foreign_key', 'owner_key');

One-to-Many

A parent record has many children.

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

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

Querying:

$post = Post::find(1);
$comments = $post->comments; // Collection of all comments

// With constraints
$recent = $post->comments()
    ->where('created_at', '>=', now()->subDay())
    ->orderBy('created_at', 'desc')
    ->get();

// Count related records (without loading them)
$count = $post->comments()->count();

The loadCount shortcut:

$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
    echo $post->comments_count; // Dynamic attribute
}

Many-to-Many

When both sides can have many of each other — requires a pivot table.

class User extends Model
{
    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class)
            ->withTimestamps()
            ->withPivot('assigned_by', 'expires_at');
    }
}

class Role extends Model
{
    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class);
    }
}

Database Schema:

users
    id
    name

roles
    id
    name

role_user (pivot)
    user_id (FK)
    role_id (FK)
    assigned_by (extra pivot data)
    expires_at (extra pivot data)
    created_at
    updated_at

Pivot Table Conventions:

ConventionDefaultCustomizable
Table namerole_user (singular, alphabetical)->withPivotTable('custom_table')
Foreign keysuser_id, role_id->belongsToMany(Role::class, 'custom_pivot', 'local_fk', 'related_fk')
Timestampscreated_at, updated_at->withTimestamps()
Extra columnsNone->withPivot('column1', 'column2')

Querying Pivot Data:

$user = User::with('roles')->find(1);

foreach ($user->roles as $role) {
    echo $role->pivot->assigned_by;
    echo $role->pivot->expires_at;
}

// Filter by pivot column
$adminRoles = $user->roles()
    ->wherePivot('assigned_by', 'admin')
    ->get();

Syncing, Attaching, Detaching:

// Attach — add a relation (duplicate-safe if no unique constraint)
$user->roles()->attach($roleId, ['assigned_by' => Auth::id()]);

// Detach — remove a relation
$user->roles()->detach($roleId);

// Sync — match the given IDs exactly (detaches missing, attaches new)
$user->roles()->sync([1, 2, 3]);

// Sync with pivot data
$user->roles()->sync([
    1 => ['assigned_by' => Auth::id()],
    2 => ['assigned_by' => Auth::id()],
]);

// Toggle — attach if not exists, detach if exists
$user->roles()->toggle([1, 2, 3]);

// Update existing pivot
$user->roles()->updateExistingPivot($roleId, ['expires_at' => now()->addYear()]);

Has-Many-Through

Access distant relations through an intermediate table — perfect for dashboard-style queries.

class Country extends Model
{
    public function posts(): HasManyThrough
    {
        return $this->hasManyThrough(
            Post::class,      // Target model
            User::class,      // Intermediate model
            'country_id',     // FK on users table
            'user_id',        // FK on posts table
            'id',             // Local key on countries
            'id'              // Local key on users
        );
    }
}

Database Schema:

countries:  id, name
users:      id, country_id, name
posts:      id, user_id, title

Generated SQL:

select `posts`.*
from `posts`
inner join `users` on `posts`.`user_id` = `users`.`id`
where `users`.`country_id` = ?

Polymorphic Relationships

A model can belong to multiple other models using a single association.

graph LR
    Post --> Comment
    Video --> Comment
    Post --> Like
    Video --> Like
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');
    }
}

Database Schema:

comments
    id
    body
    commentable_id
    commentable_type  -- e.g., 'App\Models\Post', 'App\Models\Video'

Querying:

$post = Post::find(1);
$comments = $post->comments; // Only comments on this post

$comment = Comment::find(1);
$parent = $comment->commentable; // Post or Video — polymorphic!

Multi-Column Polymorphism (Laravel 10+):

For databases that need separate type and ID columns with different names:

return $this->morphMany(Comment::class, 'commentable', 'resource_type', 'resource_id');

Many-to-Many Polymorphic

The most flexible relationship — useful for tagging, liking, and categorization.

class Tag extends Model
{
    public function posts(): MorphToMany
    {
        return $this->morphedByMany(Post::class, 'taggable');
    }

    public function videos(): MorphToMany
    {
        return $this->morphedByMany(Video::class, 'taggable');
    }
}

class Post extends Model
{
    public function tags(): MorphToMany
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
}

Database Schema:

tags:         id, name
taggables:    tag_id, taggable_id, taggable_type

Advanced Querying Patterns

Constraining Eager Loads

// Load only comments with > 10 votes
$posts = Post::with(['comments' => function ($query) {
    $query->where('votes', '>', 10)->orderBy('created_at', 'desc');
}])->get();

Nested Eager Loading

// Three levels deep
$users = User::with('posts.comments.author')->get();

This loads: users → their posts → each post's comments → each comment's author — in 4 queries total (not N+1).

Lazy Eager Loading

When you already have a collection and need to load relationships:

$posts = Post::all();

if ($shouldLoadComments) {
    $posts->load('comments');
    $posts->loadCount('likes');
}

// Conditional load
$posts->load(['comments' => function ($query) {
    $query->where('approved', true);
}]);

Lazy Eager Loading on the Fly

$post = Post::find(1);
$post->load('comments.author');

Relationship Existence

// Posts that have at least one comment
$posts = Post::has('comments')->get();

// Posts with >= 3 comments
$posts = Post::has('comments', '>=', 3)->get();

// Posts with comments from banned users
$posts = Post::whereHas('comments', function ($query) {
    $query->whereHas('user', function ($q) {
        $q->where('banned', true);
    });
})->get();

// Posts that do NOT have comments
$posts = Post::whereDoesntHave('comments')->get();

// With specific relationship counts
$posts = Post::withCount([
    'comments',
    'comments as pending_comments_count' => function ($query) {
        $query->where('approved', false);
    },
])->get();

Relationship Missing

// Find users with no profile — useful for data cleanup scripts
$users = User::doesntHave('profile')->get();

Cross-Relationship Queries

// Find users who have written comments on posts containing "Laravel"
$users = User::whereHas('posts.comments', function ($query) {
    $query->where('body', 'like', '%Laravel%');
})->get();

Has One of Many (Laravel 9+)

Get the latest or oldest related record:

class User extends Model
{
    // The user's most recent login
    public function latestLogin(): HasOne
    {
        return $this->hasOne(Login::class)->latestOfMany();
    }

    // The user's oldest login
    public function oldestLogin(): HasOne
    {
        return $this->hasOne(Login::class)->oldestOfMany();
    }

    // Custom: the login with the highest score
    public function bestLogin(): HasOne
    {
        return $this->hasOne(Login::class)->ofMany('score', 'max');
    }
}

Aggregating Related Columns

// Add the highest user score directly on the post query
$posts = Post::withMax('comments', 'votes')->get();
echo $posts[0]->comments_max_votes;

Available: withMax, withMin, withSum, withAvg, withExists.

Performance: The N+1 Problem

What is N+1?

// ❌ N+1 queries
$posts = Post::all();
foreach ($posts as $post) {
    echo $post->author->name; // 1 query for posts + N queries for authors
}

Total queries: 1 (for posts) + N (one per post for author) = N+1.

The Eager Loading Solution

// ✅ 2 queries total
$posts = Post::with('author')->get();
foreach ($posts as $post) {
    echo $post->author->name;
}

Total queries: 2 (1 for posts + 1 for author).

Detecting N+1

Method 1: Laravel Debugbar The Laravel Debugbar shows every query with a stack trace. Look for repeated identical queries in a loop.

Method 2: The NPlusOneIssueDetector trait (Laravel 11+)

use Illuminate\Database\Eloquent\Attributes\PreventNPlusOnLoading;

class Post extends Model
{
    #[PreventNPlusOnLoading]
    public function comments(): HasMany
    {
        return $this->hasMany(Comment::class);
    }
}

Method 3: Manual logging in development

DB::listen(function ($query) {
    if (str_contains($query->sql, 'where `user_id`')) {
        logger()->warning('Potential N+1 detected, sql: ' . $query->sql);
    }
});

Benchmark: Eager vs Lazy Loading

ScenarioQueriesMemoryTime (1000 records)
Lazy load author1001Minimal per query450ms
Eager load author2Higher upfront15ms
Lazy load comments & author2001Minimal per query900ms
Eager load comments.author3Higher upfront25ms

When Lazy Loading Is Acceptable

  • Single model viewsPost::find(1) then $post->author (no loop)
  • Admin panels with pagination — 20 records per page, 21 queries is acceptable
  • When you explicitly don't need the relation — conditional lazy load

Common Mistakes

Mistake 1: Forgetting Eager Load in API Resources

// ❌ N+1 when the API resource is serialized
class PostResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'author' => $this->author->name, // N+1 if not loaded!
        ];
    }
}

Fix: Eager load before serialization, or use whenLoaded():

'author' => UserResource::make($this->whenLoaded('author')),

Mistake 2: Over-Eager Loading

// ❌ Loading relationships you don't use
$posts = Post::with(['comments', 'tags', 'author.profile', 'views'])->get();

Fix: Be explicit — only load what you render.

Mistake 3: Loading the Entire Table

// ❌ Memory exhaustion with 100k records
$posts = Post::with('comments')->get();

Fix: Use chunking or cursor:

Post::with('comments')->chunk(100, function ($posts) {
    foreach ($posts as $post) {
        // Process 100 at a time
    }
});

Mistake 4: Incorrect Foreign Key Order

// ❌ Wrong order in belongsToMany
return $this->belongsToMany(Role::class, 'role_user', 'role_id', 'user_id');

// ✅ Correct: pivot local FK first, then related FK
return $this->belongsToMany(Role::class, 'role_user', 'user_id', 'role_id');

Mistake 5: Polymorphic Type Mismatch

When you move models between directories:

// Stored as 'App\Models\OldPost' in commentable_type
// But the class is now 'App\Models\Post'

Fix: Use custom morph maps in AppServiceProvider:

Relation::enforceMorphMap([
    'post' => 'App\Models\Post',
    'video' => 'App\Models\Video',
]);

This keeps commentable_type as 'post' regardless of namespace changes.

Best Practices Summary

  1. Always eager load in loops — use with() or load()
  2. Use withCount over manual counting — avoids extra queries
  3. Use whenLoaded in API resources — prevents N+1 on optional relations
  4. Use chunk or cursor for large datasets — prevent memory exhaustion
  5. Always define the inverse relationshipbelongsTo is the other half of hasOne/hasMany
  6. Use morph maps for polymorphic types — decouples DB values from class names
  7. Use has/whereHas over all()->filter() — push filtering to SQL
  8. Use doesntHave/whereDoesntHave for negative filters — expressive and efficient

Interview Questions

  1. Explain the N+1 problem and how eager loading solves it.
  2. What is a pivot table and when do you need extra columns on it?
  3. How does sync() differ from attach() and updateExistingPivot()?
  4. When would you use hasManyThrough instead of hasMany?
  5. Explain polymorphic relationships and give a real-world use case.
  6. How do morph maps prevent bugs when refactoring?
  7. What is latestOfMany() and when is it useful?

Related Articles

# Laravel# PHP# Eloquent# Database# ORM# Relationships
WhatsApp
Chat on WhatsApp