Skip to content

[6.x] Hierarchical Taxonomies - #15192

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

[6.x] Hierarchical Taxonomies#15192
jackmcdade wants to merge 86 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, using the same structure/tree model as collections.

Enable it 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 will create missing segments, but they won't re-parent a term that's already in the tree.
  • No expectsRoot. Terms don't have a collection-style root page.

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

Still needed

  • Eloquent driver companion. Still in draft.
  • Docs. Routing, the structure tag, with_descendants, the parent field, and the "typed paths don't re-parent" rule.

Test plan

  • Create a hierarchical taxonomy, nest terms in the tree, confirm nested URLs and CP parent field
  • Reparent from the publish form; reject self / descendant / max-depth
  • Max-depth 1 taxonomy behaves as a flat reorderable list
  • Terms field: typed nested path creates missing terms without moving existing ones
  • Multi-site: one tree, localized URI segments, site selector on the tree
  • {{ structure:taxonomy:… }} and collection/entry filters include descendants by default

Issues

Closes statamic/ideas#92
Closes statamic/ideas#264
Closes statamic/ideas#1053
Closes #2334.

Related: #74, statamic/ideas#839, statamic/ideas#580.

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 22 commits September 4, 2026 14:44
Tree::save() bails when dispatchSavingEvent() returns false, but
TaxonomyTreeSaving inherited Event::dispatch(), which calls event()
without halting and so returns an array of listener responses. A
listener returning false was ignored and the tree saved anyway.

NavTreeSaving and CollectionTreeSaving both override dispatch() to halt.
This one was missed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
847503a added a tree prop to SelectField, and the binding for it on
RelationshipInput, to gate a taggable placeholder branch. 8f18c5f
removed that branch as dead -- both arms returned the same string -- but
left the prop and binding behind.

SelectField never used tree for anything tree-shaped. Selector remains
the only consumer, and its binding on RelationshipInput is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The select field was type-sniffing `config.type` to decide which item keys
to fuzzy match on. That's the fieldtype declaring how it wants to be
searched, so it belongs in `preload()`.

Terms now send `search_titles` — the breadcrumb of ancestor titles plus
their own — in place of the slug-joined `path`, so what's typed can match
what's on screen.
A hierarchy prefix isn't a taxonomy concept — an entry from a structured
collection wants the same thing. So `Relationship` gains a `getItemPath()`
sibling to `getItemHint()`, `Terms` overrides it with its ancestor titles, and
the item row renders whatever `path` it's given as a breadcrumb.

Terms were shipping the ancestor chain twice: joined into `hint` and structured
as `ancestors`, with a `showHintFallback` computed existing only to stop the two
double-printing. The taxonomy name says which bucket a term came from; the
ancestors say where it sits inside it. Splitting them lets `hint` go back to
meaning what it means for every other relationship fieldtype, so its markup
reverts to the right-aligned treatment — which also fixes an entries field with
more than one collection rendering `Blog > My Post` instead of `My Post … Blog`.

That leaves `taxonomy_title` with nothing to do, since `hint` carries it again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Typing `animals > cat > calico` into a terms field creates the whole chain on
save, but until then the row was synthesised client-side by sniffing
`config.type === 'terms'` and splitting on a hard-coded `>`. It rendered a
`»`-joined hint where a saved term renders badges, so the optimistic row never
looked like the thing it was going to become — and since the term doesn't exist
until the entry is saved, that's the row you look at for the whole session.

`preload()` now says which character, if any, separates a typed path, and the
select field splits on that and emits a `path`, so the row goes through the same
breadcrumb branch as a saved one. The two methods that only existed to sniff a
term id go with it, along with the create row's third separator glyph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Search results kept their indentation. The list is only sorted into tree order
when nothing is searching, sorting or paginating, but `depth` was stamped on
every item regardless, so a matching child stayed indented under a parent that
wasn't in the results at all.

`depth` is now only sent for a list that is actually in tree order, and the
option row indents when it has one and renders a breadcrumb when it doesn't. An
indent locates an option against the ancestors listed above it; with those gone,
a breadcrumb answers the same question on its own.

`path` keeps shipping either way, because it describes the term rather than the
list — the selected row needs it however the list that produced it was ordered,
and select field options are handed straight to that row on click.

The breadcrumb is the one the item row already renders, extracted so the option
and the row it becomes can't drift apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
In select mode the options are fetched once, without a search, and the
combobox filters that list itself. The server has no query to see, so it
sends a depth, and filtered options stayed indented under parents that
were no longer in the list. Track the query the combobox already emits
and suppress the indent while it's set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The hint was hidden on anything below the top level, from when it carried a
term's ancestors as well as its taxonomy and would have collided with the
indent. It only names the taxonomy now, which a multi-taxonomy field needs most
on exactly the nested options it was being hidden from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`?fields=` could return a 500. The resource rejected the hierarchy fields
from the selection, and a request that asked for nothing else left an empty
list, which `AbstractAugmented::select()` reads as "everything" and falls back
to the full key set. `children` and `ancestors` reach each other, and nested
`AugmentedCollection`s don't inherit shallow nesting, so serializing them
recursed until the stack ran out. `?fields=entries` was enough to trigger it,
and that one predates any of this — it returns 200 on 6.x.

The rejection also ran against explicit selections, so `?fields=id` came back
with a `parent` nobody asked for, and it was gated on `hierarchical()` while
the augmented methods were gated on `hasStructure()`, leaving an orderable
taxonomy emitting `ancestors`, `children` and `is_root: true` on every term.

The terms endpoints now behave like the entries ones. Hierarchy fields are
opt-in rather than part of the default payload, `parent` comes back shallow
when it's asked for, and `children` and `ancestors` come back null: the flat
listing stays flat, and the tree endpoint is what traverses a structure, with
`fields` and `max_depth` of its own. A shallow child would only reach one
level anyway, and offering one would steer people away from the endpoint that
answers the question properly.

Shaping the values in `AugmentedTerm` rather than the resource means no field
is ever removed from a selection, so it can't empty and can't fall back. The
exclusions apply to the default key set only, and only on a taxonomy with a
structure, so a flat taxonomy keeps blueprint fields that happen to be named
`parent` or `children`. Antlers and GraphQL are untouched, since the guard is
`isApiRoute()`.

`ResolvesRequestedFields` comes along for two reasons: `?fields=` now works on
a single term, and looking a parent up re-queries it, which clears the columns
the original query selected. Reading the parameter off the request survives
that; reading it off the term did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The terms endpoints resolve it from the container, so an app that maps its own
over ours gets it there, but the tree constructed one directly and silently
ignored the override.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ReorderTermsController was a copy of ReorderEntriesController as it stood on
2026-08-13. #15238 fixed the original eight days later, and the 6.x merge
brought the fixed entries controller in alongside the unfixed copy.

Ports that fix. A positional write replaces zip(), which padded with null and
so silently dropped or duplicated branches, and a set-equality check now 409s
when the submitted ids aren't a rearrangement of the page being reordered.
Between them that covers short payloads, unknown slugs, duplicate ids and
out-of-range pages, all of which previously returned 200 and corrupted the
tree file. Also brings across #15238's toast change so the 409 message is
actually shown.

Adds min:1 to page and perPage, which #15238 did not have. Without it, page 0
yields a negative offset that slices from the end of the tree and then writes
negative keys onto it. That hole was found here and fixed for collections in
#15429.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It collides with the pre-existing LocalizedTerm::isRoot() (default-locale
meaning) and nothing internal ever reads the augmented value. depth == 1
is the existing idiom for top-level, already used on the collection/nav
side.
On a term page there's no collection in context, so Children::index()
built the handle "collection::" and Structure::ensureStructureExists()
threw CollectionNotFoundException('') trying to find a collection with
an empty handle. Entry pages never hit this because the collection is
always present in context.

This PR makes child terms a concept, which makes {{ children }} the
obvious thing to write on a term template, so it's what makes this
previously unreachable fatal reachable in practice. Guard the missing
collection and return [], matching what the collection path already
produces when there's no structure to build from.
valueRefersToTerm() slugged with the default "en" while the association
index and the terms store both slug with the item's language, so on a
non-English site a term rename silently failed to rewrite references to
it. It now goes through slugFromValue(), like everything else, using the
termLang() the fieldtype already uses when creating terms.

The language comes from the field's parent, which the reference updater
wasn't reliably supplying. Blueprint::fields() returns its cached Fields
before re-applying the parent, and the blueprint itself is shared between
items, so the parent stuck on whichever item the loop reached first.
Nested fields never had one at all. Point both at the item being updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 7 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>
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