blamodex/laravel-tags
A lightweight Laravel package for adding tags to your Eloquent models. Attach, detach, and sync tags, query models by tag, and manage taggable relationships with a simple, expressive API that fits cleanly into any Laravel app.
composer require blamodex/laravel-tags:^1.1
php artisan vendor:publish --provider="Blamodex\Tags\TagsServiceProvider"
php artisan migrate
Then, in order:
First use case: a Post model with tags, an index page listing posts by tag slug, and a form field where editors type comma-separated tag names. Everything in the rest of this assessment is a refinement of that loop.
Method names below follow the package's documented trait API. Verify exact signatures against the README and
src/immediately after install — a tagging package's surface is small enough that it's worth ten minutes of reading before you build on it.
Trait on the model, nothing else. Add the trait to Post, Article, whatever needs tagging. Polymorphic tagging means one tag vocabulary can be shared across content types, which is usually what you want — resist the urge to stand up a separate Category model alongside it until you actually need hierarchy.
Prefer sync over attach in write paths. attach-style helpers append and will happily create duplicate pivot rows; sync-style helpers replace the full set. In a form handler or an update action, sync is idempotent and matches user intent ("these are the tags now"). Reach for attach only for genuine append semantics, like adding a tag from a "related content" suggestion.
Normalise before you persist. Build one small service or form object that takes raw input and returns clean names:
$names = collect(explode(',', $request->input('tags')))
->map(fn ($t) => Str::of($t)->trim()->lower()->squish()->toString())
->filter()
->unique()
->values();
Do the dedupe and casing here, once. If you let Post and Tag write their own normalisation you'll end up with Laravel, laravel, and laravel as three rows within a month.
Wrap create-and-tag in a transaction. Creating a post and resolving/creating its tags are two writes. DB::transaction() around the pair prevents half-tagged records when a unique constraint fires mid-flight.
Query through the relationship, not the tag table. Filtering posts by tag is a whereHas on the tags relation, and tag pages are a where('slug', ...) on the tag model plus eager-loaded posts:
$posts = Post::whereHas('tags', fn ($q) => $q->where('slug', $tag->slug))
->with('tags')
->paginate(20);
Always eager-load tags for listings. A tag cloud or a post index renders tags per row; without with('tags') you get an N+1 per page. This is the single most common performance regression with any tagging package.
Route model binding by slug. Tags are a natural slug resource. Bind on slug rather than id and your URLs stay stable and readable. Keep the slug column indexed and unique.
API resources: expose tags via whenLoaded('tags') so clients opt in with ?include=tags. Return [{id, name, slug}] rather than raw models — pivot columns like created_at leaking into payloads is noise.
Seed a starter vocabulary. A seeder that creates your canonical tags up front means editors pick from existing rows instead of minting near-duplicates, and it keeps your tag index page non-empty during development.
v1.1.0 is a compatibility-only release. The changelog is a single line — Laravel 13 support — with no features, deprecations, or breaking changes announced. Bump to ^1.1 when you move to Laravel 13, and confirm your other packages support 13 before you pin it; a tagging package is rarely the blocker, but its dependencies can be. If you're staying on 10/11/12, there is no reason to rush this update.
Morph maps will bite you retroactively. If you call Relation::enforceMorphMap() (and you should), taggable_type starts storing aliases like post instead of App\Models\Post. Existing rows keep the old FQCN and silently stop matching. Write a data migration that rewrites taggable_type in the pivot in the same deploy as the morph map change.
Concurrent writes can still duplicate. Sync-then-insert isn't atomic across requests. Put a unique index on the tag name/slug and on the pivot (taggable_type, taggable_id, tag_id), and be prepared to catch a unique violation and retry — two editors tagging the same post simultaneously is a real scenario, not a theoretical one.
Deletes leave orphans unless the schema says otherwise. Check whether the published migration uses cascadeOnDelete on the pivot. If it doesn't, deleting a Post leaves pivot rows behind and your tag counts drift. Either amend the migration before first migrate (cheapest) or add cleanup in a model observer.
Case and whitespace are the whole game. Enforce normalisation at the input boundary and make the tag table's uniqueness case-insensitive if your DB supports it (MySQL's default collation does; Postgres needs a functional index). Otherwise the same tag appears twice with different casing depending on which code path created it.
Config caching. After publishing, changes to the config file do nothing until php artisan config:cache is re-run (or cleared in local dev). If a custom tag model "isn't being used," this is almost always why.
Debugging the pivot: php artisan tinker with $post->tags()->get() and DB::table('<pivot>')->where('taggable_id', $post->id)->get() side by side will tell you instantly whether the issue is the relationship definition or the underlying rows. Guessing at relationship config without looking at the rows wastes a lot of time.
Extension points, in order of preference:
Tag model extending the package's. Use this for extra columns or accessors.usage_count) before you first migrate.Forking or vendoring the package is unnecessary for this class of library. If you find yourself wanting to, the requirement is probably a different feature rather than a patch.
Watch for: the tag-cloud/count query. withCount('posts') on every tag is fine at a few hundred tags and painful at tens of thousands. Cache the cloud and invalidate on tag write, or maintain a denormalised usage_count. You won't need it on day one, but know where the cliff is.
How can I help you explore Laravel packages today?