Laravel Magazine
Building a Polymorphic Tagging System with Eloquent

Building a Polymorphic Tagging System with Eloquent

Eric Van Johnson ·

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.

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.