Skip to content

Taxonomies and Terms

A taxonomy is a named way of classifying entries, and a term is one value inside it. Cuisine and diet are taxonomies; Sicilian and vegetarian are terms.

registerTermTaxonomy(name, options) declares the taxonomy. Terms themselves are content rather than declarations. You create them in the admin or through the term API, the same way you create entries.

Three tables carry the model. Every term of every taxonomy is a row in terms, told apart by its taxonomy column, so a slug is unique within a taxonomy and a cuisine term and a diet term can both be called italian. A term’s parentId points at another term for hierarchy. Assignments live in entry_term, one row per entry-and-term pair.

The choice that separates the two example taxonomies is hierarchy. cuisine is hierarchical, so Sicilian sits under Italian and the term URL nests. diet is flat, so vegetarian, vegan and gluten-free are siblings.

label, every key of labels and description each take a plain string or a Lingui message descriptor. The admin shows description in muted text under the heading of the taxonomy’s term list, and shows nothing there when it is unset.

Registration goes through a plugin’s setup, alongside the entry types it classifies. Overview covers that arrangement.

  1. Register both taxonomies. In plugins/recipes.ts:

    import { definePlugin } from "plumix/plugin";
    export const recipes = definePlugin("recipes", {
    setup: (ctx) => {
    ctx.registerTermTaxonomy("cuisine", {
    label: "Cuisines",
    labels: { singular: "Cuisine", plural: "Cuisines" },
    isHierarchical: true,
    entryTypes: ["recipe"],
    });
    ctx.registerTermTaxonomy("diet", {
    label: "Diets",
    labels: { singular: "Diet", plural: "Diets" },
    isHierarchical: false,
    entryTypes: ["recipe"],
    });
    },
    });
  2. Point the entry type at them. Add termTaxonomies: ["cuisine", "diet"] to the registerEntryType("recipe", …) call in the same plugin.

  3. Create some terms. Open /_plumix/admin/terms/cuisine. Add Italian, then add Sicilian with Italian as its parent. Add vegetarian and vegan under /_plumix/admin/terms/diet.

  4. Classify a recipe. Open Sicilian Caponata in the editor. The editor sidebar now carries a Cuisines picker and a Diets picker. Choose Sicilian and vegetarian, and once the recipe is published it appears on the archives at /cuisine/italian/sicilian and /diet/vegetarian.

An entry type and a taxonomy each name the other, and the two declarations do different jobs.

termTaxonomies on the entry type is what puts the pickers on the editor. The editor intersects that list with the taxonomies the current user holds term:<taxonomy>:assign for, so a writer who cannot assign diets sees only the Cuisines picker rather than one that fails on save.

entryTypes on the taxonomy decides what a term archive contains. The archive at /cuisine/italian/sicilian lists entries whose type appears in that array and is public. A taxonomy registered with no entryTypes lists the entries of every public type carrying the term. Creating or deleting a term purges the stored pages that showed it and every stored list of its taxonomy’s terms, with or without entryTypes. Editing the Sicilian term purges only the pages that showed it.

Leave one side out and half the behaviour goes missing quietly. Declare both.

import { definePlugin } from "plumix/plugin";
export const recipes = definePlugin("recipes", {
setup: (ctx) => {
ctx.registerEntryType("recipe", {
label: "Recipes",
labels: { singular: "Recipe", plural: "Recipes" },
termTaxonomies: ["cuisine", "diet"],
hasArchive: true,
rewrite: { slug: "recipes" },
});
ctx.registerTermTaxonomy("cuisine", {
label: "Cuisines",
labels: { singular: "Cuisine", plural: "Cuisines" },
isHierarchical: true,
entryTypes: ["recipe"],
hasAdminColumn: true,
});
},
});

A taxonomy may list more than one entry type. That is why entryTypes is an array rather than a single name. Adding a second type widens the term archive without touching the terms already stored.

A hierarchical taxonomy nests its term URLs. Sicilian under Italian serves at /cuisine/italian/sicilian, and each level has a /page/:page variant for later pages of the archive. A flat taxonomy stays at one segment, so vegetarian serves at /diet/vegetarian.

The prefix is the taxonomy name unless rewrite.slug overrides it, and rewrite.isHierarchical: false keeps the flat URL shape even where the term tree itself is a tree. That override is one or more lowercase kebab-case segments joined by /, so rewrite: { slug: "insights/category" } serves a term at /insights/category/policy and its feed at /insights/category/policy/feed. Unlike an entry type a taxonomy cannot take the empty string — a slug the router cannot compile into /<prefix>/:term throws at boot.

A term’s parent has to belong to the same taxonomy, and the server rejects a cross-taxonomy parent on write. Deleting a term re-roots its children rather than removing them, setting their parentId to null, and drops that term’s assignments.

A term archive is a paginated listing of the entries carrying that term. It shows entries that are published and carry a publish time, so a scheduled recipe stays off its cuisine archive until the cron flips it.

A term is addressed by its slug, which is unique within its taxonomy. The ancestors in a nested URL identify nothing the slug didn’t, so the archive routes a taxonomy compiles answer any path ending in the slug and 301 it to the term’s canonical URL: /cuisine/sicilian and /cuisine/french/sicilian both redirect to /cuisine/italian/sicilian, keeping the page number and query string. A rule a plugin registers with registerRewriteRule is its own canon. /dishes/:term serves /dishes/sicilian as it stands, nested term or not, and never redirects.

archivePerPage sets the page size and defaults to 20. It sits on the taxonomy, so cuisine archives and diet archives can page differently.

import { definePlugin } from "plumix/plugin";
export const recipes = definePlugin("recipes", {
setup: (ctx) => {
ctx.registerTermTaxonomy("cuisine", {
label: "Cuisines",
labels: { singular: "Cuisine", plural: "Cuisines" },
isHierarchical: true,
entryTypes: ["recipe"],
archivePerPage: 12,
});
},
});

Term archive routes compile ahead of entry-type routes, so a taxonomy prefix wins a collision against an entry type that claimed the same prefix. Ordering only settles a collision between two different patterns. A hierarchical taxonomy and a hierarchical entry type sharing one prefix both emit the raw pattern /<prefix>/:path+, and the compiler throws duplicate_rewrite_rule at boot rather than picking a winner, so the site fails to start.

Registering a taxonomy mints five capabilities named term:<taxonomy>:<action>, each granted to the default roles below. admin holds every one, and a role you declare grants them under term, such as term: { cuisine: ["assign"] }.

Capability Default roles Gates
term:cuisine:read member, author, editor Listing and reading terms
term:cuisine:assign author, editor Attaching a term to an entry
term:cuisine:edit editor Creating and editing terms
term:cuisine:delete editor Deleting a term
term:cuisine:manage editor Nothing in core; a gate plugins can claim

The term: prefix keeps the namespaces apart, so a cuisine taxonomy and a cuisine entry type would never share a capability. capabilities on the registration names the default roles that receive any of the five, the same way it does for an entry type.

Core’s own term procedures gate on edit and delete; manage is there for a plugin that wants one taxonomy-wide switch. The menu plugin uses it, gating every menu mutation on term:menu:manage.

Assigning is deliberately a lower bar than editing. An author can tag a recipe Sicilian; adding Sicilian to the taxonomy in the first place takes an editor.

Term assignment travels with the entry, in a terms patch keyed by taxonomy name and holding term ids. The server rewrites each taxonomy in the patch independently. A taxonomy the patch omits keeps its assignments, and an empty array clears one taxonomy without touching the others.

The server validates the whole patch before it writes anything. An unregistered taxonomy, a term id from a different taxonomy, or a missing assign capability rejects the save rather than leaving a half-classified entry behind.

Terms carry their own meta bag and their own meta boxes. registerTermMetaBox scopes a card of fields to one or more taxonomies, and the field shape is the same one entries use.

import { text, textarea } from "plumix/fields";
import { definePlugin } from "plumix/plugin";
export const recipes = definePlugin("recipes", {
setup: (ctx) => {
ctx.registerTermMetaBox("cuisine-details", {
label: "Cuisine details",
termTaxonomies: ["cuisine"],
fields: [
text("region").label("Region"),
textarea("history").label("Culinary history"),
],
});
},
});

Not every option reaches the browser. The projection that builds the plugin manifest works from an allowlist, and it holds back hasAdminColumn, isInQuickEdit, rewrite and capabilities. Two of those four still do their work on the server. rewrite sets the compiled route’s base slug and decides whether term URLs nest, and capabilities names the default roles that receive each of the five actions above. The other two reach nothing today, so do not expect hasAdminColumn: true to add a column to the entry list, or isInQuickEdit to change the quick-edit form.

isPublic: false removes the taxonomy’s public surface altogether. No term archive routes compile, and the term URL builder returns null.

Taxonomies classify entries, and Entry Types covers the type on the other side of the pairing. Term meta boxes use the field builders described in Fields and the types listed in Field Types. Term archive URLs are one output of the router, which Permalinks and Slugs covers, and a theme picks the template for one through Template Hierarchy. The access layer described in Access & Identity enforces the capability table above.

Read Statuses and Publishing for what has to be true before a classified recipe shows up on its term archive. Then attach the Recipe details card with Meta Boxes, which is where prep time, servings and ingredients live.

For a worked pair of registrations, Blog ships a hierarchical category and a flat tag, both scoped to post.