Beyond Basic Joins: Flexible Tagging and Feeds with Laravel Polymorphism

Mastering Many-to-Many Polymorphic Relations in Laravel

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 ID 5 or Video ID 12).
  • taggable_type: The model class name or custom alias (e.g. App\Models\Post or post).

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. taggables for taggable).
  • Cascade Deletions: Because polymorphic tables use dynamic taggable_type strings, database foreign keys cannot automatically cascade-delete pivot rows when a parent Post or Video is 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 💬



Previous Post Next Post

Contact Form