Laravel Magazine
Building a Tagging System with Polymorphic Relationships in Laravel

Building a Tagging System with Polymorphic Relationships in Laravel

Eric Van Johnson ·

Nearly every content-driven app eventually needs tags — articles get tags, products get tags, maybe even support tickets get tags. Instead of building a separate pivot table for each model, Laravel's polymorphic many-to-many relationships let you share one tags table across all of them.

Step 1: Migrations

php artisan make:model Tag -m
php artisan make:migration create_taggables_table
// database/migrations/xxxx_xx_xx_create_tags_table.php
public function up(): void
{
    Schema::create('tags', function (Blueprint $table) {
        $table->id();
        $table->string('name');
        $table->string('slug')->unique();
        $table->timestamps();
    });
}
// database/migrations/xxxx_xx_xx_create_taggables_table.php
public function up(): void
{
    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']);
    });
}

morphs('taggable') creates taggable_id and taggable_type columns plus an index — that's the pair Laravel uses to figure out which model a tag belongs to.

Step 2: The Tag model and a Taggable trait

// app/Models/Tag.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

class Tag extends Model
{
    protected $fillable = ['name', 'slug'];

    protected static function booted(): void
    {
        static::creating(function (Tag $tag) {
            $tag->slug ??= Str::slug($tag->name);
        });
    }

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

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

Rather than writing morphToMany on every taggable model by hand, wrap it in a trait so any model can opt in with one line:

// app/Models/Concerns/HasTags.php
namespace App\Models\Concerns;

use App\Models\Tag;

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

    public function syncTags(array $names): void
    {
        $ids = collect($names)
            ->map(fn (string $name) => Tag::firstOrCreate(
                ['slug' => Str::slug($name)],
                ['name' => $name],
            )->id)
            ->all();

        $this->tags()->sync($ids);
    }
}

Add use HasTags; to any model you want tagged:

class Article extends Model
{
    use HasTags;
}

class Product extends Model
{
    use HasTags;
}

Note the missing use Str; import above — add use Illuminate\Support\Str; to the trait file, since we reference Str::slug() inside syncTags().

Step 3: Using it

$article = Article::find(1);

$article->syncTags(['Laravel', 'Eloquent', 'Tutorials']);

$article->tags; // Collection of Tag models

$laravelTag = Tag::where('slug', 'laravel')->first();
$laravelTag->articles; // every Article tagged "laravel"
$laravelTag->products; // every Product tagged "laravel"

Step 4: Querying by tag

Use whereHas to filter any taggable model by tag slug:

$articles = Article::whereHas('tags', function ($query) {
    $query->where('slug', 'laravel');
})->latest()->paginate(15);

Filtering by multiple tags (must have all of them) means chaining whereHas once per tag:

$slugs = ['laravel', 'eloquent'];

$query = Article::query();

foreach ($slugs as $slug) {
    $query->whereHas('tags', fn ($q) => $q->where('slug', $slug));
}

$articles = $query->get();

Step 5: Cleaning up orphaned tags

Once a tag has no relationships left, you probably don't want it cluttering an admin dropdown forever. A small scheduled command handles it:

php artisan make:command PruneUnusedTags
namespace App\Console\Commands;

use App\Models\Tag;
use Illuminate\Console\Command;

class PruneUnusedTags extends Command
{
    protected $signature = 'tags:prune';

    protected $description = 'Delete tags with zero taggable relationships';

    public function handle(): void
    {
        $deleted = Tag::query()
            ->doesntHave('articles')
            ->doesntHave('products')
            ->delete();

        $this->info("Pruned {$deleted} unused tags.");
    }
}

Schedule it weekly in routes/console.php:

Schedule::command('tags:prune')->weekly();

That's a full tagging system — one migration pair, one trait, and any model in your app can be tagged, searched by tag, and cleaned up automatically when tags fall out of use.

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.