NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Tags for Laravel

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, Rooftop

What 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:

#CheckException
1A 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
2A deleted tag.TagNotFoundException
3A tag of another type than the call names.TagTypeMismatchException
4The types the model may carry, from both sides: the type’s on() list and the model’s tagTypes().TagTypeNotAllowedException
5Strings: a blank or too long name is invalid; an unknown name of a curated type is not found.InvalidTagException / TagNotFoundException
6The 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 crypto

By 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.