Eloquent ORM: Complex Relationships Made Simple
Master Eloquent relationships in Laravel: from hasOne and belongsToMany to polymorphic relations, hasOneThrough, and hasManyThrough. Includes eager loading, query scopes, accessors, mutators, and model events.
Written by Francisco Zapata
Eloquent, Laravel's ORM, transforms database interactions into an expressive and elegant experience. Its relationship system is particularly powerful, allowing you to model everything from simple links to complex data structures with clean, fluent syntax. In this article, we will explore every available relationship type along with advanced techniques like eager loading, scopes, and model events.
Basic Relationships
One to One (hasOne / belongsTo)
A one-to-one relationship links a record in one table to exactly one record in another table.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
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);
}
}
The corresponding migration:
Schema::create('profiles', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('bio')->nullable();
$table->string('avatar_url')->nullable();
$table->string('website')->nullable();
$table->timestamps();
});
Practical usage:
// Access a user's profile
$profile = User::find(1)->profile;
// Access the user from a profile
$user = Profile::find(1)->user;
// Create an associated profile
$user->profile()->create([
'bio' => 'Laravel Developer',
'website' => 'https://example.com',
]);
One to Many (hasMany / belongsTo)
The one-to-many relationship is one of the most common. A parent model can have multiple children.
class Post extends Model
{
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}
class Comment extends Model
{
public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
// Get all comments for a post
$comments = Post::find(1)->comments;
// Add a comment
$post->comments()->create([
'user_id' => auth()->id(),
'body' => 'Great article',
]);
// Count comments
$count = $post->comments()->count();
Many to Many (belongsToMany)
The many-to-many relationship uses an intermediate pivot table to link records from both tables.
class Post extends Model
{
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class)
->withTimestamps()
->withPivot('order');
}
}
class Tag extends Model
{
public function posts(): BelongsToMany
{
return $this->belongsToMany(Post::class)
->withTimestamps();
}
}
Pivot table migration:
Schema::create('post_tag', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->foreignId('tag_id')->constrained()->cascadeOnDelete();
$table->integer('order')->default(0);
$table->timestamps();
$table->unique(['post_id', 'tag_id']);
});
Working with the relationship:
// Attach tags
$post->tags()->attach([1, 2, 3]);
// Attach with pivot data
$post->tags()->attach([
1 => ['order' => 1],
2 => ['order' => 2],
]);
// Sync (replaces all)
$post->tags()->sync([1, 3, 5]);
// Sync without detaching existing
$post->tags()->syncWithoutDetaching([4, 5]);
// Detach
$post->tags()->detach([2]);
// Toggle (attach if missing, detach if present)
$post->tags()->toggle([1, 3]);
Advanced Relationships
Has One Through
Access a remote model through an intermediate model using hasOneThrough.
class Country extends Model
{
// countries -> users -> profile
public function latestUserProfile(): HasOneThrough
{
return $this->hasOneThrough(
Profile::class, // Final model
User::class, // Intermediate model
'country_id', // FK on users
'user_id', // FK on profiles
'id', // PK on countries
'id' // PK on users
);
}
}
You can also use the fluent syntax:
public function latestUserProfile(): HasOneThrough
{
return $this->throughUsers()->hasProfile();
}
Has Many Through
Similar to hasOneThrough but for retrieving multiple records through an intermediate model.
class Country extends Model
{
// countries -> users -> posts
public function posts(): HasManyThrough
{
return $this->hasManyThrough(Post::class, User::class);
}
}
class Project extends Model
{
// projects -> environments -> deployments
public function deployments(): HasManyThrough
{
return $this->hasManyThrough(Deployment::class, Environment::class);
}
}
// Get all posts from a country
$posts = Country::find(1)->posts;
// With constraints
$recentPosts = Country::find(1)->posts()
->where('published_at', '>=', now()->subMonth())
->latest()
->get();
Polymorphic Relationships
Polymorphic relationships allow a child model to belong to more than one type of parent model using a single association.
One-to-Many Polymorphic (morphMany / morphTo)
class Comment extends Model
{
public function commentable(): MorphTo
{
return $this->morphTo();
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
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');
}
}
Migration for the commentable table:
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->morphs('commentable'); // Creates commentable_type and commentable_id
$table->text('body');
$table->timestamps();
});
// Comment on a post
$post->comments()->create([
'user_id' => auth()->id(),
'body' => 'Great article',
]);
// Comment on a video
$video->comments()->create([
'user_id' => auth()->id(),
'body' => 'Amazing video',
]);
// Get the parent from a comment
$parent = $comment->commentable; // Returns Post or Video
Many-to-Many Polymorphic (morphToMany / morphedByMany)
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');
}
}
class Video extends Model
{
public function tags(): MorphToMany
{
return $this->morphToMany(Tag::class, 'taggable');
}
}
Custom Morph Map
To avoid storing full class names in the database, define a morph map.
// AppServiceProvider
use Illuminate\Database\Eloquent\Relations\Relation;
public function boot(): void
{
Relation::enforceMorphMap([
'post' => \App\Models\Post::class,
'video' => \App\Models\Video::class,
'comment' => \App\Models\Comment::class,
]);
}
Eager Loading: Eliminating the N+1 Problem
Eager loading is essential for performance. Without it, every relationship access triggers an additional query.
// N+1 problem (one query per post)
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // Extra query on each iteration
}
// Solution with eager loading (only 2 queries)
$posts = Post::with('author')->get();
foreach ($posts as $post) {
echo $post->author->name; // No additional query
}
// Eager loading multiple relationships
$posts = Post::with(['author', 'tags', 'comments.user'])->get();
// Constrained eager loading
$posts = Post::with([
'comments' => fn ($query) => $query->where('approved', true)->latest(),
'author:id,name,avatar',
])->get();
// Lazy eager loading
$posts = Post::all();
$posts->load('author');
Preventing Lazy Loading in Development
// AppServiceProvider
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
Query Scopes
Scopes encapsulate frequently used query logic into reusable model methods.
Local Scopes
class Post extends Model
{
public function scopePublished(Builder $query): Builder
{
return $query->where('status', 'published')
->whereNotNull('published_at')
->where('published_at', '<=', now());
}
public function scopeByAuthor(Builder $query, User $author): Builder
{
return $query->where('user_id', $author->id);
}
public function scopePopular(Builder $query, int $minViews = 100): Builder
{
return $query->where('views', '>=', $minViews)
->orderByDesc('views');
}
public function scopeRecent(Builder $query, int $days = 30): Builder
{
return $query->where('created_at', '>=', now()->subDays($days));
}
}
// Chained usage
$posts = Post::published()
->popular(500)
->recent(7)
->with('author')
->paginate(10);
Accessors and Mutators
Accessors and mutators transform values when reading from or writing to model attributes.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
// Accessor: transforms on read
protected function fullName(): Attribute
{
return Attribute::make(
get: fn () => "{$this->first_name} {$this->last_name}",
);
}
// Mutator: transforms on write
protected function email(): Attribute
{
return Attribute::make(
get: fn (string $value) => $value,
set: fn (string $value) => strtolower($value),
);
}
// Cached accessor
protected function initials(): Attribute
{
return Attribute::make(
get: fn () => collect(explode(' ', $this->full_name))
->map(fn ($segment) => strtoupper(substr($segment, 0, 1)))
->join(''),
)->shouldCache();
}
}
Model Events and Observers
Model events let you execute logic automatically when specific actions occur on a model.
<?php
namespace App\Observers;
use App\Models\Post;
use Illuminate\Support\Str;
class PostObserver
{
public function creating(Post $post): void
{
$post->slug = $post->slug ?? Str::slug($post->title);
$post->user_id = $post->user_id ?? auth()->id();
}
public function updating(Post $post): void
{
if ($post->isDirty('title')) {
$post->slug = Str::slug($post->title);
}
}
public function deleting(Post $post): void
{
$post->tags()->detach();
$post->comments()->delete();
}
}
Register the observer on the model:
class Post extends Model
{
protected static function booted(): void
{
static::observe(PostObserver::class);
}
}
Or use closures directly:
protected static function booted(): void
{
static::creating(function (Post $post) {
$post->reading_time = self::calculateReadingTime($post->content);
});
}
Advanced Relationship Queries
// Posts with at least 5 comments
$posts = Post::has('comments', '>=', 5)->get();
// Posts with comments from a specific user
$posts = Post::whereHas('comments', function ($query) use ($userId) {
$query->where('user_id', $userId);
})->get();
// Posts without comments
$posts = Post::doesntHave('comments')->get();
// Inline relationship counting
$posts = Post::withCount(['comments', 'tags'])->get();
echo $posts->first()->comments_count;
// Aggregate relationship data
$users = User::withSum('orders', 'total')
->withAvg('reviews', 'rating')
->get();
echo $users->first()->orders_sum_total;
Conclusion
Eloquent's relationship system is one of Laravel's most powerful features. From simple relationships like hasOne and hasMany to advanced constructs like polymorphic and hasManyThrough, Eloquent provides a fluent API that abstracts away SQL complexity. Combining relationships with eager loading, query scopes, accessors, and model events lets you build expressive, efficient, and maintainable data models. Mastering these tools is key to unlocking Laravel's full potential in projects of any scale.
Comments (0)
Leave a comment
Be the first to comment