Version: 1.0.0
Added: 2026-03-07
Status: Stable
Numen's taxonomy system lets you organize content into structured, hierarchical vocabularies — Categories, Tags, Topics, or any custom grouping you define. Content can belong to multiple terms across multiple vocabularies, and the AI pipeline can auto-categorize content for you.
A vocabulary defines a classification scheme for your content. Think of it as the container:
| Vocabulary | Example Terms |
|---|---|
| Categories | Technology, Design, Business |
| Tags | laravel, php, tutorial, video |
| Topics | Getting Started, Advanced, Reference |
Vocabularies are scoped to a Space and can be configured to:
- Allow or disallow hierarchical nesting (
hierarchy: true/false) - Allow one or multiple terms per content (
allow_multiple: true/false)
A term is a single classification value within a vocabulary. Terms can be nested into trees using a parent_id relationship. For example:
Technology (depth 0)
├── Web Development (depth 1)
│ ├── Laravel (depth 2)
│ └── Vue.js (depth 2)
└── Mobile (depth 1)
Each term has a slug that is unique within its vocabulary (so news can exist in both "Categories" and "Topics").
The content_taxonomy pivot table links content to terms. Each assignment can record:
sort_order— ordering of terms within a content itemauto_assigned— whether the AI pipeline assigned this termconfidence— AI confidence score (0.0–1.0) when auto-assigned
All endpoints are prefixed with /api/v1/. Pass the X-Space header to scope requests to a space (defaults to default).
| Endpoint Type | Auth Required |
|---|---|
| Read (GET) | No (public) |
| Write (POST / PUT / DELETE) | Yes — Bearer token via Sanctum |
Authorization: Bearer <your-token>
X-Space: my-space-slugGET /api/v1/taxonomies
X-Space: my-spaceReturns all vocabularies for the space, ordered by sort_order.
Response:
{
"data": [
{
"id": "01hx...",
"space_id": "01hy...",
"name": "Categories",
"slug": "categories",
"description": "Main content categories",
"hierarchy": true,
"allow_multiple": true,
"settings": null,
"sort_order": 0,
"terms_count": 12,
"created_at": "2026-03-07T08:00:00+00:00",
"updated_at": "2026-03-07T08:00:00+00:00"
}
]
}GET /api/v1/taxonomies/{vocabSlug}
X-Space: my-spaceReturns the vocabulary metadata along with its full term tree.
Response:
{
"data": {
"vocabulary": { "id": "01hx...", "name": "Categories", ... },
"tree": [
{
"id": "01ha...",
"name": "Technology",
"slug": "technology",
"depth": 0,
"children": [
{
"id": "01hb...",
"name": "Laravel",
"slug": "laravel",
"depth": 1,
"children": []
}
]
}
]
}
}POST /api/v1/taxonomies
Authorization: Bearer <token>
X-Space: my-space
Content-Type: application/jsonRequest body:
{
"name": "Topics",
"slug": "topics",
"description": "Content topics for navigation",
"hierarchy": true,
"allow_multiple": true,
"sort_order": 1
}Note: The space is derived from the
X-Spaceheader —space_idis never accepted in the request body to prevent cross-space privilege escalation.
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | ✅ | Max 255 chars |
slug |
string | No | Auto-generated from name if omitted. Pattern: ^[a-z0-9]+(?:-[a-z0-9]+)*$ |
description |
string | No | Max 5000 chars |
hierarchy |
boolean | No | Default: true |
allow_multiple |
boolean | No | Default: true |
settings |
object | No | Arbitrary JSON for extensions |
sort_order |
integer | No | Default: 0. Range: 0–9999 |
Response: 201 Created with the created VocabularyResource.
PUT /api/v1/taxonomies/{id}
Authorization: Bearer <token>
X-Space: my-space
Content-Type: application/jsonAll fields are optional (partial update). Same validation rules as create.
Response: Updated VocabularyResource.
DELETE /api/v1/taxonomies/{id}
Authorization: Bearer <token>
X-Space: my-spaceDeletes the vocabulary and all its terms (cascade). Content assignments are also removed.
Response:
{ "data": { "deleted": true } }GET /api/v1/taxonomies/{vocabSlug}/terms
X-Space: my-spaceReturns all terms in a vocabulary, ordered by sort_order then name.
GET /api/v1/taxonomies/{vocabSlug}/terms?tree=1
X-Space: my-spaceReturns the same data as a nested tree structure (see TaxonomyTermTreeResource).
GET /api/v1/taxonomies/{vocabSlug}/terms/{termSlug}
X-Space: my-spaceResponse:
{
"data": {
"id": "01ha...",
"vocabulary_id": "01hx...",
"parent_id": null,
"name": "Technology",
"slug": "technology",
"description": null,
"path": "/01ha...",
"depth": 0,
"sort_order": 0,
"metadata": null,
"content_count": 42,
"created_at": "2026-03-07T08:00:00+00:00",
"updated_at": "2026-03-07T08:00:00+00:00"
}
}GET /api/v1/taxonomies/{vocabSlug}/terms/{termSlug}/content
X-Space: my-spaceQuery parameters:
| Param | Type | Default | Description |
|---|---|---|---|
per_page |
integer | 20 |
Items per page (max 100) |
include_descendants |
boolean | false |
Include content from child terms |
Example — fetch all Laravel content including child terms:
GET /api/v1/taxonomies/categories/terms/technology/content?include_descendants=true&per_page=10POST /api/v1/taxonomies/{vocabId}/terms
Authorization: Bearer <token>
X-Space: my-space
Content-Type: application/jsonRequest body:
{
"name": "Laravel",
"slug": "laravel",
"parent_id": "01hb...",
"description": "The Laravel PHP framework",
"sort_order": 0,
"metadata": {
"color": "#FF2D20",
"icon": "laravel"
}
}| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | ✅ | Max 255 chars |
slug |
string | No | Auto-generated from name; unique within vocabulary |
parent_id |
string | No | Must belong to the same vocabulary |
description |
string | No | Max 5000 chars |
sort_order |
integer | No | Default: 0. Range: 0–9999 |
metadata |
object | No | Arbitrary JSON (colors, icons, SEO data, etc.) |
Response: 201 Created with the created TaxonomyTermResource.
PUT /api/v1/terms/{id}
Authorization: Bearer <token>
X-Space: my-space
Content-Type: application/jsonPartial update — only include fields you want to change. Same field rules as create.
When parent_id is changed, the materialized path and depth are automatically recomputed for the term and all its descendants.
DELETE /api/v1/terms/{id}?child_strategy=reparent
Authorization: Bearer <token>
X-Space: my-space| Parameter | Value | Behavior |
|---|---|---|
child_strategy |
reparent (default) |
Child terms are moved up to the deleted term's parent |
child_strategy |
cascade |
Child terms and the entire subtree are deleted |
Response:
{ "data": { "deleted": true } }Changes a term's parent (drag-and-drop reparenting). Recomputes paths for the entire affected subtree.
POST /api/v1/terms/{id}/move
Authorization: Bearer <token>
X-Space: my-space
Content-Type: application/json{
"parent_id": "01hc..."
}Pass "parent_id": null to promote the term to root level.
Updates sort_order for a batch of terms (drag-and-drop ordering).
POST /api/v1/terms/reorder
Authorization: Bearer <token>
X-Space: my-space
Content-Type: application/json{
"ordering": {
"01ha...": 0,
"01hb...": 1,
"01hc...": 2
}
}All term IDs in the ordering map must belong to the current space, or the request is rejected with 403.
Response:
{ "data": { "reordered": true } }GET /api/v1/content/{slug}/termsReturns all taxonomy terms assigned to the published content item.
Adds terms without removing existing ones.
POST /api/v1/content/{id}/terms
Authorization: Bearer <token>
X-Space: my-space
Content-Type: application/json{
"assignments": [
{
"term_id": "01ha...",
"sort_order": 0
},
{
"term_id": "01hb...",
"sort_order": 1,
"auto_assigned": false
}
]
}| Field | Type | Required | Notes |
|---|---|---|---|
assignments[].term_id |
string | ✅ | Must belong to the same space |
assignments[].sort_order |
integer | No | Default: 0 |
assignments[].auto_assigned |
boolean | No | Default: false |
assignments[].confidence |
float | No | AI confidence score (0.0–1.0) |
Replaces all existing term assignments with the provided list.
PUT /api/v1/content/{id}/terms
Authorization: Bearer <token>
X-Space: my-space
Content-Type: application/json{
"term_ids": ["01ha...", "01hb..."]
}Pass an empty array ("term_ids": []) to remove all term assignments.
DELETE /api/v1/content/{id}/terms/{termId}
Authorization: Bearer <token>
X-Space: my-spaceResponse:
{ "data": { "removed": true } }Dispatches a background job to analyze the content and auto-assign matching terms.
POST /api/v1/content/{id}/auto-categorize
Authorization: Bearer <token>
X-Space: my-spaceResponse:
{
"data": {
"queued": true,
"content_id": "01hd..."
}
}The job uses TaxonomyCategorizationService to analyze the content body, score each available term, and assign those above the confidence threshold (default: 0.7).
The taxonomy manager is available at Admin → Taxonomy (/admin/taxonomy).
The Vocabulary List (/admin/taxonomy) shows all vocabularies for the default space with their term counts. From here you can:
- Create a new vocabulary via the form panel
- Click a vocabulary name to open its term tree editor
- Edit or Delete a vocabulary inline
When creating a vocabulary:
- Name is required; slug is auto-generated but can be customized
- Hierarchy controls whether terms can be nested (enable for categories, disable for flat tag clouds)
- Allow Multiple controls whether content can have more than one term from this vocabulary
- Sort Order controls the display order in the vocabulary list and the content editor's term picker
The Term Tree Editor (/admin/taxonomy/{id}) shows a drag-and-drop tree for one vocabulary.
- Click + Add Term (or the + button next to a parent term to create a child)
- Fill in the name; slug is auto-generated
- Optionally add a description and metadata (JSON)
- Click Save
- Drag a term node to reorder it among its siblings
- Drag a term onto another term to reparent it
- Changes are saved immediately via AJAX — no page reload needed
Click the × button on a term. You'll be prompted to choose:
- Reparent children — child terms move up to the deleted term's parent (default, safe)
- Delete subtree — the term and all its descendants are permanently deleted
From the Content Edit page, the sidebar contains a Terms panel (powered by TermPicker.vue):
- Terms are grouped by vocabulary
- Use the search box to filter terms by name
- Click a term badge to assign it; click again to remove
- AI-suggested terms appear with a confidence badge — click to accept or ignore them
use App\Models\Vocabulary;
// Find a vocabulary for a space
$vocabulary = Vocabulary::forSpace($spaceId)
->where('slug', 'categories')
->firstOrFail();
// Get root terms only
$rootTerms = $vocabulary->rootTerms; // ordered by sort_order
// Get all terms
$allTerms = $vocabulary->terms;Scopes:
| Scope | Usage |
|---|---|
forSpace($spaceId) |
Filter by space |
ordered() |
Order by sort_order |
use App\Models\TaxonomyTerm;
// Get the full tree for a vocabulary
$roots = TaxonomyTerm::inVocabulary($vocabularyId)
->roots()
->ordered()
->with('childrenRecursive')
->get();
// Find all descendants of a term
$descendants = TaxonomyTerm::descendantsOf($termId)->get();
// Get ancestor IDs from the materialized path
$ancestorIds = $term->getAncestorIds(); // array of ULID strings
// Check ancestry
$isParent = $termA->isAncestorOf($termB); // boolScopes:
| Scope | Usage |
|---|---|
roots() |
Only root terms (no parent) |
ordered() |
Order by sort_order, then name |
inVocabulary($vocabularyId) |
Filter by vocabulary |
descendantsOf($termId) |
All descendants via materialized path |
Relations:
| Relation | Type | Description |
|---|---|---|
vocabulary() |
BelongsTo | Parent vocabulary |
parent() |
BelongsTo | Parent term (nullable) |
children() |
HasMany | Direct child terms |
childrenRecursive() |
HasMany | All descendants (eager-load for trees) |
contents() |
BelongsToMany | Content items via content_taxonomy |
Path computation is automatic. When a term is created or its parent_id changes, the model recalculates path (materialized path like /root-id/parent-id/this-id) and depth (integer, 0 = root).
The Content model has two new relationships and two new scopes:
// Get all taxonomy terms for a content item
$terms = $content->taxonomyTerms; // BelongsToMany with pivot data
// Get terms for a specific vocabulary
$categories = $content->termsInVocabulary('categories')->get();
// Query: filter content by term ID
$laravelContent = Content::inTerm($termId)->published()->get();
// Query: filter content by vocab slug + term slug
$content = Content::inTaxonomy('categories', 'laravel')->published()->get();Pivot columns available:
$content->taxonomyTerms->each(function ($term) {
$term->pivot->sort_order; // int
$term->pivot->auto_assigned; // bool
$term->pivot->confidence; // float|null
});All taxonomy operations go through App\Services\Taxonomy\TaxonomyService. Inject it via the constructor or resolve from the container.
use App\Services\Taxonomy\TaxonomyService;
class MyController extends Controller
{
public function __construct(private readonly TaxonomyService $taxonomy) {}
}$vocabulary = $this->taxonomy->createVocabulary($spaceId, [
'name' => 'Topics',
'hierarchy' => true,
'allow_multiple' => true,
]);$term = $this->taxonomy->createTerm($vocabulary->id, [
'name' => 'Laravel',
'parent_id' => $webDevTerm->id, // optional
]);Slug is auto-generated from name if not provided. Uniqueness is enforced per vocabulary.
// Move under a new parent
$term = $this->taxonomy->moveTerm($term, $newParentId);
// Promote to root
$term = $this->taxonomy->moveTerm($term, null);Paths for the entire subtree are recomputed atomically.
$this->taxonomy->reorderTerms([
$termIdA => 0,
$termIdB => 1,
$termIdC => 2,
]);// Additive — does not remove existing assignments
$this->taxonomy->assignTerms($content, [
['term_id' => $termId, 'sort_order' => 0],
['term_id' => $termId2, 'sort_order' => 1, 'auto_assigned' => true, 'confidence' => 0.92],
]);
// Sync — replaces all assignments
$this->taxonomy->syncTerms($content, [$termId, $termId2]);
// Remove specific terms
$this->taxonomy->removeTerms($content, [$termId]);content_count on all affected terms is automatically recalculated after assignment changes.
// Published content for this term only
$paginator = $this->taxonomy->getContentForTerm($term, perPage: 20);
// Include content tagged with any descendant term
$paginator = $this->taxonomy->getContentForTerm($term, includeDescendants: true, perPage: 20);$tree = $this->taxonomy->getTree($vocabularyId);
// Collection of root TaxonomyTerm models, each with ->childrenRecursive loadedApp\Services\Taxonomy\TaxonomyCategorizationService uses the configured LLM to analyze content and suggest terms.
use App\Services\Taxonomy\TaxonomyCategorizationService;
// Suggest terms (no side effects)
$suggestions = $service->suggestTerms($content);
// [['term' => TaxonomyTerm, 'confidence' => 0.95], ...]
// Limit to one vocabulary
$suggestions = $service->suggestTerms($content, vocabularyId: $vocabId);
// Auto-assign (persists to DB, fires assignTerms)
$assigned = $service->autoAssign(
content: $content,
confidenceThreshold: 0.7, // default
vocabularyId: null, // null = all vocabularies
);Configuration in config/numen.php:
'taxonomy' => [
'auto_assign_threshold' => env('NUMEN_TAXONOMY_AUTO_ASSIGN_THRESHOLD', 0.7),
'auto_assign_max_terms' => env('NUMEN_TAXONOMY_AUTO_ASSIGN_MAX', 5),
'categorization_model' => env('NUMEN_TAXONOMY_MODEL', 'claude-haiku-4-5-20251001'),
'categorization_provider'=> env('NUMEN_TAXONOMY_PROVIDER', 'anthropic'),
'max_depth' => env('NUMEN_TAXONOMY_MAX_DEPTH', 10),
],Environment variables to add to .env:
NUMEN_TAXONOMY_AUTO_ASSIGN_THRESHOLD=0.7
NUMEN_TAXONOMY_AUTO_ASSIGN_MAX=5
NUMEN_TAXONOMY_MODEL=claude-haiku-4-5-20251001
NUMEN_TAXONOMY_PROVIDER=anthropic
NUMEN_TAXONOMY_MAX_DEPTH=10The CategorizePipelineContent job can be dispatched as part of a content pipeline run.
use App\Jobs\CategorizePipelineContent;
// Dispatch for a specific content item
CategorizePipelineContent::dispatch($content);To add auto-categorization as a pipeline stage, configure the pipeline:
{
"stages": [
{ "name": "content_creation", "agent": "content_creator" },
{ "name": "seo_optimization", "agent": "seo_specialist" },
{ "name": "auto_categorize", "agent": "taxonomy_categorizer" },
{ "name": "editorial_review", "agent": "editor" },
{ "name": "publish", "agent": "publisher" }
]
}The categorization stage logs its results to pipeline_runs.context under taxonomy_auto_assigned:
{
"taxonomy_auto_assigned": [
{ "term_id": "01ha...", "term_name": "Laravel", "confidence": 0.95 },
{ "term_id": "01hb...", "term_name": "PHP", "confidence": 0.88 }
]
}The settings JSON column on vocabularies is reserved for extension. Use it to store content-type restrictions, display preferences, or validation rules:
$vocabulary = $this->taxonomy->createVocabulary($spaceId, [
'name' => 'Product Tags',
'settings' => [
'content_type_restrictions' => ['product', 'product-review'],
'required' => true,
'min_terms' => 1,
'max_terms' => 10,
],
]);The metadata JSON column on terms is unstructured — store whatever you need:
$term = $this->taxonomy->createTerm($vocab->id, [
'name' => 'Laravel',
'metadata' => [
'color' => '#FF2D20',
'icon' => 'laravel',
'seo_title' => 'Laravel Tutorials & Guides',
'seo_description' => 'Browse our Laravel content library.',
],
]);<?php
namespace Database\Seeders;
use App\Models\Space;
use App\Services\Taxonomy\TaxonomyService;
use Illuminate\Database\Seeder;
class MyTaxonomySeeder extends Seeder
{
public function __construct(private readonly TaxonomyService $taxonomy) {}
public function run(): void
{
$space = Space::where('slug', 'default')->firstOrFail();
$vocab = $this->taxonomy->createVocabulary($space->id, [
'name' => 'Categories',
'hierarchy' => true,
]);
$tech = $this->taxonomy->createTerm($vocab->id, ['name' => 'Technology']);
$web = $this->taxonomy->createTerm($vocab->id, ['name' => 'Web Development', 'parent_id' => $tech->id]);
$this->taxonomy->createTerm($vocab->id, ['name' => 'Laravel', 'parent_id' => $web->id]);
$this->taxonomy->createTerm($vocab->id, ['name' => 'Vue.js', 'parent_id' => $web->id]);
}
}The content delivery API supports taxonomy filtering via query parameters:
GET /api/v1/content?taxonomy[categories]=laravel,php
GET /api/v1/content?taxonomy[tags]=tutorialYou can also use the Eloquent scopes directly:
// Single term
Content::inTerm($termId)->published()->paginate(20);
// By vocab + term slug
Content::inTaxonomy('categories', 'laravel')->published()->paginate(20);Three new tables were introduced. No existing tables were modified.
| Table | Purpose |
|---|---|
vocabularies |
Vocabulary definitions, scoped to spaces |
taxonomy_terms |
Term definitions with hierarchical adjacency list + materialized path |
content_taxonomy |
Pivot table linking content to terms |
php artisan migrateIf you are on a fresh install, this runs automatically. For existing installations:
php artisan migrate --stepThe migration order matters — vocabularies must run before taxonomy_terms, and taxonomy_terms before content_taxonomy.
| Migration | What it does |
|---|---|
2026_03_07_000001_create_vocabularies_table |
Creates vocabularies with ULID PK, space FK, slug unique constraint |
2026_03_07_000002_create_taxonomy_terms_table |
Creates taxonomy_terms with self-referencing parent_id, materialized path, depth |
2026_03_07_000003_create_content_taxonomy_table |
Creates pivot table (initial version with separate ULID PK) |
2026_03_07_080100_add_updated_at_to_content_taxonomy_table |
Adds updated_at column to pivot |
2026_03_07_080533_fix_content_taxonomy_primary_key |
Recreates pivot table with composite PK (content_id, term_id) — required for Laravel's BelongsToMany::sync() and attach() |
⚠️ Thefix_content_taxonomy_primary_keymigration drops and recreatescontent_taxonomy. On a fresh install this is a no-op. On an existing installation with taxonomy data, this migration will clear the pivot table. If you have existingcontent_taxonomyrows (from a pre-release build), back them up before migrating.
# Roll back all taxonomy migrations
php artisan migrate:rollback --step=5Rollback cascades correctly — content_taxonomy is dropped first, then taxonomy_terms, then vocabularies.
vocabularies
id CHAR(26) PRIMARY KEY -- ULID
space_id CHAR(26) NOT NULL -- FK → spaces.id (cascade delete)
name VARCHAR(255) NOT NULL
slug VARCHAR(255) NOT NULL
description TEXT NULL
hierarchy TINYINT(1) DEFAULT 1
allow_multiple TINYINT(1) DEFAULT 1
settings JSON NULL
sort_order INT DEFAULT 0
created_at TIMESTAMP NULL
updated_at TIMESTAMP NULL
UNIQUE KEY (space_id, slug)
INDEX (space_id)taxonomy_terms
id CHAR(26) PRIMARY KEY -- ULID
vocabulary_id CHAR(26) NOT NULL -- FK → vocabularies.id (cascade delete)
parent_id CHAR(26) NULL -- FK → taxonomy_terms.id (set null on delete)
name VARCHAR(255) NOT NULL
slug VARCHAR(255) NOT NULL
description TEXT NULL
path VARCHAR(1000) NULL -- Materialized: "/root-id/parent-id/this-id"
depth INT DEFAULT 0
sort_order INT DEFAULT 0
metadata JSON NULL
content_count INT DEFAULT 0 -- Denormalized, auto-refreshed on assignment changes
created_at TIMESTAMP NULL
updated_at TIMESTAMP NULL
UNIQUE KEY (vocabulary_id, slug)
INDEX (vocabulary_id, parent_id)
INDEX (path)
INDEX (sort_order)content_taxonomy (pivot)
content_id CHAR(26) NOT NULL -- FK → contents.id (cascade delete)
term_id CHAR(26) NOT NULL -- FK → taxonomy_terms.id (cascade delete)
sort_order INT DEFAULT 0
auto_assigned TINYINT(1) DEFAULT 0
confidence DECIMAL(5,4) NULL -- 0.0000–1.0000
created_at TIMESTAMP NULL
updated_at TIMESTAMP NULL
PRIMARY KEY (content_id, term_id)
INDEX (content_id)
INDEX (term_id)Prior to this feature, the Content model had a JSON column named taxonomy for unstructured classification data. That column is not removed — it remains for backward compatibility.
You should migrate custom data from contents.taxonomy to the new relational system. A one-off Artisan command is a good pattern:
// Example migration script (not included — write one specific to your data)
Content::whereNotNull('taxonomy')->chunk(100, function ($items) use ($service) {
foreach ($items as $content) {
// Map your JSON structure to term assignments
}
});The taxonomy JSON column is deprecated and will be removed in a future version.
Documentation by Scribe 📝 — Numen Technical Writing