Skip to content

[6.x] Taxonomy structures (hierarchies), routing, and more - #15192

Draft
jackmcdade wants to merge 102 commits into
6.xfrom
feature/hierarchical-taxonomies
Draft

jackmcdade wants to merge 102 commits into
6.xfrom
feature/hierarchical-taxonomies

Conversation

@jackmcdade

@jackmcdade jackmcdade commented Aug 13, 2026

Copy link
Copy Markdown
Member

(I know, finally)

Taxonomies can now be hierarchical/orderable/nestable, using the same structure/tree model as collections. While in there, taxonomies also got the routing control, view scaffolding and API surface that collections have had for a while.

Enable a structure on a taxonomy, drag terms into a tree, and you get nested URLs, parent/children/ancestors/depth in tags, a CP tree view, and a parent relationship field on the publish form. Max depth 1 stays a flat reorderable list, same as collections.

See it in action – https://screen.studio/share/IeuqzNy2

CleanShot 2026-08-13 at 16 11 17@2x

A few constraints worth knowing:

  • One global tree. Slugs stay unique per taxonomy (default locale); URI segments localize.
  • Typed paths in a terms field use > and will create missing segments, but they won't re-parent a term that's already in the tree. A segment that doesn't exist in the tree yet is grafted in at the root so the child can nest under it.
  • No expectsRoot. Terms don't have a collection-style root page.

Routing

Taxonomy and term URLs are now configurable per taxonomy, under Routing & URLs in the taxonomy config.

  • Automagic — what you get today. Nestable taxonomies default to /{handle}/{parent_uri}/{slug}.
  • Custom — your own pattern, per site if you want. {parent_uri} is the new placeholder for the ancestor path. A custom route is treated as a complete URL pattern, so a collection-scoped taxonomy no longer gets a second collection-prefixed URL for free.
  • Disabled — the taxonomy and its terms stop having URLs entirely. $taxonomy->url() and friends return null, and the front-end responses 404.

A nestable term is still resolvable by slug anywhere under the taxonomy — its old flat URL, for instance — and permanently redirects (301) to the canonical nested one. Makes migrating an existing flat taxonomy painless.

Scaffolding

Taxonomies get a Scaffold Views screen, same as collections. It generates index and show views (nestable-aware — the stubs emit ancestors and children loops when the taxonomy is nestable) and writes template / term_template back onto the taxonomy.

Templating

Tree, same as nav/collection structures. Recursive children work the usual way. nav:taxonomy:… is an alias.

{{ structure:taxonomy:categories }}
    {{ title }}
    {{ if children }}{{ *recursive children* }}{{ /if }}
{{ /structure:taxonomy:categories }}

{{ structure for="taxonomy::categories" from="animals" max_depth="2" }}
    {{ title }} (depth {{ depth }})
{{ /structure }}

On a term, the tree is just variables:

{{ parent:title }}
{{ depth }}
{{ children }}{{ title }}{{ /children }}
{{ ancestors }}{{ title }}{{ /ancestors }}

List terms by branch with {{ taxonomy }}. parent without depth is direct children; add depth to go further. depth alone is top N levels of the whole tree.

{{ taxonomy from="categories" parent="animals" }}
    {{ title }}
{{ /taxonomy }}

{{ taxonomy from="categories" parent="animals" depth="2" }}
    {{ title }}
{{ /taxonomy }}

{{ taxonomy from="categories" depth="1" }}
    {{ title }}
{{ /taxonomy }}

Entry listings include the whole branch by default. Opt out with with_descendants="false".

{{ collection:blog taxonomy:categories="animals" }}
    {{ title }}
{{ /collection:blog }}

{{ collection:blog taxonomy:categories="animals" with_descendants="false" }}
    {{ title }}
{{ /collection:blog }}

Also in here

New surface

  • Link fieldtype gains a term type, alongside entries and assets, with a taxonomies config.
  • REST: new GET /api/taxonomies/{taxonomy}/tree, matching the collection and nav tree endpoints. with_descendants=false on the collection-entries and term-entries endpoints.
  • GraphQL: Taxonomy.structure with a tree(site:) field, and parent / children / ancestors / depth on TermInterface. with_descendants on the entries query.
  • Permissions: a new reorder {taxonomy} terms, nested under edit {taxonomy} terms. Existing roles will need it granted.
  • Events: TaxonomyTreeSaving / Saved / Deleted, wired into git automation and static cache invalidation.
  • Trees are stored in content/trees/taxonomies/.

Behaviour changes

None of this is a breaking change, but a few things shift for existing sites.

  • Renaming or deleting a term now matches stored values by slug equivalence rather than exact string. Values stored as raw titles (AC/DC for a term slugged ac-dc) are now rewritten or removed where 6.x left them dangling, and fields that store taxonomy::slug values are updated too — 6.x only matched a bare slug, so those were never touched.
  • Term URL resolution was reworked. TermRepository::findByUri() matches against each taxonomy's term route now, rather than doing a uri-index lookup. Flat taxonomies go through the same path. No regression found, but it's worth knowing so a bug report is easy to place.
  • Taxonomy trees enforce max depth server-side. Collections don't, anywhere. Deliberate — it's the one spot where parity with collections wasn't applied, so please don't "fix" it in either direction.
  • A term blueprint using parent, children, ancestors or depth loses those handles to the structure once you enable a structure. Flat taxonomies keep the blueprint field and its fieldtype, so nothing changes on upgrade.

Fixes

  • ?fields= is now honoured on the terms API show route, completing [6.x] REST API: honor fields param on entries and assets #15319 — which added ResolvesRequestedFields but only wired it into EntryResource and AssetResource.
  • A bare {{ children }} no longer 500s on a term page. It 500s on released 6.x too — AugmentedTerm has never had a children key — so this isn't a regression being cleaned up, just a hole being filled.
  • Multisite: a term referenced by an entry in a site the taxonomy isn't enabled in no longer resolves to an empty title-from-slug stub. That stub shadowed the real term, so edits made to it didn't stick. Term::find() prefers the taxonomy's own site now, and the terms store falls back to the term's actual file. Single-site installs can't be affected either way.
  • Multisite: the taxonomy listing opens in a site the taxonomy actually has, rather than whichever site is globally selected. It also gets a site selector.
  • Non-English sites: taxonomy values are slugged with the entry's site language now, matching what term creation already did. Without it you could end up with a duplicate empty "ghost" term holding the entries while the real term showed none. It's read/index-time only — nothing is rewritten on disk. If an entry stores a raw title rather than a slug (hand-written YAML, an import, an API write — the CP never does this) on a site whose language transliterates differently, the archive page for that term stops listing those entries. Store the slug rather than the title to fix it.

References

jackmcdade and others added 28 commits August 12, 2026 23:31
Opt-in trees live beside collection/nav trees, with parent/child/ancestor accessors on terms and a listener that keeps the tree in sync when terms are saved or deleted.

Co-authored-by: Cursor <cursoragent@cursor.com>
Reuse the collection page tree so terms can be nested, reordered, and created as children, with max depth and a reorder permission.

Co-authored-by: Cursor <cursoragent@cursor.com>
Tree position drives URIs (with a 301 from the old flat path), and taxonomy/collection tags can filter by parent, depth, and descendant terms.

Co-authored-by: Cursor <cursoragent@cursor.com>
Indent options by depth, search by path, create missing segments from a typed path, and show ancestor hints on selected items.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add a tree endpoint plus parent/children/ancestors/depth on terms so frontends can walk the hierarchy without the CP.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…-scoped twins.

Co-authored-by: Cursor <cursoragent@cursor.com>
Make it obvious you can search or type a path, preview the hierarchy as badges in the create option, and drop the redundant parent hint once items are indented.

Co-authored-by: Cursor <cursoragent@cursor.com>
A parent typed in the same path was not in the tree yet, so the child never grafted and both terms appeared at the root.

Co-authored-by: Cursor <cursoragent@cursor.com>
Association indexes created stub keys for sites the taxonomy doesn't use, so Term::find() returned a title-from-slug stub that overwrote the real file on reload.

Co-authored-by: Cursor <cursoragent@cursor.com>
The tree previously always promoted child terms into the deleted parent's place, with no way to remove the whole branch.

Co-authored-by: Cursor <cursoragent@cursor.com>
…he tree.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ions.

Co-authored-by: Cursor <cursoragent@cursor.com>
…m ones.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ing with reorder permission.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…rees.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…follow tree order.

Co-authored-by: Cursor <cursoragent@cursor.com>
… collections.

Co-authored-by: Cursor <cursoragent@cursor.com>
…te URLs follow the selected site.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
… branch.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…when structured.

Co-authored-by: Cursor <cursoragent@cursor.com>
…reparent from the publish form.

Co-authored-by: Cursor <cursoragent@cursor.com>
@jackmcdade jackmcdade changed the title [6.x] Hierarchical Taxonomies (I know, finally) [6.x] Hierarchical Taxonomies Aug 13, 2026
@jackmcdade
jackmcdade requested a review from jasonvarga August 13, 2026 20:20
jasonvarga and others added 3 commits September 10, 2026 15:24
StructureRepository::all() merges taxonomy structures, but the fieldtype
never emitted the taxonomy:: prefix that findByHandle() expects, and had
no TaxonomyStructure branch in its authorization check. No policy is
registered for TaxonomyStructure and there is no global super-user gate
bypass, so every user was denied and taxonomy structures were filtered
out of the listing entirely.

Prefixing the id also removes the handle collision with a nav of the
same name, matching how collection structures already work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stored term values have been whole values rather than delimited paths since
735835f, so the path variants this built could never match anything, and a
filter value could never arrive with a delimiter in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both readers were reverse lookups that flipped the index to turn a uri back
into a handle. Per-taxonomy route control removed the assumption they relied
on -- that a taxonomy's uri is derivable from its handle -- so they were
replaced with forward route matching and the index went unread. It was also
being maintained wrongly by then, caching a Site::current()-dependent value,
and null for any taxonomy with routes disabled.

Nothing else reads it. An unlisted index still resolves lazily through
Store::resolveIndex(), so anything asking for it keeps working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jasonvarga and others added 19 commits September 10, 2026 16:35
…axonomies

# Conflicts:
#	src/Data/DataReferenceUpdater.php
A terms field with a single taxonomy stores bare slugs, while one with
multiple taxonomies stores prefixed term references. The filter expanded
everything to bare slugs, so on a multiple taxonomy field it would match
an identically slugged term in another taxonomy, and descendants of a
hierarchical term wouldn't match at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-site

The selector was in the template but never imported, so it rendered as a
stray element with a Vue warning. Registering it lets you switch which
site's term titles and slugs the tree is shown in.

The tree itself is shared between sites, so nothing about saving is
per-site. Dropped the site from the tree and reorder payloads, and the
unused site validation rule from the reorder controller. The list view's
reorder selector went with it, since the listing is always the selected
site and the order is global either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There were three max-depth rules across four implementations. This adds
assertCanNest(), the single rule for whether a child may be nested under a
given parent, plus a public depthOfTerm() for callers that need the depth
itself, and drops the private duplicate that graftTerm() was using.

It also takes assertDoesNotExceedMaxDepth() out of validateTree(). That
assert was running inside the tree getter, so lowering a taxonomy's
max_depth below its existing tree made every read throw and left the
taxonomy unusable until the YAML was edited by hand. The reorder path
already calls the assert explicitly, which is the only place a tree can
actually get deeper, so nothing is left unguarded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Typing "Cat > Kitten" into a terms field checked only the number of typed
segments, which ignores how deep the existing segments already sit. With a
max_depth of 2 and a tree of animals > cat, that check passed, the kitten
term was created and saved, and the graft then threw. The entry save 422'd
and a stray term was left behind.

The path is now walked before anything is created: segments already in the
tree keep their own depth, new ones land under the previous segment, and
the whole path is rejected if the leaf would land too deep.

The segment parsing lived in both the fieldtype and EnsuresTermPaths; it
now lives in segments() and the fieldtype uses it. The duplicated max-depth
check in the fieldtype is gone, and ensure() takes the validation key so
the error still attaches to the field in the publish form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
update() called toTree() on the request twice, and repaired the result
twice with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
store() only touched the tree when a parent was given, so a term created
without one was never written to the tree file. It only appeared because
validateTree() synthesises missing terms at read time. EntriesController
appends unconditionally; terms now do the same.

Grafting still happens first. Appending at the root beforehand would put
the slug in the tree, and graftTerm() would then see it and bail, leaving
a parented term sitting at the root.

A term whose parent isn't in the tree now lands at the root rather than
being left out of the file entirely.

This also switches the controller to the structure's assertCanNest()
instead of its own copy of the max-depth check, which is the same edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…axonomies

# Conflicts:
#	src/Http/Controllers/CP/Taxonomies/ExtractsFromTermFields.php
#	src/Http/Controllers/CP/Taxonomies/TermsController.php
#	tests/Feature/Taxonomies/UpdateTermTest.php
…axonomies

# Conflicts:
#	src/StaticCaching/DefaultInvalidator.php
An unknown ?site= handle fatalled with "Call to a member function
absoluteUrl() on null" because the handle was passed straight to
LocalizedTerm, and a real site the taxonomy isn't available in
silently returned the default site's terms.

Mirrors TaxonomyTermsController::show(). The tree itself remains
site-agnostic; only the localization target is validated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tree branch referencing a term that no longer exists was rejected along
with its entire subtree, so its children vanished from tree() instead of
moving up into its place. The missing term re-append couldn't bring them
back either, since they were already counted in the tree's slugs.

removeTermReferencesFromTree() now promotes children, matching what
removeDuplicateTermsFromTree() and deleting a term already do.

depthOfTerm() applies the same strip, so the max depth rules stop counting
levels that aren't in the tree the user sees. Previously a taxonomy with a
dangling branch could render an empty tree and still reject nesting under
it as exceeding max depth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`{{ entries }}` on a term already includes entries tagged with any of its
descendants, but `entries_count` queried the associations index by the
term's own slug, so the two disagreed on a nested taxonomy.

Extract the subtree walk to `Taxonomy::termWithDescendants()` so the count
and the entry query share one implementation. It returns just the term's
slug on a flat taxonomy, leaving those counts untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ad mode

The terms fieldtype promised that searching a parent would surface its
descendants, but only delivered it in select mode, where the whole list is
fetched once and the combobox fuzzysorts it over each term's breadcrumb. In
typeahead mode every keystroke hits the server, which matched on title alone,
so descendants were dropped before the client ever saw them.

Expand each title match into its subtree server-side, so both modes agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They're the keys the combobox fuzzysorts when it filters the list itself,
which reads as a promise about the fieldtype as a whole.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Restores single-line rows by truncating titles instead of wrapping, and
puts the editable title back to text-sm so it matches the read-only and
invalid states. Ancestors render as subdued text with chevrons rather
than badges, collapse in the middle past four segments, and give up
their space before the title does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Matches the separator used everywhere else in the CP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A typed path is only ever acted on when one taxonomy is configured, since
that's the only case where the save creates terms. Shipping the delimiter
for a multi-taxonomy field meant a typed value was split into a leaf and
an ancestor breadcrumb the field then had no way to honour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…axonomies

# Conflicts:
#	src/Stache/Stores/TaxonomyTermsStore.php
A terms field configured with multiple taxonomies receives an unprefixed
slug, so a stored value from another taxonomy would match on slug alone
and be rewritten or removed along with the real one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jasonvarga jasonvarga changed the title [6.x] Hierarchical Taxonomies [6.x] Hierarchical taxonomies and per-taxonomy routing Sep 17, 2026
jasonvarga and others added 4 commits September 17, 2026 15:30
A term only gets appended to the tree when the tree is read, so anything that read it beforehand cached a version without it. Orderable collections already flush for this reason in Entry::save(); taxonomies never did, so a term created after the tree had been read stayed invisible to page(), depth(), parent(), ancestors() and children() for the rest of the request.

Delete had the same gap running backwards. UpdateTaxonomyTree::handleDeleted only saves the tree when the branch was actually in it, so deleting a term that was only ever lazily appended left the slug cached, and validateTree put it straight back into the tree everything reads.

Gated on hasStructure() rather than orderable(), since both taxonomy modes share the same lazy-append path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The taxonomy tree flattened its raw branches to work out a term's order, so any term that was only lazily appended to the tree — which for orderable taxonomies is every term the control panel has ever created — had no order at all. Collections have read the validated tree for this since they gained orderable structures.

The Stache `order` index was wrong for the same terms. It's resolved from the term's order at save time, which is before the tree knows about the term, so it cached a null and nothing rewrote it. On a descending orderable taxonomy that made reordering impossible: the listing sorted the tied nulls by insertion order while the reorder controller read the tree, the two disagreed, and every reorder was rejected with a 409. Terms now re-index their order after the save, mirroring what entries do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The code and the docs disagreed. statamic/docs#1991 already uses "nestable" throughout and never says "hierarchical", so the code was the outlier. This renames Taxonomy::hierarchical() to nestable() and its derivatives, along with the CP tab handle and one English string.

Only the maxDepth !== 1 predicate moves. hasStructure() and orderable() are verbatim copies of Collection's and are untouched, so "structured" keeps its meaning. Config, GraphQL, REST, Antlers and JS have zero occurrences, so there is no data migration and no public surface beyond the eight PHP symbols, all of which are new in this PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jasonvarga jasonvarga changed the title [6.x] Hierarchical taxonomies and per-taxonomy routing [6.x] Taxonomy structures (hierarchies), routing, and more Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ability to use the link field with taxonomy terms Localizable Taxonomy Routes Scaffold taxonomy templates Hierarchical Taxonomies

3 participants