Laravel Mastery: Advanced Eloquent Relationships

15 minute read Laravel Mastery · Part 1

Master complex Eloquent relationships including polymorphic relations, has-many-through, and custom pivot tables with practical examples

Laravel’s Eloquent ORM provides powerful relationship features that go far beyond basic one-to-many and many-to-many associations. In this comprehensive guide, we’ll explore advanced relationship patterns that will elevate your Laravel applications from good to exceptional.

Why This Matters in Real Apps

Advanced Eloquent relationships are not about clever syntax. They are about keeping your domain model truthful while keeping queries predictable. Once an app has more than a handful of models, relationship design directly impacts:

  • Query count (N+1 problems are usually relationship problems in disguise)
  • Readability (a good relationship name is better than a comment)
  • Data integrity (the right constraints and pivots stop future bugs)
  • Performance under load (eager loading and indexes become non-negotiable)

Design Rationale and Trade-offs

Prefer relationships over manual joins

Manual joins can be fine for reporting queries, but for application logic they tend to scatter your domain across controllers and services. A well-defined relationship gives you a single, reusable place to express intent.

Polymorphic vs explicit models

Polymorphic relations are excellent for shared concepts like comments, attachments, activity logs, and tags. The trade-off is that they can make strict database constraints and cross-table indexes harder. If the relationship is core to the product (for example, billing), explicit models and foreign keys usually age better.

Pivot tables are part of your domain

A pivot is not “just a join”. If you have metadata like role, added_by, expires_at, or price_locked_at, treat the pivot as a first-class part of the model. That choice tends to make authorisation and auditing simpler later.

Practical Performance Notes

  • If you query a list page, assume you need with() from day one.
  • Index the foreign keys you query by, not just the ones that “look obvious”.
  • Be wary of loading large relations without scoping. A relationship should be cheap by default.

FAQ

When should I stop using Eloquent and switch to raw queries?
When the query is primarily for analytics or reporting, or when you need a database-specific feature that Eloquent cannot express clearly. For application workflows, keep the relationship model and drop to query builder only for the hotspot.

Do morph relations hurt performance?
Not automatically. The risk is usually missing indexes and accidentally loading large relation graphs. Be deliberate about indexes and eager loading.

Is it OK to eager load everything?
No. Eager load what you render or compute on. Loading unused relations is wasted memory and time.

Key Takeaways

  • Model intent first, optimise second, but measure both.
  • Relationships should be scoped so they are safe on list pages.
  • Pivots and constraints are what keep your model honest long-term.

Why Advanced Relationships Matter

Complex applications require sophisticated data modeling. While basic relationships work for simple scenarios, real-world applications often need:

  • Flexible data structures that adapt to changing requirements
  • Performance optimizations for complex queries
  • Clean, maintainable code that reflects business logic accurately
  • Scalable architectures that handle growth gracefully

Let’s dive into the advanced techniques that make this possible.

Polymorphic Relationships: The Swiss Army Knife

Polymorphic relationships allow a model to belong to multiple other models on a single association. This is incredibly powerful for scenarios like comments, images, or tags that can be attached to various content types.

Basic Polymorphic Setup

// Migration
Schema::create('comments', function (Blueprint $table) {
    $table->id();
    $table->text('content');
    $table->unsignedBigInteger('commentable_id');
    $table->string('commentable_type');
    $table->timestamps();

    $table->index(['commentable_id', 'commentable_type']);
});
// Comment Model
class Comment extends Model
{
    public function commentable()
    {
        return $this->morphTo();
    }
}

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

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

Advanced Polymorphic: Many-to-Many

For complex scenarios like tagging systems, use polymorphic many-to-many relationships:

// Migration for taggables pivot table
Schema::create('taggables', function (Blueprint $table) {
    $table->id();
    $table->unsignedBigInteger('tag_id');
    $table->unsignedBigInteger('taggable_id');
    $table->string('taggable_type');
    $table->timestamps();

    $table->unique(['tag_id', 'taggable_id', 'taggable_type']);
    $table->foreign('tag_id')->references('id')->on('tags');
});
// Tag Model
class Tag extends Model
{
    public function posts()
    {
        return $this->morphedByMany(Post::class, 'taggable');
    }

    public function videos()
    {
        return $this->morphedByMany(Video::class, 'taggable');
    }
}

// In your Post/Video models
public function tags()
{
    return $this->morphToMany(Tag::class, 'taggable');
}

Usage Examples

// Attach tags to different content types
$post = Post::find(1);
$video = Video::find(1);
$tag = Tag::where('name', 'tutorial')->first();

$post->tags()->attach($tag);
$video->tags()->attach($tag);

// Query across all tagged content
$tutorialContent = collect()
    ->merge($tag->posts)
    ->merge($tag->videos)
    ->sortByDesc('created_at');

// More efficient: Use polymorphic query
$allTagged = $tag->taggables()->with('taggable')->get();

Has-Many-Through: Bridging Complex Relationships

Has-many-through relationships provide shortcuts through intermediate models, perfect for scenarios like “all posts by users in a specific country.”

Classic Example: Countries → Users → Posts

// Models
class Country extends Model
{
    public function posts()
    {
        return $this->hasManyThrough(Post::class, User::class);
    }

    public function users()
    {
        return $this->hasMany(User::class);
    }
}

class User extends Model
{
    public function country()
    {
        return $this->belongsTo(Country::class);
    }

    public function posts()
    {
        return $this->hasMany(Post::class);
    }
}

class Post extends Model
{
    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

Advanced Has-Many-Through with Custom Keys

class Project extends Model
{
    public function deployments()
    {
        return $this->hasManyThrough(
            Deployment::class,
            Environment::class,
            'project_id',     // Foreign key on environments table
            'environment_id', // Foreign key on deployments table
            'id',            // Local key on projects table
            'id'             // Local key on environments table
        );
    }
}

// Usage
$project = Project::find(1);
$recentDeployments = $project->deployments()
    ->where('status', 'successful')
    ->orderBy('deployed_at', 'desc')
    ->limit(10)
    ->get();

Custom Pivot Tables: Beyond Basic Many-to-Many

Standard pivot tables work for simple associations, but complex applications often need additional data on the relationship itself.

Enhanced Pivot with Timestamps and Additional Fields

// Migration
Schema::create('project_user', function (Blueprint $table) {
    $table->id();
    $table->foreignId('project_id')->constrained();
    $table->foreignId('user_id')->constrained();
    $table->string('role'); // admin, member, viewer
    $table->timestamp('joined_at');
    $table->timestamp('last_active_at')->nullable();
    $table->json('permissions')->nullable();
    $table->boolean('is_active')->default(true);
    $table->timestamps();

    $table->unique(['project_id', 'user_id']);
});

Custom Pivot Model

class ProjectUser extends Pivot
{
    protected $table = 'project_user';

    protected $casts = [
        'permissions' => 'array',
        'joined_at' => 'datetime',
        'last_active_at' => 'datetime',
    ];

    public function scopeActive($query)
    {
        return $query->where('is_active', true);
    }

    public function scopeRole($query, $role)
    {
        return $query->where('role', $role);
    }

    public function isAdmin()
    {
        return $this->role === 'admin';
    }

    public function hasPermission($permission)
    {
        return in_array($permission, $this->permissions ?? []);
    }
}

Using Custom Pivot

class Project extends Model
{
    public function users()
    {
        return $this->belongsToMany(User::class)
            ->using(ProjectUser::class)
            ->withPivot(['role', 'joined_at', 'last_active_at', 'permissions', 'is_active'])
            ->withTimestamps();
    }

    public function admins()
    {
        return $this->users()->wherePivot('role', 'admin');
    }

    public function activeMembers()
    {
        return $this->users()->wherePivot('is_active', true);
    }
}

// Usage examples
$project = Project::find(1);

// Add user with specific role and permissions
$project->users()->attach($user->id, [
    'role' => 'admin',
    'joined_at' => now(),
    'permissions' => ['manage_users', 'deploy', 'view_analytics'],
    'is_active' => true,
]);

// Query through pivot
$admins = $project->admins()->get();

// Access pivot data
foreach ($project->users as $user) {
    echo "Role: " . $user->pivot->role;
    echo "Joined: " . $user->pivot->joined_at->diffForHumans();

    if ($user->pivot->isAdmin()) {
        echo "This user is an admin";
    }
}

// Update pivot data
$project->users()->updateExistingPivot($user->id, [
    'last_active_at' => now(),
    'role' => 'member'
]);

Performance Optimization Strategies

Advanced relationships can impact performance if not handled carefully. Here are proven optimization techniques:

1. Eager Loading with Constraints

// Instead of N+1 queries
$posts = Post::all();
foreach ($posts as $post) {
    echo $post->user->name; // N+1 problem
}

// Use eager loading
$posts = Post::with('user')->get();

// Eager loading with constraints
$posts = Post::with([
    'user:id,name,email',
    'comments' => function ($query) {
        $query->where('approved', true)
              ->orderBy('created_at', 'desc')
              ->limit(5);
    }
])->get();

2. Conditional Eager Loading

$posts = Post::with([
    'user',
    'comments' => function ($query) use ($includeUnapproved) {
        if (!$includeUnapproved) {
            $query->where('approved', true);
        }
    }
])->get();
// Inefficient: Loads all comments to count them
$posts = Post::with('comments')->get();
foreach ($posts as $post) {
    echo $post->comments->count();
}

// Efficient: Uses aggregate queries
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
    echo $post->comments_count;
}

// Multiple counts with constraints
$posts = Post::withCount([
    'comments',
    'comments as approved_comments_count' => function ($query) {
        $query->where('approved', true);
    }
])->get();
$users = User::addSelect([
    'latest_post_title' => Post::select('title')
        ->whereColumn('user_id', 'users.id')
        ->orderBy('created_at', 'desc')
        ->limit(1)
])->get();

Real-World Implementation: Content Management System

Let’s put it all together with a practical example - a flexible CMS that can handle various content types:

// Content Model (polymorphic parent)
class Content extends Model
{
    public function contentable()
    {
        return $this->morphTo();
    }

    public function tags()
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }

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

    public function author()
    {
        return $this->belongsTo(User::class, 'author_id');
    }
}

// Specific content types
class Article extends Model
{
    public function content()
    {
        return $this->morphOne(Content::class, 'contentable');
    }
}

class Video extends Model
{
    public function content()
    {
        return $this->morphOne(Content::class, 'contentable');
    }
}

// Usage
$recentContent = Content::with([
    'contentable',
    'author:id,name',
    'tags:id,name'
])
->withCount('comments')
->where('published_at', '<=', now())
->orderBy('published_at', 'desc')
->paginate(15);

foreach ($recentContent as $content) {
    $specificContent = $content->contentable; // Article or Video
    echo "Type: " . class_basename($specificContent);
    echo "Title: " . $specificContent->title;
    echo "Author: " . $content->author->name;
    echo "Comments: " . $content->comments_count;
}

Common Pitfalls and Solutions

1. N+1 Query Problems

Problem: Forgetting to eager load relationships Solution: Always use with() for known relationships, consider lazy eager loading with load()

2. Memory Issues with Large Datasets

Problem: Loading too much data into memory Solution: Use chunking, cursor pagination, or streaming

// Instead of loading all at once
Content::with('contentable')->get(); // Memory issue with large datasets

// Use chunking
Content::with('contentable')->chunk(100, function ($contents) {
    foreach ($contents as $content) {
        // Process content
    }
});

3. Inefficient Polymorphic Queries

Problem: Separate queries for each polymorphic type Solution: Use morphWith() for eager loading specific types

$comments = Comment::with([
    'commentable' => function (MorphTo $morphTo) {
        $morphTo->morphWith([
            Post::class => ['author'],
            Video::class => ['channel'],
        ]);
    }
])->get();

Next Steps

In Laravel Mastery #2: Custom Artisan Commands, we’ll explore building powerful command-line tools that can automate complex database operations, including relationship maintenance and data migration tasks.

Key Takeaways

  • Polymorphic relationships provide flexibility for models that can belong to multiple parent types
  • Has-many-through relationships create shortcuts through intermediate models
  • Custom pivot tables store additional relationship data efficiently
  • Performance optimization is crucial - always consider eager loading and query efficiency
  • Real-world applications often combine multiple relationship types for complex data modeling

Advanced Eloquent relationships are powerful tools that, when used correctly, create clean, maintainable, and efficient Laravel applications. Master these patterns, and you’ll be equipped to handle even the most complex data modeling challenges.


Looking for Laravel support? I help businesses across Chester and the North West build and maintain Laravel applications. Learn more about my Laravel services or get in touch.