Skip to content

[6.x] Hierarchical Taxonomies - #15192

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

[6.x] Hierarchical Taxonomies#15192
jackmcdade wants to merge 59 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
jackmcdade and others added 9 commits August 17, 2026 16:19
A rejected re-parent was returning 422 after the rest of the term had already been mutated.

Co-authored-by: Cursor <cursoragent@cursor.com>
Hitting the tree endpoint for a flat taxonomy errored on a null structure instead of 404ing like the API.

Co-authored-by: Cursor <cursoragent@cursor.com>
A tree that exceeded max depth still deleted terms, then rejected the save.

Co-authored-by: Cursor <cursoragent@cursor.com>
Parsing hint strings on ' • ' and ' » ' broke titles that contained those delimiters and missed the multi-taxonomy badge branch.

Co-authored-by: Cursor <cursoragent@cursor.com>
It returned the same string as the generic taggable placeholder.

Co-authored-by: Cursor <cursoragent@cursor.com>
A mid-path create denial left orphan terms, and each segment saved the tree file separately.

Co-authored-by: Cursor <cursoragent@cursor.com>
Fresh trees used sites()->first() as their locale, so find() missed them when the taxonomy omitted the default site.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
A rejected max-depth graft on store still persisted the term, and structure from=\"\" missed localized slugs the taxonomy tag now handles.

Co-authored-by: Cursor <cursoragent@cursor.com>
@jasonvarga
jasonvarga dismissed their stale review August 31, 2026 18:40

This round of changes have been addressed

jasonvarga and others added 2 commits August 31, 2026 15:31
Without these, apps with `cache.serializable_classes` set (the current
skeleton ships it as `false`) get a __PHP_Incomplete_Class back out of the
Stache cache for any hierarchical taxonomy, throwing a 500 on term URLs and
the CP tree view.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s_root on flat taxonomies

AugmentedTerm's hierarchy methods matched any blueprint field using those
handles via method_exists, even when the taxonomy has no structure. Gate
them on hasStructure() and fall back to the blueprint value otherwise, and
stop TermResource from unconditionally stripping parent/children/ancestors
for non-hierarchical taxonomies.
@jasonvarga
jasonvarga marked this pull request as draft August 31, 2026 20:49
jasonvarga and others added 11 commits August 31, 2026 17:02
Route every place that splits or joins a typed term path (like "animals/cat")
through EnsuresTermPaths::DELIMITER instead of hard-coding "/", so the
character can be changed in one place. Pure refactor, no behaviour change.
Terms::createTermFromString() checked for the path delimiter before looking
up an existing term, so on a structured taxonomy you could never select a
term whose title contains the delimiter (e.g. "AC/DC", "TCP/IP"). Typing
"AC/DC" would slug to two nested terms "ac" -> "dc" instead of matching an
existing "acdc" term, even one created through the CP term form.

Flip the order so a whole-string slug match wins over path parsing. This
makes creating the term via the CP a working escape hatch. The trade-off is
that a path like "animals/cat" could occasionally match an unrelated
existing term (e.g. "animalscat") instead of creating a nested path; this is
accepted as low-probability and covered by a test.
"/" is common in real term names (AC/DC, TCP/IP, 24/7, km/h) whereas ">" is
close to nonexistent, and "Clothing > Shirts" reads as the universal
breadcrumb convention rather than a URL path. Mirrors the change into the
frontend, which hard-coded "/" separately since the PHP constant doesn't
reach JS. URI generation (parent_uri, route patterns) is unaffected — that's
URL structure, unrelated to field input syntax.

Verified empirically: Symfony's YAML dumper quotes values starting with ">"
(its folded block-scalar indicator), so storage round-trips cleanly; JSON
encoding doesn't escape ">"; and a raw stored path value survives
augment() -> Antlers rendering intact.
The store goes back to indexing only. A stored value is slugged whole, so
the delimiter is an ordinary character there — it stays an input convention
for the CP terms field, which is now the only caller of ensure().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Augmentation no longer extracts a leaf from a delimited value, and renaming
or deleting a term no longer rewrites individual segments inside one. Both
made a stored path a second way to spell a slug; the slug is the identifier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Statamic 6.0 deliberately removed the equivalent field from entries in
#11506: "Most people use the tree view anyways, and the special parent
field has been the cause of numerous bugs, so we've decided to get rid
of it in Statamic 6." That change also shipped an update script which
deletes a `parent` field out of user blueprints on upgrade to 6.0.

Reintroducing the same field for terms in the same major version is hard
to justify. The rationale applies identically here -- this branch ships a
full term tree with drag-and-drop reordering -- and a term blueprint that
already declares its own `parent` field has no migration path, since
RemoveParentField only sweeps collections.

It was also actively harmful. The field was injected only when the
blueprint didn't already declare `parent`, but the controller pulled
`parent` out of the submitted values unconditionally. A blueprint's own
`parent` field was therefore silently hijacked: the author's value never
reached the term, the response still reported a successful save, and if
the value happened to match a term slug the term was silently re-parented
and the tree file rewritten. An ordinary text field could restructure the
taxonomy.

So reparenting is a tree-only operation again, and the publish form shows
no ancestry at all -- no field, and no breadcrumb trail either, because
entries don't have one and parity with entries is the point. Create-time
parenting still works off the `_parent` request param, which is exactly
the carve-out entries kept; its max-depth check now runs before the term
is saved rather than letting the graft fail once the term already exists.

Two things go with it. The `exclusions` config on the terms fieldtype was
added solely to feed this field its self-and-descendants list and had no
other producer, so it's removed and the fieldtype matches the entries and
users fieldtypes again. The `parent_cannot_be_descendant` validation
string is no longer reachable and is removed too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The condition couldn't tell an explicit "default" mode from an unset one. The CP omits the key when the default mode is chosen, so users could never actually get the default mode on a hierarchical taxonomy.

Collection::ensureTaxonomyFields() already sets mode to select explicitly anyway for automatically added taxonomy fields.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
None of the existing tests were multisite, so nothing pinned the
localization of terms returned by `structure { tree(site: "...") }`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TaxonomyStructureType extended Rebing's Type directly and reimplemented
fields(), the resolver and a tree builder, duplicating StructureType.
CollectionStructureType is 17 lines extending the shared base; this makes
the taxonomy one match.

TreeBuilder already understands taxonomy structures, so the shared path
builds the same tree. The only thing the duplicate did that it couldn't
was hand the localized term to the branch type -- hydrateTaxonomyPage()
was already resolving that term to copy its title and url, then throwing
it away, so it now returns it and toTree() puts it on the branch. That
drops a redundant Term::find() per branch as well.

The term goes on the branch rather than onto Page, which is coupled to
Entry to the point of implementing its contract. Tags\Structure and
BulkAugmentor only read the page and children keys, so the extra key is
inert for existing consumers.

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