Advanced Eloquent Relationships: Beyond HasMany and BelongsTo
Unlock the full power of Eloquent with polymorphic relations, many-through, eager loading constraints, and query scopes on relationships.
SenpaiDev
Author
Most Laravel tutorials cover hasMany and belongsTo, but Eloquent's relationship system goes much deeper. Mastering advanced relationships unlocks cleaner code and dramatically better database performance.
Polymorphic Relationships: One Table, Many Models
Polymorphic relationships let a model belong to more than one type of model. The classic use case is a comments table that can belong to both blog posts and videos:
// On Comment model
public function commentable(): MorphTo
{
return $this->morphTo();
}
// On Blog and Video models
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable');
}
The comments table stores commentable_id and commentable_type columns. Eloquent handles the type mapping automatically. You can even use MorphMap aliases to avoid storing class names in the database:
// In AppServiceProvider
Relation::morphMap([
'post' => Blog::class,
'video' => Video::class,
]);
Has Many Through: Jumping Relations
hasManyThrough lets you access distant relations through an intermediate model. Get all posts written by users in a given country without joining manually:
// Country has many Posts through Users
public function posts(): HasManyThrough
{
return $this->hasManyThrough(Blog::class, User::class);
}
This generates an efficient single JOIN query rather than loading all users first and then fetching their posts in a loop.
Eager Loading with Constraints
You can constrain eager loads to only retrieve the data you actually need:
// Load blogs with only published comments authored in the last 30 days
$blogs = Blog::with(['comments' => function ($query) {
$query->where('created_at', '>=', now()->subDays(30))
->with('author:id,name');
}])->paginate(20);
This prevents loading thousands of comments when you only display recent ones, without resorting to N+1 queries.
Relationship Query Methods
Filter parent records based on relationship existence with has() and whereHas():
// Blogs with more than 5 comments
Blog::has('comments', '>', 5)->get();
// Blogs with at least one comment from a verified user
Blog::whereHas('comments.author', function ($query) {
$query->whereNotNull('email_verified_at');
})->get();
// Blogs WITHOUT any comments (great for moderation dashboards)
Blog::doesntHave('comments')->get();
Aggregates Without Loading Collections
Never load a full collection just to count it:
// Bad - loads all comments into memory
$count = $blog->comments()->get()->count();
// Good - single COUNT() query
$count = $blog->comments()->count();
// Even better - load the count as a property via withCount()
$blogs = Blog::withCount('comments')->get();
// Access as: $blog->comments_count
Dynamic Relationships with ofMany
The hasOne...ofMany family lets you define "the most recent", "the highest-rated", or "the oldest" as a first-class relationship:
// User's most recent login
public function latestLogin(): HasOne
{
return $this->hasOne(LoginRecord::class)->latestOfMany();
}
// User's highest-value invoice
public function largestInvoice(): HasOne
{
return $this->hasOne(Invoice::class)->ofMany('amount', 'max');
}
This returns a proper relationship that can be eager-loaded, preventing N+1 queries that plague "get the latest X for each Y" patterns.
Eloquent's relationship system is one of Laravel's greatest strengths. Investing time to understand these advanced patterns pays dividends in cleaner code, fewer queries, and applications that scale gracefully.
Laravel field notes
How To Apply This In A Real Laravel App
Use the article as a starting point, then validate the idea against the shape of your application. In Laravel projects, the safest pattern is to make the first version small, measurable, and easy to remove if the tradeoff is wrong.
Implementation approach
Start with one route, one controller or action, and one test that proves the expected behavior. Once the path is stable, extract shared code into a service class or action only if a second caller needs it.
For production work, keep config in environment variables, cache expensive reads, and add clear failure states. A feature that works locally but fails silently in a queue, scheduler, or cached config environment is not ready for users.
Review Checklist
- Add a feature or regression test before changing shared behavior.
- Run the route through production-like cache settings with config and route caching enabled.
- Check authorization, validation, and error responses before exposing the feature publicly.
- Document any non-obvious tradeoff in the code or article notes so future edits stay honest.
Written by
SenpaiDev
Developer and publisher at SenpaiDev, writing practical notes on Laravel, PHP, browser tools, and shipping better web products.
Comments (0)
Join the conversation
Log in to commentNo comments yet. Be the first to share your thoughts!