Building a Polymorphic Tagging System with Eloquent
Most content-heavy apps end up needing tags: blog posts, support tickets, products, whatever PhpArchitect happens to be shipping this quarter. Instead of bolting a tags string column onto every table, you can build one reusable tagging system with a polymorphic many-to-many relationship. Here's how to build it from scratch.
Step 1: The migrations
Schema::create('tags', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->timestamps();
});
Schema::create('taggables', function (Blueprint $table) {
$table->foreignId('tag_id')->constrained()->cascadeOnDelete();
$table->morphs('taggable'); // taggable_id, taggable_type
$table->primary(['tag_id', 'taggable_id', 'taggable_type']);
});
morphs('taggable') creates the taggable_id and taggable_type columns and indexes them, which is exactly what a pivot table for a polymorphic relationship needs.
Step 2: The Tag model
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 static function findOrCreateByName(string $name): self
{
return static::firstOrCreate(
['slug' => Str::slug($name)],
['name' => $name]
);
}
}
Step 3: A reusable Taggable trait
Rather than wiring up the relationship on every model that needs tags, extract it into a trait:
namespace App\Concerns;
use App\Models\Tag;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
trait Taggable
{
public function tags(): MorphToMany
{
return $this->morphToMany(Tag::class, 'taggable');
}
public function attachTags(array $names): void
{
$tags = collect($names)->map(
fn (string $name) => Tag::findOrCreateByName($name)->id
);
$this->tags()->syncWithoutDetaching($tags);
}
public function scopeWithAnyTag(\Illuminate\Database\Eloquent\Builder $query, array $names): void
{
$query->whereHas('tags', function ($q) use ($names) {
$q->whereIn('slug', array_map(fn ($n) => \Illuminate\Support\Str::slug($n), $names));
});
}
}
Step 4: Apply it to any model
namespace App\Models;
use App\Concerns\Taggable;
use Illuminate\Database\Eloquent\Model;
class Article extends Model
{
use Taggable;
}
class SupportTicket extends Model
{
use Taggable;
}
Both models now get tags(), attachTags(), and a withAnyTag() scope for free, with zero duplicate pivot logic.
Step 5: Using it
$article = Article::find(1);
$article->attachTags(['laravel', 'eloquent', 'php']);
$article->tags; // Collection of Tag models
$taggedArticles = Article::withAnyTag(['laravel'])->get();
Step 6: Keeping tag counts fresh (optional)
If you show a tag cloud, you probably want counts without running withCount on every request. Add a taggables_count style counter, or lean on withCount('taggables') on the Tag model since it also has the inverse relation:
class Tag extends Model
{
public function taggables(): \Illuminate\Database\Eloquent\Relations\MorphToMany
{
return $this->morphedByMany(Article::class, 'taggable');
}
}
Tag::withCount('taggables')->orderByDesc('taggables_count')->get();
Wrapping up
The polymorphic pivot pattern is one of Eloquent's most underused tools outside of tagging, comments, and likes. Once you have taggables in place, adding tags to a new model is a one-line use Taggable away, no new migration, no new pivot table, and no duplicated relationship code scattered across your models.