JSON API
Routes, form requests, controllers, resources, query-builder list filters and rate limits — off until you switch them on:
TAGS_ROUTES=on- Off unless switched on: a switch that is not set — missing from a published config whose routes array overrides only some keys, null or blank — keeps it off.
- Prefix tags.routes.prefix (api), route names tags.*, middleware tags.routes.middleware (['api', 'auth', 'throttle:tags-api']).
- {type} is a type name; {tag} is a key whenever it fits the key type, else a slug or name. suggest, popular and reorder register before {tag}, so a tag with one of those slugs is addressed by its key.
- To change a route, publish routes/tags.php (--tag=tags-routes); the published copy replaces the package’s file, still behind tags.routes.enabled.
- Every write goes through the manager, so Tags::fake() records API calls too.
Aliases, Sanctum and abilities
The API never takes a class name: scopes and taggables are named by aliases you allow-list. The package defines no gate, and an undefined ability denies — define the ones you use:
// config/tags.php
'scopes' => ['team' => App\Models\Team::class], // ?scope=team:12
'taggables' => ['concert' => App\Models\Concert::class], // popular?on=concert
'routes' => [
'middleware' => ['api', 'auth:sanctum', 'throttle:tags-api'],
// …
],
// AppServiceProvider::boot() — the package defines no gate; an undefined ability denies
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate;
Gate::define('tags.manage', fn (User $user, string $type, ?Model $scope = null) => $scope === null
? $user->is_admin // global tags: admins only
: $user->team_id === $scope->getKey() && $user->is_team_admin);
Gate::define('tags.scope', fn (User $user, Model $scope) => $user->team_id === $scope->getKey());- manage (tags.manage) is asked before every write with the type name and the target scope — null for a global write — so tenant admins can manage their own tags and never the global ones. It also decides who sees private types and — in each tag’s own scope, so a global tag asks for global management whatever scope the request reads — usage_count, translations and filter[trashed]; the check runs once per type and scope per request.
- scope (tags.scope) is asked with the scope model whenever a request passes ?scope=. A denied scope answers 422 unknown_alias, exactly like a scope that does not exist, so a reference reveals no tenant ids.
- view (null by default) is asked before reading a type and its tags, with the type name and the scope; without it, reads need only the route middleware.
- is_admin, is_team_admin and team_id above stand for your own columns or role checks; a permissions package that grants abilities through Gate::before works too.
Routes
| Method & path | Name | Ability | Notes |
|---|---|---|---|
GET tag-types | tags.types.index | — | The types the caller may see — private ones only for their managers. view is not asked for the list. |
GET tag-types/{type} | tags.types.show | view | One type. |
GET tags/{type} | tags.index | view | The list — filters, sorts, paging — or ?tree=1 for nested nodes. |
POST tags/{type} | tags.store | manage | Create in the request’s scope; 201. |
GET tags/{type}/suggest | tags.suggest | view | ?q= (1–64 characters) and limit; also throttle:tags-suggest. |
GET tags/{type}/popular | tags.popular | view | ?limit and on=alias from tags.taggables. |
POST tags/{type}/reorder | tags.reorder | manage | {parent, order} |
GET tags/{type}/{tag} | tags.show | view | One tag, with its ancestors. |
PATCH tags/{type}/{tag} | tags.update | manage | Only the fields sent change. |
DELETE tags/{type}/{tag} | tags.destroy | manage | ?children=refuse (default), reparent or delete; answers the deleted tag. |
POST tags/{type}/{tag}/move | tags.move | manage | {parent: key|null, position} |
POST tags/{type}/{tag}/restore | tags.restore | manage | By key, or by the slug or name of a deleted tag of exactly the request’s scope. |
GET /api/tag-types
GET /api/tags/genre?tree=1
GET /api/tags/label?scope=team:12&filter[search]=pri&sort=-usage_count&per_page=50
GET /api/tags/label/suggest?q=liv&limit=5
GET /api/tags/label/popular?on=concert&limit=10
GET /api/tags/genre/jazz
POST /api/tags/genre {"name": {"en": "Bebop"}, "parent": "2", "color": "#f24e29"}
PATCH /api/tags/genre/bebop {"name": {"en": "Bebop jazz"}, "description": {"sk": null}, "color": null}
POST /api/tags/genre/bebop/move {"parent": null, "position": 0}
POST /api/tags/genre/reorder {"parent": "1", "order": ["4", "2", "3"]}
DELETE /api/tags/genre/music?children=reparent
POST /api/tags/genre/rock/restoreRequest bodies
| Endpoint | Body / query |
|---|---|
POST tags/{type} | name (a locale map, at least one locale, each ≤ names.max_length), description (a locale map), slug (a locale map — curated types only), color, icon, parent (a key of the same type in exactly the request’s scope; null asks for a root), position (0–2147483647, a number or a digit string). A name a deleted tag holds brings that tag back with the body’s attributes — where it was, unless the body has a parent key. |
PATCH tags/{type}/{tag} | name, description (null clears every locale; a null or empty entry clears that locale), slug, color (null clears), icon (null clears) — only what is sent changes; maps merge per locale. |
POST …/{tag}/move | parent — required, a key or null for a root; position as for a create. |
POST tags/{type}/reorder | parent (a key, or null for the roots), order — required, an array of up to 1000 keys. |
DELETE tags/{type}/{tag} | ?children=refuse|reparent|delete |
| every endpoint | ?scope=alias:key (an alias from tags.scopes — reads see the scope plus the global tags, writes go to the scope) and ?scope_only=1 (reads see the scope’s tags only). |
Listing tags
| Parameter | Meaning |
|---|---|
filter[search] | Names containing the term (escaped), in any supported locale. Always one term — a comma stays part of it; a blank term filters nothing. |
filter[parent] | A parent key, or root. |
filter[depth] | A level number. |
filter[ids] | Comma-separated keys; malformed ones are skipped. |
filter[slug] | Comma-separated slugs, in any locale of the chain. |
filter[trashed] | Managers only (for anyone else an unknown filter, 400): with or only — and only the deleted tags of the scopes the caller manages, so a team admin who may not manage global tags never sees deleted global ones. |
sort | position (default), name (the current locale’s), usage_count, created_at; - for descending, comma-separated. |
per_page, page | Up to tags.routes.max_per_page; 20 by default. |
tree=1 | Nested nodes of a hierarchical type (an empty list for a flat one); more than max_tree_nodes answers 422 tree_too_large. |
include=translations | Every locale of name, description and slug — when the caller manages the type in the request’s scope, and only on the tags whose own scope they manage too. |
Every list — index, suggest, popular and reorder — loads the scope owners of its tags in one query, so a page costs the same number of queries whatever its size.
Resources
| Resource | Everyone who may read | Managers also get |
|---|---|---|
TagResource | id, type (the type name), name, slug and description in the current locale, color, icon, parent_id, depth, position, scope (through tags.resources.scope; null for a global tag), ancestors (when loaded — the show endpoint loads them), deleted_at (when deleted), created_at, updated_at. | usage_count — which for a global tag adds up every tenant’s taggings — and translations with ?include=translations, for managers of the tag’s own scope: a global tag asks for global management, whatever scope the request reads. |
TagSummaryResource | id, type, name, slug, color, icon, depth — plus ancestors when loaded. No usage count, scope, parent or timestamps, so no gate check, relation or query. | — |
TagNodeResource | The summary plus parent_id, position and children. | — |
TagTypeResource | name, label (translated at render time), hierarchical, max_depth, max, curated. | — |
TagScopeResource | {id, type} — the owner’s key and class basename (Team), never the class name. | — |
GET /api/tags/genre/jazz, as a reader:
{
"data": {
"id": 2,
"type": "genre",
"name": "Jazz",
"slug": "jazz",
"description": null,
"color": null,
"icon": "sax",
"parent_id": 1,
"depth": 2,
"position": 0,
"scope": null,
"ancestors": [
{ "id": 1, "type": "genre", "name": "Music", "slug": "music", "color": "#f24e29", "icon": null, "depth": 1 }
],
"created_at": "2026-10-19T09:30:00+00:00",
"updated_at": "2026-10-19T09:30:00+00:00"
}
}A team label in a list, as a manager of that team with ?include=translations — the owner renders through TagScopeResource as its key and class basename, never the class name, and scope_type and scope_key never leave the server:
{
"id": 31,
"type": "label",
"name": "Printer",
"slug": "printer",
"description": "Office printers.",
"color": null,
"icon": null,
"parent_id": null,
"depth": 1,
"position": 0,
"usage_count": 14,
"scope": { "id": 12, "type": "Team" },
"translations": {
"name": { "en": "Printer", "sk": "Tlačiareň" },
"description": { "en": "Office printers.", "sk": "Kancelárske tlačiarne." },
"slug": { "en": "printer", "sk": "tlaciaren" }
},
"created_at": "2026-10-19T09:30:00+00:00",
"updated_at": "2026-10-19T09:30:00+00:00"
}GET /api/tag-types:
{
"data": [
{ "name": "genre", "label": "Genre", "hierarchical": true, "max_depth": 5, "max": 1, "curated": true },
{ "name": "label", "label": "Label", "hierarchical": false, "max_depth": null, "max": 5, "curated": false }
]
}GET /api/tags/genre?tree=1:
{
"data": [
{
"id": 1, "type": "genre", "name": "Music", "slug": "music", "color": "#f24e29", "icon": null, "depth": 1,
"parent_id": null, "position": 0,
"children": [
{
"id": 2, "type": "genre", "name": "Jazz", "slug": "jazz", "color": null, "icon": "sax", "depth": 2,
"parent_id": 1, "position": 0,
"children": []
}
]
}
]
}Your own owner shape
Point tags.resources.scope at a JsonResource of your own to render a name or anything else your API needs; it receives the scope model:
// config/tags.php
'resources' => [
'scope' => App\Http\Resources\TagOwnerResource::class,
],
// app/Http/Resources/TagOwnerResource.php — receives the scope model
final class TagOwnerResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->resource->getKey(),
'name' => $this->resource->name,
];
}
}Private types
A type declared private() is invisible to everyone who may not manage it: it is left out of /tag-types and every endpoint under it answers 404 unknown_tag_type, exactly like a type that does not exist.
Rate limits
Defined only with the routes on: tags-api allows tags.routes.rate_limit (120) requests a minute per signed-in user, per IP otherwise; the suggest endpoint adds tags-suggest with tags.routes.suggest_rate_limit (120).
Errors
Package errors are mapped in the package’s controllers — never in your exception handler — to {message, code}, in the request’s locale. A path segment that names nothing the caller may see is a 404, a reference in the body a 422, and a tag of another scope answers exactly like a missing one:
{ "message": "Unknown tag type [nope].", "code": "unknown_tag_type" }
{ "message": "A tag with the slug [jazz] in [en] already exists.", "code": "duplicate_tag" }| Status | code | When |
|---|---|---|
| 404 | unknown_tag_type | The {type} segment names no type the caller may see — a private type is unknown to non-managers. |
| 404 | tag_not_found | The {tag} segment names no tag in the readable scopes (writes: exactly the request’s scope). A tag of another scope answers exactly the same. |
| 422 | tag_not_found | A parent or order key in the body that is missing, of another type or of another scope. |
| 422 | unknown_alias | A ?scope= or on= alias the allow-lists do not hold, a class name, a malformed key, a key that names no record — or a scope the caller may not use: a denied scope ability answers exactly the same. |
| 422 | invalid_tag, invalid_hierarchy, tag_type_mismatch | A refused attribute (colour, slug, locale…); a cycle, too deep a tree or a deleted parent; a tag of another type. |
| 422 | tree_too_large | ?tree=1 over tags.routes.max_tree_nodes. |
| 409 | duplicate_tag, tag_has_children | A slug another tag of the type and scope holds (a deleted one included); a delete that would orphan live children. |
| 403 | — | The manage or view gate denied — an undefined ability denies. |
| 400 | — | An unknown filter or sort, under query-builder’s default mode.unknown_filter / unknown_sort = reject; ignore drops it instead. |
| 422 / 429 | — | Laravel’s validation errors (field names from tags::validation.attributes); the rate limiters. |
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.