Building a Tagging System with Polymorphic Relationships in Laravel
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.