diff --git a/content/collections/pages/collections.md b/content/collections/pages/collections.md
index 0f039a511..3899bb1cb 100644
--- a/content/collections/pages/collections.md
+++ b/content/collections/pages/collections.md
@@ -253,6 +253,16 @@ A structured collection will **not** have a maximum depth unless you set one, al
These reorderable entries have a max depth of 1.
+### Turning ordering off
+
+:::warning
+Switching **Orderable** back off **deletes the collection's tree immediately**, without a confirmation, and there's no undoing it.
+
+Your entries are untouched, but they stop being nested — so every nested entry's URL moves. An entry that lived at `/about/staff` falls back to whatever its collection's route produces without a `parent_uri`, and the old URL returns a 404. Set up [redirects](/routing#redirects) before you flip the switch on a live site.
+
+[Structured taxonomies](/taxonomies#turning-it-off) behave the same way.
+:::
+
### Default sort order in listings
For non-structured collections, you can choose which field and direction to sort the list of entries in the Control Panel by setting the `sort_by` and `sort_dir` variables in your collection.yaml. By default, the Title field will be used.
diff --git a/content/collections/pages/graphql.md b/content/collections/pages/graphql.md
index b19bea23a..f7bcd6035 100644
--- a/content/collections/pages/graphql.md
+++ b/content/collections/pages/graphql.md
@@ -237,6 +237,7 @@ Returns a [paginated](#pagination) list of [EntryInterface](#entry-interface) ty
| `page` | `Int` | The paginated page to be shown. Defaults to `1`.
| `filter` | `JsonArgument` | Narrows down the results based on [filters](#filtering).
| `sort` | `[String]` | [Sorts](#sorting) the results based on one or more fields and directions.
+| `with_descendants` | `Boolean` | Defaults to `true`. When filtering by a term on a [nestable taxonomy](/taxonomies#ordering-and-nesting), entries tagged with its descendant terms are included. Pass `false` to match only the exact term.
Example query and response:
diff --git a/content/collections/pages/rest-api.md b/content/collections/pages/rest-api.md
index d400f6764..2c0670671 100644
--- a/content/collections/pages/rest-api.md
+++ b/content/collections/pages/rest-api.md
@@ -63,10 +63,11 @@ You may send requests to the following endpoints:
- [Sites](#sites)
- [Collections](#collections) / [Collection](#collection)
- [Entries](#entries) / [Entry](#entry)
-- [Collection Tree](#collection-tree) / [Navigation Tree](#navigation-tree)
+- [Collection Tree](#collection-tree) / [Navigation Tree](#navigation-tree) / [Taxonomy Tree](#taxonomy-tree)
- [Navs](#navs) / [Nav](#nav)
- [Taxonomies](#taxonomies) / [Taxonomy](#taxonomy)
- [Taxonomy Terms](#taxonomy-terms) / [Taxonomy Term](#taxonomy-term)
+- [Taxonomy Term Entries](#taxonomy-term-entries)
- [Asset Containers](#asset-containers) / [Asset Container](#asset-container)
- [Assets](#assets) / [Asset](#asset)
- [Globals](#globals) / [Global](#global)
@@ -367,6 +368,12 @@ Gets entries within a collection.
If you are using [Multi-Site](/multi-site), the entries endpoint will serve from all sites at once. If needed, you can limit the fetched data to a specific site with the `site` query parameter (ie. `?site=fr`), or a `site` [filter](#filtering) (ie. `&filter[site]=fr`).
:::
+When you [filter](#filtering) by a term on a [nestable taxonomy](/taxonomies#ordering-and-nesting), entries tagged with that term's descendants are included. Add `?with_descendants=false` to match only the exact term.
+
+```url
+/api/collections/products/entries?filter[taxonomy:product_categories]=clothing&with_descendants=false
+```
+
## Entry
@@ -569,6 +576,72 @@ Gets a single taxonomy term.
}
```
+## Taxonomy Term Entries
+
+`GET` `/api/taxonomies/{taxonomy}/terms/{slug}/entries`
+
+Gets the entries tagged with a taxonomy term.
+
+``` json
+{
+ "data": [
+ {
+ "title": "My First Day"
+ }
+ ],
+ "links": {...},
+ "meta": {...}
+}
+```
+
+On a [nestable taxonomy](/taxonomies#ordering-and-nesting), the entries tagged with the term's descendants are included. Add `?with_descendants=false` to get only the entries tagged with this exact term.
+
+```url
+/api/taxonomies/product_categories/terms/clothing/entries?with_descendants=false
+```
+
+## Taxonomy Tree
+
+`GET` `/api/taxonomies/{taxonomy}/tree`
+
+Gets the term tree for a [structured taxonomy](/taxonomies#ordering-and-nesting). Returns a 404 if the taxonomy isn't structured.
+
+``` json
+{
+ "data": [
+ {
+ "term": {
+ "title": "Clothing",
+ "url": "/product-categories/clothing"
+ },
+ "depth": 1,
+ "children": [
+ {
+ "term": {
+ "title": "Shirts",
+ "url": "/product-categories/clothing/shirts"
+ },
+ "depth": 2,
+ "children": []
+ }
+ ]
+ }
+ ]
+}
+```
+
+### Params
+
+On this endpoint, the [fields](#selecting-fields) param will allow you to select fields within each `term` object. You may also set a `max_depth` to limit nesting depth, or `site` to choose the site.
+
+```url
+/api/taxonomies/{taxonomy}/tree?fields=title,url&max_depth=2&site=fr
+```
+
+:::warning
+Taxonomy trees are **not per-site**. The `site` param localizes each `term` payload — its title, slug, and URL — but the tree's shape and order are the same for every site. Requesting a site the taxonomy isn't available in returns a 404.
+:::
+
## Globals
`GET` `/api/globals`
diff --git a/content/collections/pages/structures.md b/content/collections/pages/structures.md
index 61a7dc7ba..556781e61 100644
--- a/content/collections/pages/structures.md
+++ b/content/collections/pages/structures.md
@@ -17,6 +17,7 @@ Every structure is a hierarchy of branches. What differs is *what the hierarchy
1. **Structured collections** — the tree *is* the content hierarchy. Nesting and order drive URLs (and sibling order). Your sitemap lives on the collection.
2. **Navigations** — the tree is a menu. Mix entry references, hard URLs, and text nodes. Position in the tree does **not** rewrite entry URLs.
+3. **Structured taxonomies** — the tree nests terms inside each other. Nesting and order drive term URLs, and entry queries for a term include its whole subtree. Covered in [Taxonomies](/taxonomies#ordering-and-nesting).
Same drag-and-drop UI. Same YAML tree shape. Different jobs.
@@ -44,13 +45,14 @@ Same drag-and-drop UI. Same YAML tree shape. Different jobs.
**Use both when** a pages collection owns the URLs and one or more navs compose what appears in chrome. That's normal — not overkill.
-| | Structured collection | Navigation |
-| --- | --- | --- |
-| Owns URLs | Yes — position drives routes | No — entries keep their collection URLs |
-| Entry once | Yes | No — can repeat |
-| Freeform links / text | Entry-link redirects (via collection settings) | Yes — URLs and text nodes |
-| Config lives in | The collection itself | `content/navigation` |
-| Tree lives in | `content/trees/collections` | `content/trees/navigation` |
+| | Structured collection | Navigation | Structured taxonomy |
+| --- | --- | --- | --- |
+| Owns URLs | Yes — position drives routes | No — entries keep their collection URLs | Yes — position drives term routes |
+| Item once | Yes | No — can repeat | Yes |
+| Freeform links / text | Entry-link redirects (via collection settings) | Yes — URLs and text nodes | No |
+| Config lives in | The collection itself | `content/navigation` | The taxonomy itself |
+| Tree lives in | `content/trees/collections` | `content/trees/navigation` | `content/trees/taxonomies` |
+| Tree per site | Yes | Yes | No — one tree, shared |
## Structured collections
@@ -109,6 +111,8 @@ Each page may have an optional `children` array which is itself another tree. Ne
_\* Text and link branches are only available in navs._
+In a taxonomy's tree, branches reference a term by slug with a `term` key instead — see [Taxonomies](/taxonomies#the-tree).
+
## Templating
Loop either kind of structure with the [nav tag](/tags/nav).
diff --git a/content/collections/pages/taxonomies.md b/content/collections/pages/taxonomies.md
index cb8a7d2a3..704ed37b8 100644
--- a/content/collections/pages/taxonomies.md
+++ b/content/collections/pages/taxonomies.md
@@ -65,6 +65,8 @@ For each taxonomy [assigned to a collection](#collections) you will also get the
- Accessible at `/{collection-url}/{taxonomy-slug}/{term-slug}` (eg. `/products/tags/t-shirts`)
- The `{collection_handle}/{taxonomy_handle}/show` view will be used. (eg. `products/tags/show.antlers.html`)
+If the taxonomy is [nestable](#nested-term-urls), term URLs include the slugs of the term's ancestors.
+
## Term values and slugs
A term **value** is how you might identify a term in your content. For example, “Star Wars”.
@@ -88,6 +90,179 @@ Titles are saved on a first-come, first-serve basis, which means consistency is
To further clarify, `Star wars`, `star wars`, `StAr WaRS`, and `star-wars` are all treated as the same term. If case-sensitivity is important, you can add a `title` field to the taxonomy blueprint.
+## Ordering and nesting
+
+Flick on the **Orderable** switch in the "Ordering & Nesting" area of a taxonomy's settings and you'll have a drag and drop UI in the control panel to order and nest the terms. The taxonomy is now "structured". Learn more about [structures](/structures).
+
+Existing terms are added to the tree in their current sort order, so turning the switch on doesn't rearrange anything on its own.
+
+### Constraining depth
+
+A structured taxonomy will **not** have a maximum depth unless you set one, allowing you to nest terms as deep as you like. Set the **Max Depth** option to limit this behavior. Setting it to `1` gives you a flat, reorderable list — order without nesting, and term URLs stay flat.
+
+``` yaml
+# content/taxonomies/product_categories.yaml
+title: 'Product Categories'
+structure:
+ max_depth: 3
+```
+
+:::tip
+Max depth is enforced on the server, not just in the tree UI. Nesting a term too deep — by dragging it, or by choosing a parent when creating one — will be rejected.
+:::
+
+### Nested term URLs
+
+Once a taxonomy is nestable (structured with a max depth other than `1`), the default term route gains a `{parent_uri}` segment:
+
+```url
+/{taxonomy-slug}/{parent_uri}/{term-slug}
+```
+
+So a `shirts` term nested under `clothing` lives at `/product-categories/clothing/shirts`. Root terms have an empty `parent_uri` and keep their existing URL.
+
+### The tree
+
+A taxonomy's tree is stored in a single file at `content/trees/taxonomies/{taxonomy_handle}.yaml`, and each branch references a term by slug.
+
+``` yaml
+tree:
+ -
+ term: clothing
+ children:
+ -
+ term: shirts
+ -
+ term: footwear
+```
+
+:::tip
+You *can* edit the tree in the file. You *shouldn't*, unless you enjoy YAML indentation as a hobby. The Control Panel's drag-and-drop UI is the move.
+:::
+
+### Multi-site
+
+**Taxonomy trees are not per-site.** There is one tree per taxonomy, shared by every site — which is why there's only one tree file, with no site directory. Shape and order are global. Titles, slugs, and therefore parent URIs *are* localized, exactly as they already were for terms.
+
+This is a **deliberate divergence from [collection structures](/collections#ordering)**, which do have a tree per site. A long-standing complaint about collection trees is that people want to arrange things once and have it apply everywhere, which is closer to how terms already work.
+
+Two consequences are worth stating outright, because both read the other way round at first glance:
+
+- **The site selector on a taxonomy switches which site the tree is _rendered_ in, not which tree you are editing.** It swaps the titles, slugs, and URLs shown on each branch. Dragging a term to a new position applies that move to every site. The same control on a collection means something different.
+- **The `site` parameter on the [tree endpoint](/rest-api#taxonomy-tree) localizes the term payloads, not the tree's shape or order.** The parameter reads as though it selects a tree; it doesn't.
+
+Branches reference a term by its slug in the default site, so renaming a slug in a secondary site never moves a term in the tree.
+
+### Nesting variables
+
+On a structured taxonomy, terms get these variables in addition to the usual ones.
+
+| Variable | Description |
+|----------|-------------|
+| `parent` | The term one level up, or `null` for a root term. |
+| `children` | The terms directly beneath this one. |
+| `ancestors` | Every term above this one, root first. |
+| `depth` | How deep the term sits in the tree. Root terms are `1`. |
+
+::tabs
+
+::tab antlers
+```antlers
+{{ ancestors }}
+ {{ title }} /
+{{ /ancestors }}
+
+
{{ title }}
+
+
+```
+::tab blade
+```blade
+@foreach ($ancestors as $ancestor)
+ {{ $ancestor->title }} /
+@endforeach
+
+{{ $title }}
+
+
+```
+::
+
+:::tip
+Terms don't have an `is_root` variable. To check whether you're on a top-level term, compare the depth — `{{ if depth == 1 }}` in Antlers, or `@if ($depth == 1)` in Blade. That's the same check Statamic uses internally.
+
+If you're writing PHP, don't reach for `LocalizedTerm::isRoot()` for this. It's unrelated, and answers a different question: whether the term is in the default site.
+:::
+
+### Descendant entries
+
+Filtering entries by a term on a nestable taxonomy **includes the entries of that term's whole subtree by default**. Asking for entries in `clothing` gets you everything tagged `shirts` and `shoes` too, which is almost always what you want from a category page.
+
+When it isn't, pass `with_descendants="false"` to limit the results to entries tagged with that exact term.
+
+::tabs
+
+::tab antlers
+```antlers
+{{ collection:products taxonomy:product_categories="clothing" with_descendants="false" }}
+ {{ title }}
+{{ /collection:products }}
+```
+::tab blade
+```blade
+
+ {{ $title }}
+
+```
+::
+
+The opt-out is available wherever you can filter entries by a term:
+
+| Where | How |
+|-------|-----|
+| [Collection tag](/tags/collection) | `with_descendants="false"` |
+| `{{ entries }}` on a [term route](#routing) | `with_descendants="false"` |
+| The `{{ query }}` tag, and any other tag pair that loops over a query builder | `with_descendants="false"` |
+| [REST API](/rest-api#entries) collection entries | `?with_descendants=false` |
+| [REST API](/rest-api#taxonomy-term-entries) term entries | `?with_descendants=false` |
+| [GraphQL](/graphql#entries-query) `entries` query | `with_descendants: false` |
+
+On a flat taxonomy — or one with a max depth of `1` — there are no descendants, so the parameter does nothing.
+
+:::tip
+In PHP, call `withTaxonomyDescendants(false)` on the [query builder](/content-queries).
+
+```php
+Entry::query()
+ ->whereTaxonomy('product_categories::clothing')
+ ->withTaxonomyDescendants(false)
+ ->get();
+```
+:::
+
+The `entries_count` variable counts descendants too, so it agrees with what `{{ entries }}` gives you. It has no opt-out — query the entries yourself if you need a count of only the directly tagged ones.
+
+### Turning it off
+
+:::warning
+Switching **Orderable** back off **deletes the taxonomy's tree file immediately**, without a confirmation, and there's no undoing it.
+
+Your terms are untouched, but they stop being nested — so every nested term's URL moves. A term that lived at `/product-categories/clothing/shirts` is now at `/product-categories/shirts`, and the old URL returns a 404. Set up [redirects](/routing#redirects) before you flip the switch on a live site.
+
+Structured collections behave the same way when you turn **Orderable** off.
+:::
+
## Templating
### Views
@@ -214,6 +389,8 @@ When on a [term route](#routing), you can list the entries by using an `entries`
```
::
+On a nestable taxonomy this includes the entries of the term's [descendants](#descendant-entries). Add `with_descendants="false"` to get only the entries tagged with this exact term.
+
## Search indexes
You can configure search indexes for your collections to improve the efficiency and relevancy of your users searches. Learn [how to connect indexes](/search#connecting-indexes).
diff --git a/content/collections/tags/collection.md b/content/collections/tags/collection.md
index ad17a1e4f..c2622648b 100644
--- a/content/collections/tags/collection.md
+++ b/content/collections/tags/collection.md
@@ -66,6 +66,11 @@ parameters:
type: mixed
description: 'A multitude of ways to filter by taxonomies. [More details](#taxonomies)'
required: false
+ -
+ name: with_descendants
+ type: 'boolean *true*'
+ description: 'When filtering by a term on a nestable taxonomy, entries tagged with its descendant terms are included. Set to `false` to match only the exact term. [More details](#descendant-terms)'
+ required: false
-
name: paginate
type: 'boolean|int *false*'
@@ -286,6 +291,32 @@ It is important that the collection has been [configured to use this taxonomy](/
There are several different ways to use this filtering parameter. They are explained in depth on the [Conditions page](/conditions#taxonomy-conditions).
:::
+#### Descendant terms
+
+If the taxonomy is [nestable](/taxonomies#ordering-and-nesting), filtering by a term also matches entries tagged with any of that term's descendants. Asking for `clothing` gets you the entries tagged `shirts` and `shoes` too.
+
+Pass `with_descendants="false"` to match only the entries tagged with the exact term.
+
+::tabs
+::tab antlers
+```antlers
+{{ collection:products taxonomy:product_categories="clothing" with_descendants="false" }}
+```
+
+::tab blade
+
+```blade
+
+
+
+```
+::
+
+On a flat taxonomy there are no descendants, so the parameter does nothing.
+
### Published Status
By default, only `published` entries are included. Entries can be queried against `any`, `draft`, `scheduled`, or `expired` status with [conditions](#conditions) on `status` like this:
diff --git a/content/collections/variables/entries_count.md b/content/collections/variables/entries_count.md
index b26fe9182..2de6dfab8 100644
--- a/content/collections/variables/entries_count.md
+++ b/content/collections/variables/entries_count.md
@@ -23,3 +23,5 @@ There are {{ $entries_count }} 'news' entries.
```html
There are 85 'news' entries.
```
+
+On a [nestable taxonomy](/taxonomies#ordering-and-nesting) this counts the entries tagged with the term's descendants too, so it agrees with what `{{ entries }}` returns. There's no way to opt out — [query the entries yourself](/taxonomies#descendant-entries) with `with_descendants="false"` if you need a count of only the directly tagged ones.