#
  • Development, Database

Mastering Eloquent Relationships

Mastering Eloquent Relationships

Eloquent relationships are the feature that separates Laravel from raw SQL workflows. They let you express complex database connections as readable PHP methods, and they handle the underlying joins, foreign key lookups, and hydration for you. But with that power comes responsibility — understanding how relationships work under the hood is essential to avoiding the N+1 query problem, one of the most common performance killers in Laravel applications.

This guide covers every relationship type with real-world examples, eager loading strategies, and performance considerations you won't find in a typical quickstart.

How Relationships Work

Every Eloquent relationship is a query builder instance under the hood. When you call $user->posts, Eloquent runs a query like:

SELECT * FROM posts WHERE user_id = 1

The result is cached on the model instance for the duration of the request — subsequent calls to $user->posts return the cached result without hitting the database again. Understanding this is key to understanding why N+1 queries happen.

One-to-One

Use hasOne when one model owns exactly one of another:

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

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

Always define both sides. The BelongsTo side holds the foreign key (user_id on profiles).

// Access
$avatar = $user->profile->avatar_url;

// Create the related model
$user->profile()->create(['bio' => 'Software developer']);

// Update through the relationship
$user->profile()->update(['bio' => 'Senior developer']);

One-to-Many

The most common relationship. A Post has many Comments; a Category has many Posts:

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

    // Constrained variant — only approved comments
    public function approvedComments(): HasMany
    {
        return $this->hasMany(Comment::class)->where('approved', true);
    }
}

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

You can add query constraints directly on the relationship definition. approvedComments() is a constrained relationship — it always filters, so you never forget the condition at the call site.

// All comments
$post->comments;

// Constrained
$post->approvedComments;

// Add further constraints at call time
$post->comments()->latest()->limit(5)->get();

// Create a related record (foreign key set automatically)
$post->comments()->create([
    'user_id' => $user->id,
    'body'    => 'Great post!',
]);

Many-to-Many

When both sides of a relationship can have multiple of the other, you need a pivot table:

// Users have many Roles; Roles belong to many Users
class User extends Model
{
    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class)
            ->withPivot('assigned_at', 'assigned_by')
            ->withTimestamps();
    }
}

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

The pivot table role_user is named by convention (alphabetical, singular). withPivot() makes the pivot columns accessible on the result.

// Attach roles
$user->roles()->attach([1, 2, 3]);

// Detach a specific role
$user->roles()->detach(2);

// Sync — attaches new, detaches removed, keeps existing
$user->roles()->sync([1, 3]);

// Sync without detaching
$user->roles()->syncWithoutDetaching([4]);

// Access pivot data
foreach ($user->roles as $role) {
    echo $role->pivot->assigned_at;
}

Has-Many-Through

Access a distant relationship through an intermediate model:

// A Country has many Posts through Users
class Country extends Model
{
    public function posts(): HasManyThrough
    {
        return $this->hasManyThrough(Post::class, User::class);
    }
}

This is more readable and efficient than manually joining three tables. Laravel handles the join:

SELECT posts.* FROM posts
INNER JOIN users ON users.id = posts.user_id
WHERE users.country_id = ?

Polymorphic Relationships

When multiple models share a single relationship type — like both Posts and Videos can have Comments:

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');
    }
}

The comments table has two columns: commentable_id (the related record's ID) and commentable_type (the model class name). Eloquent handles this automatically.

// Works identically for Post and Video
$post->comments()->create(['body' => 'Nice article!']);
$video->comments()->create(['body' => 'Great video!']);

// From a comment, get the parent regardless of type
$comment->commentable; // Returns a Post or Video instance

Register morph maps in a service provider to avoid storing fully-qualified class names in your database — it makes moving classes much safer.

// In AppServiceProvider::boot()
Relation::morphMap([
    'post'  => Post::class,
    'video' => Video::class,
]);

The N+1 Problem — and How to Solve It

This is the most important concept in this entire guide.

Without eager loading:

// 1 query to fetch posts
$posts = Post::all();

foreach ($posts as $post) {
    // 1 query PER POST to fetch the author — N queries for N posts
    echo $post->author->name;
}

With 100 posts, this runs 101 queries. With eager loading, it's always 2:

// 1 query for posts + 1 query for all authors
$posts = Post::with('author')->get();

foreach ($posts as $post) {
    echo $post->author->name; // No query — already loaded
}

Nested Eager Loading

// Load posts with their author and each author's country
$posts = Post::with(['author.country', 'tags', 'comments' => function ($query) {
    $query->approved()->latest()->limit(3);
}])->paginate(15);

Prevent Lazy Loading in Development

Add this to AppServiceProvider::boot() to catch N+1 issues at development time:

Model::preventLazyLoading(! app()->isProduction());

This throws an exception when you access a relationship that wasn't eager-loaded, forcing you to fix it before it reaches production.

withCount and withSum

When you only need aggregate data, don't load the full relationship:

// Bad — loads all comments just to count them
$posts = Post::with('comments')->get();
$count = $post->comments->count(); // In-memory count on loaded collection

// Good — adds a comments_count column in the query
$posts = Post::withCount('comments')->get();
echo $post->comments_count;

// Also available: withSum, withAvg, withMin, withMax
$posts = Post::withSum('orders', 'amount')->get();
echo $post->orders_sum_amount;

Querying Relationship Existence

Filter models based on whether related records exist:

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

// Posts with more than 5 comments
Post::has('comments', '>=', 5)->get();

// Posts with at least one approved comment (constrained)
Post::whereHas('comments', function ($query) {
    $query->where('approved', true);
})->get();

// Posts with NO comments
Post::doesntHave('comments')->get();

Subquery Relationships for Performance

Sometimes two simple queries beat one complex join. For fetching the "latest" related record per parent:

class User extends Model
{
    public function latestOrder(): HasOne
    {
        return $this->hasOne(Order::class)->latestOfMany();
    }

    public function largestOrder(): HasOne
    {
        return $this->hasOne(Order::class)->ofMany('amount', 'max');
    }
}
// Efficient — uses a subquery, not a join over all orders
$users = User::with('latestOrder')->get();

Practical Performance Checklist

  • Always eager load relationships you know you'll access in a loop.
  • Use withCount instead of loading a full relationship to count records.
  • Add preventLazyLoading in local and staging environments.
  • Index foreign key columns — Eloquent doesn't do this automatically.
  • Use select() to limit columns when you don't need the full model.
// Limit columns on eager-loaded relationships
$posts = Post::with(['author:id,name,avatar'])->paginate(15);

Eloquent relationships are one of the clearest examples of Laravel's philosophy: complex operations should read like plain English. Once you understand what happens at the SQL level, you can lean into that expressiveness with confidence — knowing exactly how many queries you're running and why.

Share this post