[6.x] Hierarchical Taxonomies - #15192
Draft
jackmcdade wants to merge 59 commits into
Draft
Conversation
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>
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
dismissed
their stale review
August 31, 2026 18:40
This round of changes have been addressed
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
marked this pull request as draft
August 31, 2026 20:49
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
(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
A few constraints worth knowing:
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 }}.parentwithoutdepthis direct children; adddepthto go further.depthalone 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
with_descendants, the parent field, and the "typed paths don't re-parent" rule.Test plan
{{ structure:taxonomy:… }}and collection/entry filters include descendants by defaultIssues
Closes statamic/ideas#92
Closes statamic/ideas#264
Closes statamic/ideas#1053
Closes #2334.
Related: #74, statamic/ideas#839, statamic/ideas#580.