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.

SE

SenpaiDev

Author

| | 4 min read | 1 |
Original article Updated Apr 27, 2026 Editorial standards

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.
SE

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 comment

No comments yet. Be the first to share your thoughts!

Newsletter

Get useful digital tips in your inbox

Get practical guides on files, privacy, productivity, writing, online tools, and web work. No spam, no daily blasts, just useful updates.

No spam, unsubscribe anytime. We respect your privacy.