Tagging models
Four verbs write a model’s tags, on the trait, the for() handle and the facade alike. The type is always the last, optional argument:
$live = Tags::type('label')->make('Live')->create();
// A model, a primary key (int), a slug or a name — mixed freely, the type last
$concert->attachTags([$live, 42, 'outdoor', 'Late night'], 'label');
// Unknown names of a free-form type are created on the fly; a curated type never creates
$concert->attachTags('Polka', 'genre'); // TagNotFoundException
// A single type carries one tag: sync replaces it, attach refuses a second one
$concert->syncTags('jazz', 'genre'); // sets the category
$concert->attachTags('rock', 'genre'); // TagLimitExceededException — nothing written
$concert->syncTags(null, 'genre'); // clears it
// Detach is lenient: names that name no tag are ignored
$concert->detachTags(['outdoor', 'no-such-label'], 'label');
$concert->clearTags('label'); // every label
$concert->clearTags(); // every tag of every type- Inputs are a Tag model, a primary key (an int — a uuid or ulid key goes through findMany(), because a string always means a name), a slug or a name — one, or any iterable of them.
- attach adds. Unknown names of a free-form type are created in the model’s scope; a curated type never creates (TagNotFoundException). Going over a type’s max throws TagLimitExceededException and writes nothing — single types included.
- sync makes the model’s tags of one type exactly the given set: it detaches the rest and attaches what is missing; tags of other types are untouched. sync(null) or sync([]) clears the type. Without a type it syncs the default type.
- detach is lenient — names that name no tag, deleted tags and tags of another scope are skipped — so it is idempotent.
- clear removes every tag of one type, or of every type.
- Every tagging verb — on the facade, the handle or the trait — drops the model’s loaded tags relation afterwards, so tagsOf() and your resources see the write.
- The max is checked only for the types a call adds to, so a model already over a lowered max may still shrink.
Chaining
$concert
->syncTags($jazz, 'genre') // each write returns the model…
->attachTags(['Live', 'Outdoor'], 'label');
Tags::for($concert)
->detach('outdoor', 'label') // …or the handle, which drops the loaded
->attach('Rooftop', 'label') // tags relation after every write
->of('label'); // Live, RooftopWhat really changed
The flat verbs on the facade return a TagChanges — the same object every action returns:
$changes = Tags::sync($concert, ['live', 'Outdoor', 'Rooftop'], 'label');
$changes->attached; // list<Tag> — put on the model by this call
$changes->detached; // list<Tag> — taken off by this call
$changes->created; // the attached tags this call created for new free-form names
$changes->restored; // the attached tags this call brought back from deleted
$changes->isEmpty(); // true when nothing was attached or detached
$changes->ofType('label');Tagged and Untagged fire only when something changed, after the transaction commits; a TagCreated or TagRestored fires for every tag a free-form name created or brought back.
Strings and the default type
// No type: a string names a tag of tags.default_type ('tag' — flat, free-form, unlimited)
$article->attachTags(['laravel', 'php']);
$article->tagsOf('tag');
// Models and keys carry their own type, so a list may mix types when no type is given
$article->attachTags([$jazz, $live]);
// 'default_type' => null (or blank): every string must name its type
$article->attachTags('laravel'); // UnknownTagTypeException (type_required)The check order
Every attach and sync resolves its input in a fixed order, so the same call always fails the same way — and nothing is written until every check passed:
| # | Check | Exception |
|---|---|---|
| 1 | A key that names no tag, then input visibility: a tag model or key of a scope the model cannot see answers exactly like a missing one, before anything else can confirm that it exists. | TagNotFoundException / TagScopeMismatchException |
| 2 | A deleted tag. | TagNotFoundException |
| 3 | A tag of another type than the call names. | TagTypeMismatchException |
| 4 | The types the model may carry, from both sides: the type’s on() list and the model’s tagTypes(). | TagTypeNotAllowedException |
| 5 | Strings: a blank or too long name is invalid; an unknown name of a curated type is not found. | InvalidTagException / TagNotFoundException |
| 6 | The max of every type the call adds to — the call throws and writes nothing. | TagLimitExceededException |
Reading without N+1
foreach (Concert::with('tags')->get() as $concert) {
echo $concert->tagOf('genre')?->name; // Jazz — filtered in memory
echo $concert->tagsOf('label')->pluck('name')->join(', '); // Live, Outdoor
}
$concert->hasTag('live', 'label'); // a model, a key, a name or a slug
$concert->hasAnyTags(['live', 'free'], 'label');
$concert->hasAllTags(['live', 'outdoor'], 'label');
Tags::for($concert)->all(); // every tag, by position
Tags::for($concert)->one('genre'); // the same as tagOf()tagsOf() and tagOf() filter the loaded tags relation in memory when it is loaded, else run one query — so a page of 25 models with their tags is two queries, however many tags they carry. Reads ignore the taggings of a deleted tag.
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.