zing/laravel-eloquent-tags
Add tags to any Eloquent model in Laravel with a simple trait. Store, attach, detach, and query tags through clean relationships, making it easy to organize, filter, and search records by tag across your application.
composer require zingimmick/laravel-eloquent-tags
tags table and a polymorphic pivot table (taggables by default) carrying tag_id, taggable_type, and taggable_id:
php artisan vendor:publish --tag=eloquent-tags
php artisan migrate
HasTags trait (Zing\LaravelEloquentTags\Concerns\HasTags) to any model that should be taggable.Tag rows:
$post->attachTags(['laravel', 'php']); // creates tags as needed, attaches them
$post->tags; // Collection of Tag models
$post->detachTags(['php']);
Look first at README.md for the config keys, then at the published migration to confirm the table/column names actually used in your app — this is the single most common source of confusion.Attach from user input. Treat the trait methods as the write API and keep Tag::create() out of your controllers. attachTags() / syncTags() take either strings or Tag instances, and are idempotent-ish (existing tags are reused rather than duplicated) — ideal for a comma-separated tag input in a form:
$post->syncTags($request->input('tags', [])); // replaces the model's tag set
Prefer syncTags() over detach-all-then-attach inside requests; it avoids a window where the model has no tags.
Querying. Filter collections via the tags relation rather than joining the pivot by hand:
Post::whereHas('tags', fn ($q) => $q->whereIn('name', $names))->get();
Combine with with('tags') whenever you render tag lists, otherwise a listing page produces one query per row (N+1 on the polymorphic pivot is easy to miss because the relation looks cheap).
Reading back in APIs. Map to a flat array in the resource layer instead of exposing Tag models — you almost never want id/created_at of the tag in a public payload:
'tags' => $post->tags->pluck('name'),
Tag management / autocomplete. A Tag model is a normal Eloquent model, so a GET /tags?q= autocomplete endpoint is a simple Tag::where('name', 'like', "{$q}%")->limit(20)->get(). Cache that list if it's hit on every keystroke, and invalidate on attach/detach.
Custom Tag model. If you need extra columns (color, slug, usage count), extend the package's Tag model and point the config at yours — this is the supported extension point rather than editing vendor files.
Seeding and backfills. Chunk over models and use attachTags() in batches; do it inside a transaction per chunk so a failure doesn't leave half-tagged rows. Note that tags are not garbage collected — a tag with zero taggables stays in the tags table until you prune it yourself.
1.1.0 removes the taggable_morph_key option (PR #24). If you had customised the morph key column name in config, that setting is now ignored — the pivot must use the conventional taggable_id. Either rename your column or stay on 0.1.x until you migrate. This is the one change in this release with real upgrade cost.
"Fix table name" (PR #23) can shift which tables are resolved. If you published the migration/config under 0.1.x and customised table names, re-verify after upgrading — a corrected default can silently point the relation at a table that doesn't exist (or, worse, at a stale one). Run the test suite and a quick $model->tags()->toSql() check.
Everything else in 1.1.0 is CI/housekeeping — Dependabot bumps for actions/cache, codecov-action, setup-php, actions/checkout, a coding-standard range widening, and an auto-merge workflow. No runtime behaviour, so patch-level risk is low apart from the two items above.
Morph maps bite you here. If you call Relation::enforceMorphMap(), the values written to taggable_type change. Existing rows created before the morph map will no longer resolve, producing empty tags collections with no error. Decide on the morph map before you start tagging.
Normalise names before attaching. The package stores what you give it; "Laravel" and "laravel" are two different tags. Lowercase/trim in a form request or a small normaliseTags() helper, otherwise your autocomplete and tag clouds fragment quickly.
Add an index on the pivot. (taggable_type, taggable_id) and an index on tag_id are what keep whereHas('tags') and reverse lookups fast; the shipped migration may not cover your query patterns.
Debugging tip. When a whereHas('tags') returns nothing, dump $model->tags()->toSql() and $model->getMorphClass() — mismatched morph class or table name accounts for nearly every "tags silently not working" report.
Detaching is not deleting. detachTags() only removes pivot rows. If you want a real tag-delete flow, add your own Tag deletion plus a prune job for orphans, and be explicit about whether that's intended — deleting a shared tag affects every model using it.
How can I help you explore Laravel packages today?