Taggable models
Any Eloquent model becomes taggable with the Taggable contract and the HasTags trait. Two methods are yours to override — the owner of the model’s free-form tags, and the types it may carry:
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use RoundlyConsulting\Tags\Concerns\HasTags;
use RoundlyConsulting\Tags\Contracts\Taggable;
final class Task extends Model implements Taggable
{
use HasTags;
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
// The owner of this task's free-form tags: it sees the team's tags plus the global ones,
// and a new label typed on a task belongs to its team. null = global tags only.
public function tagScope(): ?Model
{
return $this->team;
}
// The types a task may carry; null (the default) = any defined type.
public function tagTypes(): ?array
{
return ['label'];
}
}- tagScope() — null (the default) means global tags only. A model with a scope sees its scope’s tags plus the global ones, and an unknown free-form name typed on it is created in that scope (see Tenant-scoped tags).
- tagTypes() — null (the default) means any defined type. The type’s own on() list is checked too: a type limited to Concert never lands on a Task, whatever tagTypes() says.
- A model must be saved before it is tagged (UnsavedTaggableException) and keyed by tags.key_type.
What the trait adds
| Method | Returns | Does |
|---|---|---|
attachTags($tags, $type = null) | static | Tags::for($this)->attach() — chainable. |
detachTags($tags, $type = null) | static | Tags::for($this)->detach(). |
syncTags($tags, $type = null) | static | Tags::for($this)->sync(); syncTags(null, $type) clears the type. |
clearTags($type = null) | static | Tags::for($this)->clear(). |
tagsOf($type) / tagOf($type) | Collection / ?Tag | The tags of one type, or its one tag — the loaded relation filtered in memory, else one query. |
hasTag() / hasAnyTags() / hasAllTags() | bool | The handle’s has(), hasAny() and hasAll(). |
tags() | TagsRelation | The read-only relation, ordered by position. |
tagScope() / tagTypes() | ?Model / ?array | Override points: the owner of free-form tags, and the types allowed; both null by default. |
Reserved names — don’t define these on a taggable model: tags, tagsOf, tagOf, hasTag, hasAnyTags, hasAllTags, attachTags, detachTags, syncTags, clearTags, tagScope, tagTypes, bootHasTags, scopeWithAnyTags, scopeWithAllTags, scopeWithoutTags and scopeWithTagsOf.
The read-only tags() relation
tags() is a plain morph-to-many for every read — with('tags'), whereHas, withCount, wherePivot — ordered by position. Every write through it throws ReadOnlyTagsRelationException before touching a row and names the verb to use, because a raw pivot write would skip the type rules, scopes, usage counts, events and the fake:
$concert->tags; // every tag, by position — a plain morph-to-many read
Concert::with('tags')->get(); // eager load: two queries for the whole page
Concert::withCount('tags')->get();
Concert::whereHas('tags', fn ($tags) => $tags->where('color', '#f24e29'))->get();
$jazz->taggedModels(Concert::class)->get(); // the inverse — read-only too
$concert->tags()->attach($jazz); // ReadOnlyTagsRelationException: use attachTags()attach, detach, sync, syncWithoutDetaching, syncWithPivotValues, toggle, updateExistingPivot, save, saveMany, create, createMany, firstOrCreate, createOrFirst, updateOrCreate and incrementOrCreate (and their Quietly variants) all throw. The pivot carries the tag’s type and created_at.
In model factories
hasAttached() writes through the relation, so tag in afterCreating instead:
// database/factories/ConcertFactory.php — hasAttached() would hit the read-only relation
public function configure(): static
{
return $this->afterCreating(fn (Concert $concert) => $concert->attachTags(['Live'], 'label'));
}Deleting a taggable
$concert->forceDelete(); // detaches every tag: counts drop, Untagged fires
$concert->delete(); // a soft delete keeps the taggings, so a restore is lossless
Concert::query()->where('archived', true)->delete(); // a mass delete fires no model event…
// …so the taggings stay behind until: php artisan tags:repair --orphansA force delete — or a delete of a model without soft deletes — clears every tag through the manager: usage counts drop, Untagged fires and Tags::fake() records it as a clear. A soft delete keeps the taggings. A mass delete fires no model event, so its taggings stay until tags:repair --orphans, which also recounts the tags they pointed at; tags:doctor counts them.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.