Laravel Mastery: Advanced Eloquent relationships

Polymorphic relations, has-many-through and custom pivot models in Eloquent, with working examples and the eager loading that keeps them fast.

Updated 15 min read

Eloquent’s hasMany and belongsToMany cover most relationships. Three patterns handle the cases they don’t: polymorphic relations, has-many-through, and custom pivot models.

A polymorphic relation lets one model, such as a comment, belong to several parent types through a _type and _id column pair. Has-many-through reaches across an intermediate model, for example all posts by users in one country. A custom pivot model gives a join table its own casts and methods. It earns its place once the link carries data, such as a role or a join date.

Each pattern has a cost. Polymorphic columns can’t hold a database foreign key, and every relationship is an N+1 problem waiting to happen. This guide covers all three with working code, then the eager-loading habits that keep them fast. The examples assume Laravel 11 or later. Where older versions behave differently, I say so.

Polymorphic relationships

A polymorphic relationship lets a model belong to more than one other model on a single association. It suits comments, images or tags that attach to several content types.

Fig. 01 Morph
One relationship, many models. Each tool on the handle stands for a model the comments attach to. Point along the handle to open one, and the read-out names its model.

Basic polymorphic setup

// Migration
Schema::create('comments', function (Blueprint $table) {
    $table->id();
    $table->text('content');
    $table->morphs('commentable'); // commentable_id, commentable_type and an index on both
    $table->timestamps();
});
// 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');
    }
}

When not to use them

Polymorphic relations work best for shared concepts: comments, attachments, tags and activity logs. The trade-off is integrity. The database can’t enforce a foreign key on commentable_id, because it might point at any of several tables. If the relationship is core to the product, billing for example, explicit models and real foreign keys age better.

Advanced polymorphic: many-to-many

For a tagging system, where one tag can sit on many types of content, use a polymorphic many-to-many relationship:

// Migration for taggables pivot table
Schema::create('taggables', function (Blueprint $table) {
    $table->foreignId('tag_id')->constrained()->cascadeOnDelete();
    $table->morphs('taggable');
    $table->timestamps();

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

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

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

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');

That works while you have two types. When you want one mixed list in the order things were tagged, give the pivot table its own model:

use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphPivot;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class Taggable extends MorphPivot
{
    protected $table = 'taggables';

    public function taggable(): MorphTo
    {
        return $this->morphTo();
    }
}

// In the Tag model
public function taggables(): HasMany
{
    return $this->hasMany(Taggable::class);
}

// One query for the pivot rows, then one per content type
$allTagged = $tag->taggables()
    ->with('taggable')
    ->latest()
    ->get()
    ->pluck('taggable');

Has-many-through relationships

A has-many-through relationship reaches through an intermediate model in one query: “all posts by users in a specific country”, without loading the users first.

Countries, users and 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);
    }
}

Has-many-through with explicit keys

The keys below match Laravel’s defaults. Spell them out when your columns don’t follow the naming convention, and keep the order straight, because it’s easy to swap them.

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 models

Once a link carries data such as a role, who added it or when it expires, treat the pivot as part of your domain model. That makes authorisation and auditing simpler later.

A pivot with timestamps and extra 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';

    // The table has its own auto-incrementing id, which pivots don't expect by default
    public $incrementing = true;

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

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

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

Using the 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'
]);

Keeping relationships fast

Every relationship is a query, so these patterns get slow quickly when they’re used carelessly. Eager load what you render or compute on, and nothing more. Four habits do most of the work:

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. From Laravel 11 the limit applies per post;
// earlier versions apply it to the whole query, so you'd get five comments in total.
$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();

All three in a content management system

Here are all three together, in a CMS that handles several 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: Process in batches with chunkById() or lazyById()

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

// Process in batches, keyed on id, so rows you update can't shift the pages
Content::with('contentable')->chunkById(100, function ($contents) {
    foreach ($contents as $content) {
        // Process content
    }
});

// Or iterate one model at a time, still fetched 100 rows per query
foreach (Content::with('contentable')->lazyById(100) as $content) {
    // Process content
}

Plain chunk() pages with an offset. If the loop changes the column you filter on, rows move between pages and some get skipped.

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();

4. Reports built from relationships

Problem: Totals and breakdowns computed by loading models and summing in PHP Solution: Use withCount() and withSum() for per-row figures, and the query builder for real reporting

Relationships are for working with records. Once you need grouped totals across thousands of rows, a DB::table() query with groupBy() does the work in the database.

Next steps

The next part, Laravel Mastery 02: Custom Artisan commands, covers command-line tools that automate database work, including relationship maintenance and data migrations.

Key takeaways

  • Polymorphic relationships let one model belong to several parent types, at the cost of a database foreign key
  • Has-many-through reaches across an intermediate model in a single query
  • Custom pivot models give the link its own casts and behaviour once it carries data
  • Eager loading is the habit that keeps all three fast: load what you render, and nothing more
  • Real applications combine these, so check the query log on any page that uses more than one

I design relationships like these into the Laravel applications I build, and untangle them in the ones I inherit.

Published , updated .

Part 1 of Laravel Mastery.

Related posts