Laravel Eloquent: Mastering Many-to-Many and Polymorphic Relationships

Published on November 15, 2025
Laravel Eloquent Relationships ManyToMany Polymorphic Database

Understanding Advanced Relationships

While One-to-Many relationships handle simple parent-child scenarios, real-world applications often require more complex relationships. Let's dive into two powerful Eloquent relationship types: Many-to-Many and Polymorphic Relationships.

Many-to-Many Relationships

What is a Many-to-Many Relationship?

A Many-to-Many relationship exists when multiple models can belong to multiple other models. This requires an intermediate "pivot" table to connect the two models.

Real-world Examples:

  • Users and Roles (A user can have multiple roles, a role can belong to multiple users)
  • Posts and Tags (A post can have multiple tags, a tag can be used by multiple posts)
  • Students and Courses (A student can take multiple courses, a course can have multiple students)

Database Structure for Many-to-Many

-- Users table
id | name      | email
1  | John Doe  | john@example.com
2  | Jane Smith| jane@example.com

-- Roles table
id | name       | description
1  | admin      | Administrator role
2  | editor     | Content editor
3  | subscriber | Basic subscriber

-- role_user pivot table
user_id | role_id | assigned_at
1       | 1       | 2024-01-15
1       | 2       | 2024-01-15
2       | 3       | 2024-01-15

Creating the Migration for Pivot Table

php artisan make:migration create_role_user_table
<?php
// database/migrations/2024_01_15_000003_create_role_user_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('role_user', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->foreignId('role_id')->constrained()->onDelete('cascade');
            $table->timestamp('assigned_at')->useCurrent();
            $table->timestamps();
            
            // Prevent duplicate relationships
            $table->unique(['user_id', 'role_id']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('role_user');
    }
};

Defining Many-to-Many Relationships

User Model:

<?php
// app/Models/User.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class User extends Model
{
    /**
     * The roles that belong to the user.
     */
    public function roles(): BelongsToMany
    {
        return $this->belongsToMany(Role::class)
                    ->withPivot('assigned_at')
                    ->withTimestamps();
    }
    
    /**
     * Get only admin roles.
     */
    public function adminRoles(): BelongsToMany
    {
        return $this->roles()->where('name', 'admin');
    }
    
    /**
     * Check if user has a specific role.
     */
    public function hasRole($roleName): bool
    {
        return $this->roles()->where('name', $roleName)->exists();
    }
    
    /**
     * Check if user has any of the given roles.
     */
    public function hasAnyRole(array $roles): bool
    {
        return $this->roles()->whereIn('name', $roles)->exists();
    }
    
    /**
     * Get users with specific roles.
     */
    public function scopeWithRole($query, $roleName)
    {
        return $query->whereHas('roles', function ($q) use ($roleName) {
            $q->where('name', $roleName);
        });
    }
}

Role Model:

<?php
// app/Models/Role.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class Role extends Model
{
    protected $fillable = ['name', 'description'];
    
    /**
     * The users that belong to the role.
     */
    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class)
                    ->withPivot('assigned_at')
                    ->withTimestamps();
    }
    
    /**
     * Get users count for this role.
     */
    public function getUsersCountAttribute(): int
    {
        return $this->users()->count();
    }
}

Working with Many-to-Many Relationships

Basic Operations:

// Get user with roles
$user = User::with('roles')->find(1);

// Get role with users
$role = Role::with('users')->find(1);

// Access roles for a user
foreach ($user->roles as $role) {
    echo $role->name;
    echo $role->pivot->assigned_at; // Pivot data
}

// Access users for a role
foreach ($role->users as $user) {
    echo $user->name;
}

Attaching and Detaching:

$user = User::find(1);
$adminRole = Role::where('name', 'admin')->first();

// Attach a role
$user->roles()->attach($adminRole->id);
$user->roles()->attach([1, 2, 3]); // Multiple roles

// Attach with pivot data
$user->roles()->attach($adminRole->id, [
    'assigned_at' => now(),
    'assigned_by' => auth()->id()
]);

// Detach a role
$user->roles()->detach($adminRole->id);
$user->roles()->detach([1, 2, 3]); // Multiple roles
$user->roles()->detach(); // All roles

// Sync roles (only these roles will remain)
$user->roles()->sync([1, 2, 3]);

// Sync with pivot data
$user->roles()->sync([
    1 => ['assigned_at' => now()],
    2 => ['assigned_at' => now()],
]);

// Sync without detaching
$user->roles()->syncWithoutDetaching([1, 2, 3]);

// Toggle roles (add if missing, remove if present)
$user->roles()->toggle([1, 2, 3]);

Advanced Many-to-Many Queries

// Get users with specific roles
$admins = User::whereHas('roles', function ($query) {
    $query->where('name', 'admin');
})->get();

// Get users with multiple roles
$powerUsers = User::whereHas('roles', function ($query) {
    $query->whereIn('name', ['admin', 'editor']);
})->get();

// Get roles with user count
$roles = Role::withCount('users')->get();
foreach ($roles as $role) {
    echo "{$role->name}: {$role->users_count} users";
}

// Eager loading with pivot data
$users = User::with(['roles' => function ($query) {
    $query->select('name')->withPivot('assigned_at');
}])->get();

Polymorphic Relationships

What are Polymorphic Relationships?

Polymorphic relationships allow a model to belong to multiple other models on a single association. This is useful when you have multiple models that can share a relationship type.

Real-world Examples:

  • Comments that can belong to Posts, Videos, or Products
  • Images that can belong to Users, Posts, or Categories
  • Tags that can be applied to Posts, Videos, or Products

Database Structure for Polymorphic Relationships

-- posts table
id | title          | content
1  | First Post     | Post content...
2  | Second Post    | More content...

-- videos table  
id | title          | url
1  | Introduction   | https://example.com/video1
2  | Tutorial       | https://example.com/video2

-- comments table
id | body               | commentable_type | commentable_id
1  | Great post!        | App\Models\Post  | 1
2  | Nice video         | App\Models\Video | 1
3  | Thanks for sharing | App\Models\Post  | 2

One-to-Many Polymorphic Relationship

Creating the Comments Migration:

php artisan make:migration create_comments_table
<?php
// database/migrations/2024_01_15_000004_create_comments_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('comments', function (Blueprint $table) {
            $table->id();
            $table->text('body');
            $table->morphs('commentable'); // Creates commentable_type and commentable_id
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->boolean('is_approved')->default(false);
            $table->timestamps();
            
            // Indexes for performance
            $table->index(['commentable_type', 'commentable_id']);
            $table->index('is_approved');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('comments');
    }
};

Defining Polymorphic Relationships:

Comment Model:

<?php
// app/Models/Comment.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;

class Comment extends Model
{
    protected $fillable = ['body', 'user_id', 'is_approved'];
    
    protected $casts = [
        'is_approved' => 'boolean',
    ];
    
    /**
     * Get the parent commentable model (Post or Video).
     */
    public function commentable(): MorphTo
    {
        return $this->morphTo();
    }
    
    /**
     * Get the user who made the comment.
     */
    public function user()
    {
        return $this->belongsTo(User::class);
    }
    
    /**
     * Scope approved comments.
     */
    public function scopeApproved($query)
    {
        return $query->where('is_approved', true);
    }
    
    /**
     * Scope pending comments.
     */
    public function scopePending($query)
    {
        return $query->where('is_approved', false);
    }
}

Post Model:

<?php
// app/Models/Post.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;

class Post extends Model
{
    /**
     * Get all of the post's comments.
     */
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
    
    /**
     * Get only approved comments.
     */
    public function approvedComments(): MorphMany
    {
        return $this->comments()->approved();
    }
    
    /**
     * Get comments count.
     */
    public function getCommentsCountAttribute(): int
    {
        return $this->comments()->count();
    }
    
    /**
     * Get approved comments count.
     */
    public function getApprovedCommentsCountAttribute(): int
    {
        return $this->approvedComments()->count();
    }
}

Video Model:

<?php
// app/Models/Video.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;

class Video extends Model
{
    protected $fillable = ['title', 'url', 'description'];
    
    /**
     * Get all of the video's comments.
     */
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
    
    /**
     * Get only approved comments.
     */
    public function approvedComments(): MorphMany
    {
        return $this->comments()->approved();
    }
}

Working with Polymorphic Relationships

Basic Operations:

// Get a post with comments
$post = Post::with('comments.user')->find(1);

// Get a video with comments  
$video = Video::with('comments.user')->find(1);

// Access comments
foreach ($post->comments as $comment) {
    echo $comment->body;
    echo $comment->user->name;
}

// Get the parent model from a comment
$comment = Comment::find(1);
$commentable = $comment->commentable; // Could be Post or Video

// Check the type
if ($comment->commentable_type === Post::class) {
    echo "This comment is on a post";
} elseif ($comment->commentable_type === Video::class) {
    echo "This comment is on a video";
}

Creating Polymorphic Relationships:

// Create comment for a post
$post = Post::find(1);
$comment = $post->comments()->create([
    'body' => 'Great post!',
    'user_id' => auth()->id(),
    'is_approved' => true
]);

// Create comment for a video
$video = Video::find(1);
$comment = $video->comments()->create([
    'body' => 'Nice video!',
    'user_id' => auth()->id()
]);

// Create comment using the commentable relationship
$comment = new Comment([
    'body' => 'Awesome content!',
    'user_id' => auth()->id()
]);
$post->comments()->save($comment);

Many-to-Many Polymorphic Relationships (Taggable)

What are Many-to-Many Polymorphic Relationships?

This allows a model to belong to multiple other models using a single relationship, with a pivot table.

Real-world Examples:

  • Tags that can be applied to Posts, Videos, and Products
  • Categories that can organize Posts, Videos, and Pages

Database Structure:

-- tags table
id | name       | slug
1  | PHP        | php
2  | Laravel    | laravel
3  | JavaScript | javascript

-- taggables table
tag_id | taggable_type   | taggable_id
1      | App\Models\Post | 1
2      | App\Models\Post | 1
3      | App\Models\Video| 1
1      | App\Models\Video| 2

Creating the Taggable Migration:

php artisan make:migration create_taggables_table
<?php
// database/migrations/2024_01_15_000005_create_taggables_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('taggables', function (Blueprint $table) {
            $table->id();
            $table->foreignId('tag_id')->constrained()->onDelete('cascade');
            $table->morphs('taggable'); // Creates taggable_type and taggable_id
            $table->timestamps();
            
            // Prevent duplicate tagging
            $table->unique(['tag_id', 'taggable_type', 'taggable_id']);
            
            // Indexes for performance
            $table->index(['taggable_type', 'taggable_id']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('taggables');
    }
};

Defining Many-to-Many Polymorphic Relationships:

Tag Model:

<?php
// app/Models/Tag.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphToMany;

class Tag extends Model
{
    protected $fillable = ['name', 'slug'];
    
    /**
     * Get all of the posts that are assigned this tag.
     */
    public function posts(): MorphToMany
    {
        return $this->morphedByMany(Post::class, 'taggable');
    }
    
    /**
     * Get all of the videos that are assigned this tag.
     */
    public function videos(): MorphToMany
    {
        return $this->morphedByMany(Video::class, 'taggable');
    }
    
    /**
     * Get all models that have this tag.
     */
    public function taggables()
    {
        return $this->morphToMany(
            config('taggable.models'), 
            'taggable'
        );
    }
    
    /**
     * Get usage count across all models.
     */
    public function getUsageCountAttribute(): int
    {
        return $this->posts()->count() + $this->videos()->count();
    }
}

Post Model (adding tag relationship):

<?php
// app/Models/Post.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphToMany;

class Post extends Model
{
    // ... existing code ...
    
    /**
     * Get all of the tags for the post.
     */
    public function tags(): MorphToMany
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
    
    /**
     * Get popular tags for posts.
     */
    public function scopeWithPopularTags($query, $limit = 5)
    {
        return $query->with(['tags' => function ($query) use ($limit) {
            $query->limit($limit);
        }]);
    }
}

Video Model (adding tag relationship):

<?php
// app/Models/Video.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphToMany;

class Video extends Model
{
    // ... existing code ...
    
    /**
     * Get all of the tags for the video.
     */
    public function tags(): MorphToMany
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
}

Working with Many-to-Many Polymorphic Relationships

Basic Operations:

// Get post with tags
$post = Post::with('tags')->find(1);

// Get video with tags
$video = Video::with('tags')->find(1);

// Get tag with all related models
$tag = Tag::with('posts', 'videos')->find(1);

// Access tags
foreach ($post->tags as $tag) {
    echo $tag->name;
}

// See what models use a tag
foreach ($tag->posts as $post) {
    echo "Post: {$post->title}";
}

foreach ($tag->videos as $video) {
    echo "Video: {$video->title}";
}

Attaching and Detaching Tags:

$post = Post::find(1);
$phpTag = Tag::where('name', 'PHP')->first();
$laravelTag = Tag::where('name', 'Laravel')->first();

// Attach tags
$post->tags()->attach($phpTag->id);
$post->tags()->attach([1, 2, 3]);

// Sync tags
$post->tags()->sync([1, 2, 3]);

// Detach tags
$post->tags()->detach($phpTag->id);
$post->tags()->detach([1, 2, 3]);

// Toggle tags
$post->tags()->toggle([1, 2, 3]);

Real-World Complete Examples

Example 1: Complete Blog System with Tags and Comments

Complete Post Model:

<?php
// app/Models/Post.php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;

class Post extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = [
        'title', 'slug', 'content', 'excerpt', 'featured_image',
        'is_published', 'published_at', 'user_id', 'meta_title', 'meta_description'
    ];

    protected $casts = [
        'is_published' => 'boolean',
        'published_at' => 'datetime',
    ];

    protected $appends = ['reading_time', 'published_ago'];

    // Relationships
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

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

    public function approvedComments(): MorphMany
    {
        return $this->comments()->approved();
    }

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

    public function categories(): MorphToMany
    {
        return $this->morphToMany(Category::class, 'categorizable');
    }

    // Scopes
    public function scopePublished($query)
    {
        return $query->where('is_published', true)
                    ->where('published_at', '<=', now());
    }

    public function scopeWithTag($query, $tagName)
    {
        return $query->whereHas('tags', function ($q) use ($tagName) {
            $q->where('name', $tagName);
        });
    }

    public function scopeWithComments($query)
    {
        return $query->whereHas('comments');
    }

    // Accessors
    public function getReadingTimeAttribute(): int
    {
        $wordCount = str_word_count(strip_tags($this->content));
        return max(1, ceil($wordCount / 200));
    }

    public function getPublishedAgoAttribute(): string
    {
        return $this->published_at ? $this->published_at->diffForHumans() : 'Not published';
    }

    public function getTagsListAttribute(): string
    {
        return $this->tags->pluck('name')->implode(', ');
    }

    // Business Logic
    public function addTags(array $tagNames): void
    {
        $tagIds = [];
        foreach ($tagNames as $tagName) {
            $tag = Tag::firstOrCreate([
                'name' => $tagName,
                'slug' => Str::slug($tagName)
            ]);
            $tagIds[] = $tag->id;
        }
        
        $this->tags()->syncWithoutDetaching($tagIds);
    }

    public function approveAllComments(): void
    {
        $this->comments()->update(['is_approved' => true]);
    }
}

Example 2: E-commerce Product Tagging System

Product Model with Polymorphic Tags:

<?php
// app/Models/Product.php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphToMany;

class Product extends Model
{
    use HasFactory;

    protected $fillable = [
        'name', 'slug', 'description', 'price', 'sku',
        'stock_quantity', 'is_active', 'category_id'
    ];

    protected $casts = [
        'price' => 'decimal:2',
        'is_active' => 'boolean',
    ];

    // Relationships
    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }

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

    public function reviews(): MorphMany
    {
        return $this->morphMany(Review::class, 'reviewable');
    }

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

    public function scopeWithTags($query, array $tagNames)
    {
        return $query->whereHas('tags', function ($q) use ($tagNames) {
            $q->whereIn('name', $tagNames);
        });
    }

    public function scopeSearchByTags($query, $searchTerm)
    {
        return $query->whereHas('tags', function ($q) use ($searchTerm) {
            $q->where('name', 'like', "%{$searchTerm}%");
        });
    }

    // Business Logic
    public function syncTags(array $tagNames): void
    {
        $tagIds = [];
        foreach ($tagNames as $tagName) {
            $tag = Tag::firstOrCreate([
                'name' => trim($tagName),
                'slug' => Str::slug(trim($tagName))
            ]);
            $tagIds[] = $tag->id;
        }
        
        $this->tags()->sync($tagIds);
    }

    public function getRelatedProducts($limit = 5)
    {
        $tagIds = $this->tags->pluck('id');
        
        return self::active()
                  ->where('id', '!=', $this->id)
                  ->whereHas('tags', function ($query) use ($tagIds) {
                      $query->whereIn('id', $tagIds);
                  })
                  ->limit($limit)
                  ->get();
    }
}

Advanced Query Techniques

Querying Polymorphic Relationships

// Get all comments with their commentable models
$comments = Comment::with('commentable')->get();

// Get comments for specific model types
$postComments = Comment::where('commentable_type', Post::class)->get();
$videoComments = Comment::where('commentable_type', Video::class)->get();

// Get models that have comments
$postsWithComments = Post::has('comments')->get();
$videosWithComments = Video::has('comments')->get();

// Get comments with specific conditions on parent models
$comments = Comment::whereHasMorph(
    'commentable', 
    [Post::class, Video::class],
    function ($query, $type) {
        $query->when($type === Post::class, function ($query) {
            $query->where('is_published', true);
        })->when($type === Video::class, function ($query) {
            $query->where('is_public', true);
        });
    }
)->get();

Advanced Many-to-Many Polymorphic Queries

// Get tags used by both posts and videos
$commonTags = Tag::whereHas('posts')
                ->whereHas('videos')
                ->get();

// Get most popular tags across all models
$popularTags = Tag::withCount(['posts', 'videos'])
                 ->get()
                 ->sortByDesc(function ($tag) {
                     return $tag->posts_count + $tag->videos_count;
                 })
                 ->take(10);

// Get posts with specific tags
$phpPosts = Post::whereHas('tags', function ($query) {
    $query->whereIn('name', ['PHP', 'Laravel']);
})->get();

// Get unused tags
$unusedTags = Tag::doesntHave('posts')
                ->doesntHave('videos')
                ->get();

Performance Optimization

Eager Loading for Complex Relationships

// Eager load multiple polymorphic relationships
$posts = Post::with([
    'user',
    'tags',
    'comments' => function ($query) {
        $query->approved()->with('user');
    },
    'categories'
])->published()->get();

// Lazy eager loading
$posts = Post::published()->get();
$posts->load([
    'tags',
    'approvedComments.user',
    'categories'
]);

Database Indexing for Performance

// Always index polymorphic columns
Schema::create('comments', function (Blueprint $table) {
    // ...
    $table->index(['commentable_type', 'commentable_id']);
});

Schema::create('taggables', function (Blueprint $table) {
    // ...
    $table->index(['taggable_type', 'taggable_id']);
    $table->index('tag_id');
});

Common Interview Questions & Answers

1. What's the difference between Many-to-Many and Polymorphic relationships?

Many-to-Many connects two specific models through a pivot table, while Polymorphic relationships allow one model to belong to multiple other models using a single relationship.

2. When would you use a polymorphic relationship?

Use polymorphic relationships when you have multiple models that need the same type of relationship, like comments that can belong to posts, videos, or products, or tags that can be applied to different content types.

3. How do pivot tables work in Many-to-Many relationships?

Pivot tables store the foreign keys of both related models and can include additional metadata about the relationship. Laravel automatically handles accessing this pivot data.

4. What are the database columns needed for polymorphic relationships?

You need {relationship}_type (VARCHAR storing the model class) and {relationship}_id (INT storing the model ID), typically created using the morphs() method in migrations.

5. How do you query polymorphic relationships efficiently?

Use whereHasMorph() for conditional queries, ensure proper indexing on polymorphic columns, and use eager loading to avoid N+1 query problems.

6. Can you have pivot data in polymorphic Many-to-Many relationships?

Yes, you can use withPivot() method to include additional columns from the pivot table, just like regular Many-to-Many relationships.

Best Practices

  • Use consistent naming for polymorphic relationships (commentable, taggable, etc.)
  • Always add indexes on polymorphic columns for performance
  • Use eager loading to prevent N+1 queries with complex relationships
  • Validate relationship data before attaching/detaching
  • Use database transactions for complex relationship operations
  • Consider using model events to maintain relationship integrity
  • Implement soft deletes carefully with polymorphic relationships
  • Use custom pivot models for complex pivot logic

You've now mastered the most advanced Eloquent relationship types! In our next post, we'll explore Laravel Seeding and Factories: Populating Your Database with Fake Data for Testing to learn how to generate test data for your relationships.