0x55aa
← Back to Blog

Laravel Eloquent Relationships: Beyond hasMany and belongsTo 🔗

7 min read

Laravel Eloquent Relationships: Beyond hasMany and belongsTo 🔗

You know hasMany and belongsTo. Cool! But Eloquent has some WILD relationship tricks that'll make your queries faster and your code cleaner. Let's dig into the good stuff most tutorials skip!

Why Relationships Matter (Like, A LOT) 🎯

Real talk: I once saw someone use 47 database queries to load a single page. The culprit? Not understanding relationships. Don't be that developer!

Think of relationships like knowing shortcuts in your neighborhood. Sure, you COULD take the long way every time, but why would you when there's a faster route?

1. Many-to-Many with Extra Data (The Pivot Table Magic) 🎭

The scenario: Users can join teams, but you also need to track WHEN they joined and their role.

Basic many-to-many:

// User model
public function teams()
{
    return $this->belongsToMany(Team::class);
}

This works but you lose the extra data! Here's the fix:

// User model
public function teams()
{
    return $this->belongsToMany(Team::class)
        ->withPivot('role', 'joined_at') // Access extra columns
        ->withTimestamps(); // Auto-manage created_at/updated_at
}

// Now you can do this:
$user->teams->each(function ($team) {
    echo $team->pivot->role; // 'admin', 'member', etc.
    echo $team->pivot->joined_at; // When they joined!
});

Pro tip: Name your pivot table alphabetically: team_user (not user_team). Laravel convention!

2. Has-One-Through: The Indirect Relationship 🔄

The scenario: A user has one account. An account has one billing address. You want to get a user's billing address directly.

The long way:

$billingAddress = $user->account->billingAddress; // Two jumps!

The shortcut:

// User model
public function billingAddress()
{
    return $this->hasOneThrough(
        BillingAddress::class, // Final model
        Account::class,        // Intermediate model
        'user_id',            // Foreign key on accounts table
        'account_id',         // Foreign key on billing_addresses table
        'id',                 // Local key on users table
        'id'                  // Local key on accounts table
    );
}

// Now it's clean:
$billingAddress = $user->billingAddress; // One elegant call! ✨

Translation: "Jump through Account to get BillingAddress." Laravel handles the joins for you!

3. Polymorphic Relationships: One Model, Many Parents 🦎

The scenario: Comments can belong to Posts OR Videos OR Photos. Don't want 3 different comment tables!

// Comment model
public function commentable()
{
    return $this->morphTo();
}

// Post model
public function comments()
{
    return $this->morphMany(Comment::class, 'commentable');
}

// Video model (same thing!)
public function comments()
{
    return $this->morphMany(Comment::class, 'commentable');
}

The magic: One comments table serves EVERYTHING!

// Migration
Schema::create('comments', function (Blueprint $table) {
    $table->id();
    $table->text('body');
    $table->morphs('commentable'); // Creates commentable_id and commentable_type
    $table->timestamps();
});

// Usage
$post->comments; // Gets comments for this post
$video->comments; // Gets comments for this video
$comment->commentable; // Gets the parent (Post or Video)

Real-world use cases:

  • Likes on multiple models
  • Images for products, users, posts
  • Tags for articles, videos, courses
  • Comments (like above!)

4. Eager Loading Constraints: Load Smart, Not Hard 🧠

The problem: Loading ALL comments when you only need approved ones.

Bad way:

$posts = Post::with('comments')->get();

foreach ($posts as $post) {
    $approved = $post->comments->where('approved', true); // Filtering AFTER loading!
}

Good way:

$posts = Post::with(['comments' => function ($query) {
    $query->where('approved', true)
        ->orderBy('created_at', 'desc')
        ->limit(5); // Only get 5 latest approved comments!
}])->get();

foreach ($posts as $post) {
    // Already filtered and sorted!
    echo $post->comments;
}

The difference: Load 1000 comments vs load 50. Your database will send you a thank-you card! 📬

5. Querying Relationship Existence: "Show me posts that have..." 🔍

The scenario: Get all users who have at least one published post.

Inefficient way:

$users = User::all()->filter(function ($user) {
    return $user->posts()->where('published', true)->count() > 0;
});
// This runs a query for EACH user! 😱

Efficient way:

// Users with at least one published post
$users = User::whereHas('posts', function ($query) {
    $query->where('published', true);
})->get();

// Users with exactly 5 posts
$users = User::has('posts', '=', 5)->get();

// Users with 10+ posts
$users = User::has('posts', '>=', 10)->get();

One query. That's it! Use has() for counts, whereHas() for conditions.

Opposite: doesntHave() and whereDoesntHave() for "users WITHOUT posts"

// Users who never posted
$lurkers = User::doesntHave('posts')->get();

6. Lazy Eager Loading: "Oops, Forgot to Load That!" 🤦

The situation: You already loaded users but forgot to include their posts. Don't want to re-query!

$users = User::all(); // Forgot to load posts!

// Later in your code...
$users->load('posts'); // Load posts NOW for all users!

// Even better, with constraints:
$users->load(['posts' => function ($query) {
    $query->where('published', true);
}]);

Translation: "I messed up my eager loading. Fix it without re-querying users!"

7. Count Relationships Without Loading Them 📊

The scenario: Show "Post count" next to each user without loading all posts.

Wasteful:

$users = User::with('posts')->get();

foreach ($users as $user) {
    echo $user->posts->count(); // Loaded ALL posts just to count!
}

Smart:

$users = User::withCount('posts')->get();

foreach ($users as $user) {
    echo $user->posts_count; // Just the number! No loading posts!
}

// Multiple counts:
$users = User::withCount(['posts', 'comments', 'likes'])->get();
// Gives you: posts_count, comments_count, likes_count

The magic: One query with a COUNT join. Fast as lightning! ⚡

8. Custom Pivot Model: Full Control! 🎮

The scenario: Your pivot table has so much logic it needs its own model.

// Create a Pivot model
class TeamUser extends Pivot
{
    protected $table = 'team_user';

    protected $casts = [
        'joined_at' => 'datetime',
        'permissions' => 'array', // JSON column!
    ];

    // Add methods!
    public function isAdmin(): bool
    {
        return $this->role === 'admin';
    }
}

// User model
public function teams()
{
    return $this->belongsToMany(Team::class)
        ->using(TeamUser::class) // Use custom pivot!
        ->withPivot('role', 'permissions', 'joined_at')
        ->withTimestamps();
}

// Usage
if ($user->teams->first()->pivot->isAdmin()) {
    // Do admin stuff!
}

When to use: When your pivot table has business logic or needs casts/accessors.

Bonus Round: The Power Moves 💪

Default models for empty relationships:

// Instead of checking if null
public function avatar()
{
    return $this->hasOne(Avatar::class)
        ->withDefault([
            'url' => '/images/default-avatar.png'
        ]);
}

// No more null checks!
echo $user->avatar->url; // Always works!

Touch parent timestamps when child changes:

// Comment model
protected $touches = ['post'];

// Now when you update a comment:
$comment->update(['body' => 'Updated!']);
// The post's updated_at also changes! Good for cache busting!

Conditional relationships:

// User model
public function latestPost()
{
    return $this->hasOne(Post::class)->latestOfMany();
}

public function oldestPost()
{
    return $this->hasOne(Post::class)->oldestOfMany();
}

The Relationship Survival Guide 📖

Use the right tool:

  • Basic parent-child? hasMany / belongsTo
  • Many-to-many? belongsToMany with pivot
  • Polymorphic needs? morphMany / morphTo
  • Indirect access? hasManyThrough / hasOneThrough
  • Filtering? whereHas() / whereDoesntHave()
  • Just counting? withCount()
  • Forgot to load? load() to the rescue!

Real Talk 💬

Q: "Should I always use relationships or sometimes just join manually?"

A: Use relationships 95% of the time! They're readable, maintainable, and Laravel optimizes them. Manual joins only when you're doing something really exotic.

Q: "What's the performance difference between with() and lazy loading?"

A: HUGE! with() = 2 queries. Lazy loading = N+1 queries (could be hundreds!). Always eager load when you know you'll need the data!

Q: "How do I know when to use hasManyThrough?"

A: When you need data that's "two relationships away." Like: Country → User → Post. Get a country's posts without loading users!

Q: "Polymorphic relationships seem complex. When do I really need them?"

A: When multiple models share the same feature. If you're about to make post_comments, video_comments, and photo_comments tables - STOP. Use polymorphic!

The Bottom Line

Eloquent relationships are like having a really smart assistant:

  1. Basic relationships - "Get me this user's posts"
  2. Eager loading - "Get all the data I'll need upfront"
  3. Constraints - "Only get the good stuff"
  4. Existence queries - "Find records that have/don't have related data"
  5. Polymorphic - "One feature, many models"
  6. Through relationships - "Take the shortcut"

Stop writing raw SQL joins for everything. Let Eloquent do the heavy lifting while you focus on building features!

Think of it this way: Would you rather manually draw a map every time you need directions, or just use Google Maps? Eloquent relationships ARE your Google Maps for database queries! 🗺️✨


Want to discuss advanced Eloquent patterns? Connect on LinkedIn. Let's talk database optimization!

Found this helpful? Star this blog on GitHub for more Laravel deep dives!

Now go build some elegant relationships! 🔗💫