Skip to content

[6.x] Hierarchical Taxonomies - #15192

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

[6.x] Hierarchical Taxonomies#15192
jackmcdade wants to merge 43 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
Comment thread resources/js/components/inputs/relationship/Item.vue Outdated
Comment thread resources/js/components/inputs/relationship/Item.vue Outdated

@jaygeorge jaygeorge left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some minor text-contrast suggestions

@jaygeorge

jaygeorge commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Hey Jack – nice work 🖖 !

Couple of things I came across UI-wise…

Attaching Multiple Taxonomies

Attaching multiple taxonomies flattens the hierarchical view, even if they're both hierarchical, like this:

Single taxonomy attached, and we get the tree view (sidebar):

2026-08-14 at 11 48 24@2x

Multiple taxonomies attached, and everything is flattened:

2026-08-14 at 12 03 14@2x

I wondered, if all attached terms are hierarchical, what if we could still keep the hierarchical view?

This unites some of the interface work I've been doing on multisite grouping, where the heading terms in uppercase are not selectable:

2026-08-14 at 12 18 09@2x

Double Chevrons

I wonder if it would be cleaner/more consistent with the rest of the UI to use a single chevron to indicate the hierarchy.

Before:

2026-08-14 at 12 29 17@2x

After:

2026-08-14 at 12 29 41@2x

Hierarchy icons

The return-carriage icons feel a little brutalist to my eyes, I wonder if we could use something from the Streamline set. Here's an example:

Before:

2026-08-14 at 12 44 49@2x

After:

2026-08-14 at 12 44 00@2x

Minor issue with text-contrast

I've made some suggestions in the review to pass AA for text colors.

@jasonvarga jasonvarga left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a really well-put-together feature — the reuse of the existing structure/tree machinery, the opt-in design, and the test coverage (~2,800 lines across routing, tree CRUD, grafting, reference updates, GraphQL, REST, scaffolding, permissions) are all great. CI is green and the branch merges cleanly.

Requesting changes for one regression and one likely multisite gap, both commented inline:

  1. Flat-taxonomy values containing / get corrupted by the new reference-update logic in Terms::replaceValuesInArray()/valueRefersToTerm(). This needs to be gated to hierarchical taxonomies — details inline.
  2. parent/depth params on the {{ taxonomy }} tag filter by default-locale slugs, which likely returns nothing on sites with localized term slugs.

The remaining inline comments are smaller notes/nits that don't block on their own.

Comment thread src/Fieldtypes/Terms.php Outdated
Comment thread src/Tags/Taxonomy/Terms.php Outdated
Comment thread src/Http/Controllers/CP/Taxonomies/TermsController.php
Comment thread src/Http/Controllers/CP/Taxonomies/TaxonomyTreeController.php
Comment thread src/Http/Controllers/CP/Taxonomies/TaxonomyTreeController.php
Comment thread resources/js/components/ui/Combobox/Combobox.vue
Comment thread resources/js/components/inputs/relationship/SelectField.vue Outdated
Comment thread src/Taxonomies/EnsuresTermPaths.php Outdated
Comment thread src/Stache/Repositories/TaxonomyTreeRepository.php
Comment thread lang/en/validation.php Outdated
jackmcdade and others added 11 commits August 17, 2026 16:16
Flat values like AC/DC were being treated as segments, so renaming or deleting an unrelated `dc` term silently corrupted them.

Co-authored-by: Cursor <cursoragent@cursor.com>
parent/depth filtered by default-locale slugs after the query was already scoped to the current site, so localized terms returned nothing.

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