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

The Tags facade

Everything goes through one facade, RoundlyConsulting\Tags\Facades\Tags (global alias Tags), sugar over the TagsManager singleton. Types are declared on it, type() reaches the catalog of one type, for() one model’s tags, and the flat verbs tag a model and return what really changed:

use RoundlyConsulting\Tags\Facades\Tags;

// Types — declared once, at boot
Tags::define('genre')->hierarchical()->single();
Tags::types();                                            // every type, resolved

// The catalog of one type, optionally within a scope
$genres = Tags::type('genre');
$jazz = $genres->make('Jazz')->under($music)->create();
$genres->tree();
Tags::type('label')->within($team)->suggest('liv');

// One model's tags — a chainable handle
Tags::for($concert)->sync($jazz, 'genre')->attach(['Live', 'Outdoor'], 'label');
Tags::for($concert)->of('label');
Tags::for($concert)->findMany($validatedIds, 'label');    // keys a form validated, in the model's scopes

// Flat verbs return what really changed
$changes = Tags::attach($concert, ['live'], 'label');    // TagChanges
Tags::detach($concert, ['outdoor'], 'label');
Tags::sync($concert, null, 'genre');                      // clears the category
Tags::clear($concert);                                    // every tag of every type

// Any tag by key, ancestors for a whole page, maintenance
Tags::find($key);
Tags::loadAncestors($concerts->map(fn (Concert $concert) => $concert->tagOf('genre')));
Tags::repair();

Manager methods

MethodReturnsDoes
define($type)TagTypeBuilderDeclare a type, from a service provider’s boot(). A string or a string-backed enum; a second definition throws.
extend($type, Closure $callback)voidAdjust a type from app code, whatever the provider order.
types()Collection<string, TagType>Every defined type, resolved, keyed by name.
type($type)TypeTagsThe catalog of one type — create, find, move, suggest… Unknown names throw.
find($key) / findOrFail($key)?Tag / TagA live tag of any type by primary key.
for($taggable)TaggableTagsOne model’s tags — a chainable handle.
attach($taggable, $tags, $type = null)TagChangesAdd tags; unknown free-form names are created in the model’s scope.
detach($taggable, $tags, $type = null)TagChangesRemove tags; names that name no tag are ignored.
sync($taggable, $tags, $type = null)TagChangesMake the model’s tags of one type (the default type when none is given) exactly these.
clear($taggable, $type = null)TagChangesRemove every tag of a type, or of every type.
loadAncestors(iterable $tags)voidLoad the ancestors of many tags in one query; nulls are skipped.
repair(?RepairOptions $options = null)RepairReportRepair paths, scope keys, orphan taggings and usage counts.
Tags::fake()TagsFakeFacade only — the recording, still-performing fake (see Testing).

perform() is public on the manager too, but internal: it is the single funnel every flat verb, handle, builder and model method runs its action through, and the hook the fake records with. Don’t call it from app code.

Tags::type($type) — the catalog

type() returns an immutable TypeTags handle for one type, optionally narrowed with within($scope). Reads see the scope’s tags plus the global ones; writes match the scope exactly — a scoped handle never writes a global tag, an unscoped one writes global tags only:

MethodReturnsDoes
within(?Model $scope, bool $includeGlobal = true)TypeTagsThe same catalog within a scope: reads see the scope’s tags plus the global ones (or only the scope’s), writes go to the scope.
definition()TagTypeThe resolved type.
make($name)TagBuilderStart a new tag; a string is the current locale, an array a locale map.
create(TagData $data)TagThe builder’s code path, from a DTO. A deleted tag holding the name comes back with these attributes.
findOrCreate(string|array|TagData $name)TagThe tag the name names (in the scope, else globally) unchanged, or a new one in the scope; a deleted tag holding the name comes back as it was. Allowed on curated types.
edit(Tag $tag)TagEditorChange a tag fluently, then save().
update(Tag $tag, UpdateTagData $data)TagRename, or change the description, manual slug, colour or icon.
rename(Tag $tag, string|array $name)TagA string renames the current locale; a map merges per locale.
move(Tag $tag, ?Tag $under = null, ?int $position = null)TagA new parent (null: a root), optionally a position among the new siblings.
reorder(?Tag $parent, array $order)Collection<int, Tag>Order the siblings under a parent (the roots when null): listed first, then the rest.
delete(Tag $tag, TagChildren $children = Refuse)TagSoft-delete and detach everywhere; children refuse, reparent or delete.
restore(Tag $tag)TagBring a deleted tag back, without its old taggings, where its parent is now; refused under a deleted parent or past the depth cap.
find($tag) / findOrFail($tag)?Tag / TagA live tag in the readable scopes: a model, a key (int), or a name or slug (string), the scope’s before a global one.
findTrashed($tag)?TagA deleted tag of exactly this handle’s scope — the scope a restore writes to.
findMany(array $keys)Collection<int, Tag>Live tags by key, in the order given; malformed, missing or foreign keys are skipped.
all() / roots()Collection<int, Tag>Every live tag, or the roots, by position.
tree(?Tag $root = null)list<TagNode>The tree, or one subtree, in one query.
suggest(string $term, ?int $limit = null)Collection<int, Tag>Autocomplete across locales.
popular(?int $limit = null, ?string $on = null, ?Closure $where = null)Collection<int, Tag>The most used tags — by usage count, or live on one class.
query()Builder<Tag>Live tags of the type in the readable scopes.

The TagBuilder

make($name) returns a TagBuilder. It validates eagerly where it can — a colour, an icon, a parent of another type or scope throws at the call — and both terminals build a TagData, so the builder and the DTO run the same code:

MethodReturnsDoes
description($description)TagBuilderPlain text; a string is the current locale, an array a locale map.
slug($slug)TagBuilderA manual slug, already in slug form — curated types only.
color(?string $color) / icon(?string $icon)TagBuilderA #rrggbb colour (stored lower-case); an identifier or an emoji.
under(?Tag $parent) / position(?int $position)TagBuilderA live parent of the same hierarchical type and scope — under(null) asks for a root; not called, a new tag is a root and a deleted tag the name brings back stays where it was. A position 0–2147483647 among its siblings.
create() / findOrCreate()TagCreate — a deleted tag holding the name comes back with these attributes — or find the named tag unchanged and create only when missing; a deleted tag findOrCreate() brings back keeps its own attributes.

The TagEditor

edit($tag) returns a TagEditor — only what you call changes:

MethodReturnsDoes
name($name)TagEditorA string renames the current locale; a map merges per locale.
description(?$description)TagEditorMerges per locale; a blank or null entry (['sk' => null]) clears that locale, null clears every locale.
slug($slug)TagEditorA manual slug — curated types only.
color(?string) / icon(?string)TagEditornull clears.
save()TagBuilds an UpdateTagData and calls update(); only what was called changes.

Tags::for($model) — one model’s tags

for() returns a TaggableTags handle. Writes go through the flat verbs — so the fake records them, and the model’s loaded tags relation is dropped so a chained read sees the change — and return the handle for chaining. findMany() turns keys a form validated into tags, in the model’s own scopes:

MethodReturnsDoes
attach($tags, $type = null)TaggableTagsThrough Tags::attach(), which drops the model’s loaded tags relation after the write.
detach($tags, $type = null)TaggableTagsThrough Tags::detach().
sync($tags, $type = null)TaggableTagsThrough Tags::sync().
clear($type = null)TaggableTagsThrough Tags::clear().
all()Collection<int, Tag>Every tag of the model, by position — the loaded relation when loaded.
of($type) / one($type)Collection / ?TagThe tags of one type, or its one tag (a category).
has($tag, $type = null)boolWhether the model carries a tag: a model, a key, a name or a slug along the locale chain.
hasAny($tags, $type = null) / hasAll(…)boolAny or every one of them.
findMany(array $keys, $type)Collection<int, Tag>Live tags of a type by key, in the scopes this model reads (its tagScope() and the global tags; global only for a global-only type), in the order given — the recipe for keys TagsRule::for($type)->byId()->taggable($model) validated.
model()Model&TaggableThe model behind the handle.

The HasTags methods (attachTags(), tagsOf(), …) are shorthands over this handle, so everything here — the fake included — applies to them too.

Data objects

Every DTO is final readonly:

DTOFieldsMeaning
TagDataname, description = [], slug = [], color = null, icon = null, parent = NotGiven, position = nullA new tag. Names, descriptions and manual slugs are locale maps; a string is the current locale. parent left out makes a new tag a root and leaves a deleted tag the name brings back where it was; null asks for a root.
UpdateTagDataname, description, slug, color, iconEvery field defaults to NotGiven and stays; null clears a description, colour or icon; a name or description map merges per locale, and a blank or null description entry clears that locale only.
TagChangestaggable, attached, detached, created, restoredWhat a tagging write really changed; isEmpty(), ofType($type).
TagSelectiontags, typeA tagging call’s input; TagSelection::of($tags, $type) for actions.
TagNodetag, childrenOne node of tree(), children in position order.
RepairOptionspaths = true, scopeKeys = true, counts = true, orphans = true, type = null, dryRun = falseWhat Tags::repair() fixes.
RepairReportpaths, cycles, scopeKeys, counts, orphans, unresolvableTypes, dryRun, trashedTaggingsWhat a repair found and fixed; isClean().

The PHP API trusts its caller

The facade enforces the domain rules — types, limits, curated lists, scopes, the tree — but never asks a Gate. The abilities in tags.authorization guard the JSON API only; code calling the facade decides who may call what.

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.