---
title: "JSON API — Tags for Laravel | Roundly"
description: "An opt-in JSON API — types, lists and trees, suggest and popular, manager CRUD with moves and reorders, gated by your abilities, with no class names."
url: https://roundly-consulting.com/open-source/docs/tags-for-laravel/json-api
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Tags for Laravel](https://roundly-consulting.com/open-source/docs/tags-for-laravel.md)

# JSON API

Routes, form requests, controllers, resources, query-builder list filters and rate limits — off until you switch them on:

```bash
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:

```php
// 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. |

```http
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

| 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:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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:

```php
// 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:

```json
{ "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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/tags-for-laravel.md)
