Mastering Many-to-Many Polymorphic Relations in Laravel
When designing relational databases, standard Many-to-Many relationships are quite simple and straightforward: a users table connects to a roles table through a role_user pivot table. But what happens when you want to attach Tags to Blog Posts, Videos, Courses, and Products? Or what if you want to build a centralized Activity Feed / Likes system that connects to multiple different models across your application?
Creating separate pivot tables for every entity (such as post_tag, video_tag, course_tag) quickly creates schema clutter and duplicates your base code. Instead, Laravel Eloquent provides a better and simple solution: Polymorphic Many-to-Many Relationships (morphToMany and morphedByMany).
In this tutorial, we will learn how Polymorphic Many-to-Many relationships work, set up a real-world multi-model tagging system, explore morph maps for cleaner database records, and query cross-model relationships efficiently.
What is a Polymorphic Many-to-Many Relationship?
A Polymorphic Many-to-Many relationship allows a single model (like Tag) to belong to more than one other model type (such as Post, Video, or Course) on a many-to-many basis using a single, shared intermediate pivot table.
Instead of hardcoding a single foreign key like post_id, the shared pivot table stores two special columns:
taggable_id: The primary key ID of the parent model (e.g. Post ID5or Video ID12).
taggable_type: The model class name or custom alias (e.g.App\Models\Postorpost).
Database Schema and Migrations
Let's create the database structure for a shared tagging system supporting both Post and Video models.
1. Posts and Videos Tables
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('body');
$table->timestamps();
});
Schema::create('videos', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('video_url');
$table->timestamps();
});
2. Tags and Taggables Pivot Table
Schema::create('tags', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->timestamps();
});
// The shared polymorphic pivot table
Schema::create('taggables', function (Blueprint $table) {
$table->id();
$table->foreignId('tag_id')->constrained()->cascadeOnDelete();
// Creates both taggable_id (unsignedBigInteger) and taggable_type (string) with indexes
$table->morphs('taggable');
$table->unique(['tag_id', 'taggable_id', 'taggable_type']);
});
The $table->morphs('taggable') helper automatically creates taggable_id, taggable_type, and adds a composite index for fast lookups.
Setting Up the Eloquent Models
Now, let's connect our models using morphToMany() and morphedByMany().
1. Defining Relationships on the Parent Models (Post & Video)
Use the morphToMany() method on your parent models:
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
class Post extends Model
{
protected $fillable = ['title', 'body'];
public function tags(): MorphToMany
{
return $this->morphToMany(Tag::class, 'taggable');
}
}
Do the exact same for the Video model:
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
class Video extends Model
{
protected $fillable = ['title', 'video_url'];
public function tags(): MorphToMany
{
return $this->morphToMany(Tag::class, 'taggable');
}
}
2. Defining the Inverse Relationship on the Tag Model
To retrieve all posts or videos that belong to a specific tag, use the morphedByMany() method on the Tag model:
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
class Tag extends Model
{
protected $fillable = ['name', 'slug'];
/**
* Get all posts associated with this tag.
*/
public function posts(): MorphToMany
{
return $this->morphedByMany(Post::class, 'taggable');
}
/**
* Get all videos associated with this tag.
*/
public function videos(): MorphToMany
{
return $this->morphedByMany(Video::class, 'taggable');
}
}
Attaching, Detaching, and Syncing Tags
Because polymorphic many-to-many relationships are standard Eloquent relations under the hood, all conventional pivot methods—such as attach(), detach(), sync(), and toggle()—work out of the box.
use App\Models\Post;
use App\Models\Video;
use App\Models\Tag;
// Create tags
$laravelTag = Tag::create(['name' => 'Laravel', 'slug' => 'laravel']);
$phpTag = Tag::create(['name' => 'PHP', 'slug' => 'php']);
// Create a post and attach tags
$post = Post::create(['title' => 'Getting Started with Eloquent', 'body' => '...']);
$post->tags()->attach([$laravelTag->id, $phpTag->id]);
// Create a video and sync tags
$video = Video::create(['title' => 'Laravel Tutorial Video', 'video_url' => 'https://...']);
$video->tags()->sync([$laravelTag->id]);
// Detach a tag
$post->tags()->detach($phpTag->id);
Best Practice: Enforcing Custom Morph Maps
By default, Laravel stores the fully qualified class name in the taggable_type column (e.g. App\Models\Post).
Storing full PHP class namespaces directly inside your database is bad practice because if you ever rename, reorganize, or move your models to a new directory (e.g., App\Domain\Posts\Models\Post), existing database relationships will break.
To avoid this, define a Morph Map in your AppServiceProvider:
namespace App\Providers;
use App\Models\Post;
use App\Models\Video;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Relation::morphMap([
'post' => Post::class,
'video' => Video::class,
]);
// Optional: Enforces strict morph maps so unmapped models throw exceptions
Relation::enforceMorphMap([
'post' => Post::class,
'video' => Video::class,
]);
}
}
Now, the taggable_type column will store clean aliases like post or video instead of hardcoded class paths.
Advanced Querying Examples
1. Eager Loading to Prevent N+1 Issues
// Eager load tags with posts
$posts = Post::with('tags')->latest()->get();
foreach ($posts as $post) {
echo $post->title;
foreach ($post->tags as $tag) {
echo $tag->name;
}
}
2. Filtering Parent Models by Tag (whereHas)
You can easily query models that contain specific tags using whereHas():
// Get all posts tagged with "laravel"
$laravelPosts = Post::whereHas('tags', function ($query) {
$query->where('slug', 'laravel');
})->get();
3. Cross-Model Aggregation
To find all content associated with a given tag across both posts and videos:
$tag = Tag::with(['posts', 'videos'])->where('slug', 'laravel')->first();
$totalItems = $tag->posts->count() + $tag->videos->count();
Note:
- Pivot Table Naming: The convention for the intermediate table is the plural form of the morph name (e.g.
taggablesfortaggable).
- Cascade Deletions: Because polymorphic tables use dynamic
taggable_typestrings, database foreign keys cannot automatically cascade-delete pivot rows when a parentPostorVideois deleted. Clean up attached pivot relations using model events ($post->tags()->detach()) or model pruning.
- Indexing is Mandatory: Ensure
$table->morphs('taggable')is present in your migration so composite indexes are properly created on[taggable_type, taggable_id].
Conclusion
Polymorphic Many-to-Many relationships in Laravel provide an efficient, scalable way to connect shared features like tags, categories, bookmarks, and activity feeds across multiple disparate models. By combining morphToMany and morphedByMany with explicit Morph Maps, you keep your database schema lean, maintainable, and decoupled from your code structure.
Thank you for reading this article 😊
For any query, do not hesitate to comment 💬
