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:
| Convention | Default | Customizable |
|---|---|---|
| Table name | role_user (singular, alphabetical) | ->withPivotTable('custom_table') |
| Foreign keys | user_id, role_id | ->belongsToMany(Role::class, 'custom_pivot', 'local_fk', 'related_fk') |
| Timestamps | created_at, updated_at | ->withTimestamps() |
| Extra columns | None | ->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
| Scenario | Queries | Memory | Time (1000 records) |
|---|---|---|---|
| Lazy load author | 1001 | Minimal per query | 450ms |
| Eager load author | 2 | Higher upfront | 15ms |
| Lazy load comments & author | 2001 | Minimal per query | 900ms |
| Eager load comments.author | 3 | Higher upfront | 25ms |
When Lazy Loading Is Acceptable
- Single model views —
Post::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
- Always eager load in loops — use
with()orload() - Use
withCountover manual counting — avoids extra queries - Use
whenLoadedin API resources — prevents N+1 on optional relations - Use
chunkorcursorfor large datasets — prevent memory exhaustion - Always define the inverse relationship —
belongsTois the other half ofhasOne/hasMany - Use morph maps for polymorphic types — decouples DB values from class names
- Use
has/whereHasoverall()->filter()— push filtering to SQL - Use
doesntHave/whereDoesntHavefor negative filters — expressive and efficient
Interview Questions
- Explain the N+1 problem and how eager loading solves it.
- What is a pivot table and when do you need extra columns on it?
- How does
sync()differ fromattach()andupdateExistingPivot()? - When would you use
hasManyThroughinstead ofhasMany? - Explain polymorphic relationships and give a real-world use case.
- How do morph maps prevent bugs when refactoring?
- What is
latestOfMany()and when is it useful?