How-to: Tag / Label API
This guide demonstrates a generic entity tagging API where arbitrary tags can be attached to any entity ID, with tag-based reverse lookup.
Pattern Overview
- Tags are stored globally and identified by a slug (
a-z0-9-, 1–50 chars). - Any entity (identified by integer ID) can have multiple tags.
POST /tags— Create or retrieve a tag (find-or-create; idempotent).GET /tags— List all known tags.GET /tags/{tag}/entities— Reverse lookup: which entities have this tag?POST /entities/{entityId}/tags— Attach a tag to an entity.GET /entities/{entityId}/tags— List all tags for an entity.DELETE /entities/{entityId}/tags/{tag}— Detach a tag from an entity.
Schema
sql
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS entity_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE,
UNIQUE (entity_id, tag_id)
);Find-or-Create Pattern
Tag creation is idempotent — POST /tags returns 201 on first creation, 200 if the tag already exists:
php
public function findOrCreate(string $name): array
{
$existing = $this->findByName($name);
if ($existing !== null) {
return $existing;
}
$this->pdo->prepare(
'INSERT INTO tags (name, created_at) VALUES (:name, :now)'
)->execute([':name' => $name, ':now' => $this->now()]);
return $this->findByName($name) ?? [];
}The attachTag handler also uses find-or-create so clients can attach tags without a separate create step.
Tag Name Validation
Tag names are normalized to lowercase and validated with a tight format regex:
php
private const string TAG_PATTERN = '/\A[a-z0-9-]{1,50}\z/';
$name = strtolower(trim((string) ($body['name'] ?? '')));
if (!preg_match(self::TAG_PATTERN, $name)) {
return $this->problem(422, 'validation-failed', '...');
}Spaces, uppercase, underscores, and special characters are all rejected.
Reverse Lookup (Tag → Entities)
GET /tags/{tag}/entities returns 404 if the tag doesn't exist in the database, and an empty array if it exists but is unused:
php
if ($this->repo->findByName($tag) === null) {
return $this->problem(404, 'not-found', 'Tag not found.');
}
return $this->json(['tag' => $tag, 'entity_ids' => $this->repo->entitiesForTag($tag)]);SQL for reverse lookup:
sql
SELECT entity_id FROM entity_tags WHERE tag_id = :tid ORDER BY entity_id ASCAttach / Detach Idempotency
Attaching the same tag to the same entity twice returns 200 (not 201) with "attached": false:
php
$attached = $this->repo->attach($entityId, (int) $tag['id']);
return $this->json([...], $attached ? 201 : 200);Detaching a tag that is not attached returns 404.
Entity ID Validation
Entity IDs are validated with ctype_digit() to avoid ReDoS and ensure non-negative integers:
php
if (!ctype_digit($raw) || strlen($raw) > 18) {
return null;
}
$id = (int) $raw;
return $id > 0 ? $id : null;Routes
POST /tags Create or retrieve a tag
GET /tags List all tags
GET /tags/{tag}/entities Reverse lookup: entities with this tag
POST /entities/{entityId}/tags Attach tag to entity
GET /entities/{entityId}/tags List tags for entity
DELETE /entities/{entityId}/tags/{tag} Detach tag from entitySee Also
- FT209 source:
../NENE2-FT/taglog/ - Related:
docs/howto/note-taking.md(FT202, tag-based note search)