Tenant-scoped tags
A tag is global, or owned by one scope model — a team, a tenant, a project. A taggable model names its scope through tagScope(); it then sees its scope’s tags plus the global ones, and a free-form name typed on it is created in its scope:
// Free-form tags typed on a task belong to its team (Task::tagScope() returns $this->team)
$task->attachTags('printer', 'label'); // the team's "printer", else the global one, else a new team tag
// Catalog handles: reads see the scope's tags plus the global ones…
$labels = Tags::type('label')->within($team);
$labels->all();
$labels->suggest('pri');
// …or only the scope's
Tags::type('label')->within($team, includeGlobal: false)->all();
// Writes match the handle's scope exactly
$labels->make('Urgent')->create(); // a team tag
Tags::type('label')->make('Urgent')->create(); // a global tag, for every team
$labels->delete($globalTag); // TagScopeMismatchException — never touches a global tag
// A tag of another scope is refused on a model, exactly like a missing one
$task->attachTags($tagOfAnotherTeam, 'label'); // TagScopeMismatchException (code tag_not_found)- A string resolves in the model’s scope first, then globally. The same name may exist as a team tag and as a global tag; the team’s wins for that team’s models.
- Uniqueness is per type and scope, so two teams may each own a printer label.
- Writes through a catalog handle match its scope exactly: a within($team) handle edits, moves and deletes the team’s own tags and never a global tag every tenant sees; an unscoped handle writes global tags only. A tag of another scope throws TagScopeMismatchException.
- A tag or key of another scope answers exactly like a missing one — in every verb, in TagsRule and in the API — so no response tells someone else’s tag from no tag.
- A scope model must be saved and keyed by tags.key_type. Its internal scope key is its morph class and key, at most 300 characters — register a short morph alias for a long class name.
Global-only types
Some kinds of tags should be one list for everyone — a country, a main category:
Tags::define('country')->globalOnly(); // one shared list for every tenant
$venue->attachTags('Austria', 'country'); // looked up and created globally, tagScope() ignored
Tags::type('country')->within($team)->make('Austria')->create(); // InvalidTagException: always globalScoped queries
// Strings resolve in every scope of the type, unless within: narrows them to a scope plus global
Task::withAnyTags(['printer'], 'label', within: $team)->get();
Tag::query()->ofType('label')->within($team)->get(); // the team's tags and the global ones
Tag::query()->ofType('label')->within($team, false)->get(); // the team's only
Tag::query()->ofType('label')->global()->get(); // global onlyIn the query scopes, strings match the named tag in every scope of the type — in each scope at its own first matching locale of the chain — unless within: narrows them, because your own query already narrows the models. Tag models and keys match exactly the tags they are.
When a model’s scope changes
Taggings keep the tags they point at. A model whose tagScope() changes keeps tags of its old scope until the next sync of that type drops them; tags:doctor counts such taggings as a warning.
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.