Managing tags
Tags::type($type) is the catalog of one type. Every write goes through it — the Tag model carries reads only — so slugs, paths, usage counts and events stay right under concurrent writers.
Creating and seeding
use RoundlyConsulting\Tags\DataTransferObjects\TagData;
$genres = Tags::type('genre');
// The builder…
$music = $genres->make(['en' => 'Music', 'sk' => 'Hudba'])->color('#f24e29')->create();
$jazz = $genres->make('Jazz')->under($music)->position(0)->create();
// …or the DTO — the same code path
$rock = $genres->create(new TagData(name: 'Rock', parent: $music, icon: 'guitar'));
// Idempotent seeding: an existing tag is returned unchanged (never moved, recoloured or renamed),
// a missing one is created with every attribute — so the seeder may run again
$bebop = $genres->make('Bebop')->under($jazz)->color('#a7c4d6')->findOrCreate();
$live = Tags::type('label')->findOrCreate('Live'); // a name, a locale map or a TagDatacreate() refuses a name a live tag of the type and scope holds in any locale (DuplicateTagException) and restores a deleted tag that holds it with the new attributes, where it was unless you give a parent (TagRestored instead of TagCreated). findOrCreate() looks the name up in the scope, else globally, and returns a found tag unchanged — a deleted one comes back as it was, moved only by a given parent (see Names, slugs and translations); a locale map is matched on the current locale’s entry (else its first), its other entries only fill a newly created tag. It is allowed on curated types — it is an explicit catalog call — and two racing calls end with one tag.
Finding
$genres = Tags::type('genre');
$genres->find('jazz'); // a live tag by slug or name (along the locale chain), a key or a model
$genres->find(12); // by primary key (int)
$genres->findOrFail('jazz'); // TagNotFoundException when missing
$genres->findMany($ids); // by keys, in the order given — malformed, foreign or missing keys skipped
$genres->findTrashed('jazz'); // a deleted tag of exactly this handle's scope, to restore it
$genres->all(); // every live tag of the type, by position
$genres->roots(); // the roots (every tag of a flat type)
$genres->query()->where('usage_count', '>', 10)->get(); // Builder<Tag> of the readable scopes
Tags::find($key); // a live tag of any type by primary key
Tags::findOrFail($key);Editing
use RoundlyConsulting\Tags\DataTransferObjects\UpdateTagData;
// Fluently — only what you call changes
Tags::type('genre')->edit($jazz)->name('Jazz music')->color(null)->save();
// A rename of the current locale, or of several locales at once
Tags::type('genre')->rename($jazz, 'Jazz music');
Tags::type('genre')->rename($jazz, ['en' => 'Jazz music', 'sk' => 'Jazzová hudba']);
// The DTO — fields left out stay; null clears a description, colour or icon
Tags::type('genre')->update($jazz, new UpdateTagData(description: null, icon: 'sax'));
// A null (or blank) description entry clears that locale only
Tags::type('genre')->edit($jazz)->description(['sk' => null])->save();A name or description map merges into the stored one per locale; a blank or null description entry clears that locale only. A rename onto a slug another tag holds is refused — and points to restore() when a deleted tag holds it. TagUpdated fires with the attributes that changed (name, description, slug, color, icon); a call that changes nothing fires nothing.
Deleting and restoring
use RoundlyConsulting\Tags\Enums\TagChildren;
$genres = Tags::type('genre');
$genres->delete($rock); // soft-deletes it and detaches it everywhere
$genres->delete($music); // TagHasChildrenException while it has live children
$genres->delete($music, TagChildren::Reparent); // children move up to its parent
$genres->delete($music, TagChildren::Delete); // the whole subtree goes
$genres->restore($rock); // back, with no old taggings
$genres->restore($genres->findTrashed('rock')); // find it by name firstdelete() soft-deletes the tag and detaches it from every model in the same transaction, so a re-created tag of the same name never relabels old records. No per-model Untagged fires — TagDeleted carries the number of taggings removed. Deleting an already deleted tag, or restoring a live one, returns it unchanged. restore() refuses a child whose parent is still deleted, recomputes its path from the parent’s current place — where it must fit the type’s depth cap, its live subtree included — and keeps its position under an unchanged parent; restoring a parent restores only that tag.
A plain $tag->delete()
// An admin panel calling Eloquent directly — safe, but it detaches nothing
$tag->delete();
// Reads ignore the leftover taggings at once; a restore through the package starts clean,
// and tags:doctor reports them as an error until you run:
// php artisan tags:repair --orphansThe Tag model
RoundlyConsulting\Tags\Models\Tag is swappable through tags.models.tag (a subclass). It uses sluggable’s HasSlug, translatable’s HasTranslations, SoftDeletes and HasFactory:
$jazz->type; // 'genre'
$jazz->name; // in the current locale
$jazz->slug; // in the current locale
$jazz->slugFor('sk'); // one locale's slug, or null
$jazz->description;
$jazz->color; // '#f24e29' or null
$jazz->icon;
$jazz->parent_id;
$jazz->depth; // 1 for a root
$jazz->position; // among its siblings
$jazz->usage_count; // taggings, kept in the same transaction as each write
$jazz->definition(); // its TagType
Tag::query()->ofType('genre')->within($team)->roots()->ordered()->get();| Attribute | Meaning |
|---|---|
type | The type name; never changes. |
scope_type, scope_id, scope() | The owner of a scoped tag (null for a global one); never changes. scope_type is hidden from serialisation. |
scope_key | Internal and hidden: '' for a global tag, the owner’s morph class and key otherwise, so the unique slug index binds global tags too. Derived on every save. |
parent_id, path, depth | The tree: path is the materialised chain of ancestor keys (/ for a root), depth 1 for a root. |
position | The order among siblings (0 first). |
name, description, slug | Translatable: read in the current locale; getTranslations('name') gives the locale map, slugFor($locale) one locale’s slug. |
color, icon | #rrggbb or null; an identifier or an emoji, or null. |
usage_count | Taggings of the tag, adjusted in the same transaction as every write — every taggable class, soft-deleted taggables included. |
wasRecentlyRestored | A plain property, the counterpart of wasRecentlyCreated: the instance was just brought back from deleted. |
| Method | Does |
|---|---|
definition() | The tag’s resolved TagType. |
parent() / children() | BelongsTo and HasMany; children by position. |
ancestors() | Root first, in one query — or none, when loadAncestors() loaded them. A new collection either way. |
descendants() / siblings() | Builders of the live tags below it, and of the live tags with the same parent, type and scope. |
isRoot() | No parent. |
taggings() | HasMany of the pivot rows (Models\Tagging). |
taggedModels($class) | The models of one class carrying the tag — read-only, like tags(). |
ofType(), within($scope, $includeGlobal = true), global(), roots(), descendantsOf($tag, self: false), ordered() | Query scopes on Tag::query(). |
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.