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

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 & pathNameAbilityNotes
GET tag-typestags.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.showviewOne type.
GET tags/{type}tags.indexviewThe list — filters, sorts, paging — or ?tree=1 for nested nodes.
POST tags/{type}tags.storemanageCreate in the request’s scope; 201.
GET tags/{type}/suggesttags.suggestview?q= (1–64 characters) and limit; also throttle:tags-suggest.
GET tags/{type}/populartags.popularview?limit and on=alias from tags.taggables.
POST tags/{type}/reordertags.reordermanage{parent, order}
GET tags/{type}/{tag}tags.showviewOne tag, with its ancestors.
PATCH tags/{type}/{tag}tags.updatemanageOnly the fields sent change.
DELETE tags/{type}/{tag}tags.destroymanage?children=refuse (default), reparent or delete; answers the deleted tag.
POST tags/{type}/{tag}/movetags.movemanage{parent: key|null, position}
POST tags/{type}/{tag}/restoretags.restoremanageBy 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/restore

Request bodies

EndpointBody / 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}/moveparent — required, a key or null for a root; position as for a create.
POST tags/{type}/reorderparent (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

ParameterMeaning
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.
sortposition (default), name (the current locale’s), usage_count, created_at; - for descending, comma-separated.
per_page, pageUp to tags.routes.max_per_page; 20 by default.
tree=1Nested nodes of a hierarchical type (an empty list for a flat one); more than max_tree_nodes answers 422 tree_too_large.
include=translationsEvery 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

ResourceEveryone who may readManagers also get
TagResourceid, 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.
TagSummaryResourceid, type, name, slug, color, icon, depth — plus ancestors when loaded. No usage count, scope, parent or timestamps, so no gate check, relation or query.—
TagNodeResourceThe summary plus parent_id, position and children.—
TagTypeResourcename, 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" }
StatuscodeWhen
404unknown_tag_typeThe {type} segment names no type the caller may see — a private type is unknown to non-managers.
404tag_not_foundThe {tag} segment names no tag in the readable scopes (writes: exactly the request’s scope). A tag of another scope answers exactly the same.
422tag_not_foundA parent or order key in the body that is missing, of another type or of another scope.
422unknown_aliasA ?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.
422invalid_tag, invalid_hierarchy, tag_type_mismatchA refused attribute (colour, slug, locale…); a cycle, too deep a tree or a deleted parent; a tag of another type.
422tree_too_large?tree=1 over tags.routes.max_tree_nodes.
409duplicate_tag, tag_has_childrenA 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 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.