MenuTreeLoader service is responsible for turning a flat database table into the nested structure used by the Twig template and the JSON API.
Two-query loading
Loading a menu tree requires exactly two SQL queries — one for theMenu row and one for all of its MenuItem rows. There is no N+1 problem regardless of how deep the tree is.
The two-query path is the fast path. A legacy path using the Doctrine ORM collections exists as a fallback but is not used in normal operation.
position ASC within each parent. The PHP assembly step re-sorts children by position after building the map, so the final tree is always correctly ordered.
Tree assembly
Once the flat list of items is loaded, the tree is built in PHP in two passes:Permission filtering
Each item is checked against the menu’s
MenuPermissionCheckerInterface. Items that fail canView() are discarded before the tree is wired up.First pass — create node map
Every visible item is inserted into an in-memory map keyed by its database ID:
Second pass — wire parent/child references
Each item’s
parent is checked. If the parent is in the map (visible), the node is appended to the parent’s children array using PHP references so updates are shared. Items whose parent was filtered out are promoted to root level.Prune empty parents
Parents that originally had children (before filtering) but now have none visible are removed. See Permissions — pruning empty parents.
Node structure
The return value ofMenuTreeLoader::loadTree() is a list<array> of root nodes. Each node has this shape:
menuTree.
Item types
EachMenuItem has an itemType that controls how it is rendered:
| Type | Rendered as | Can have children |
|---|---|---|
link | <a href="..."> | Yes |
section | <span> label (no link) | Yes |
divider | <hr> | No |
link item with children renders a clickable link and (optionally) an expand/collapse chevron when nestedCollapsible is enabled on the menu. A section item renders only a label; the chevron is its only interactive element.
Depth limit
ThedepthLimit field on Menu restricts how many levels the Twig template renders.
| Value | Effect |
|---|---|
null | Unlimited depth (render everything) |
1 | Root items only — children are not rendered |
2 | Root + one level of children |
n | Root + n - 1 levels of children |
depthLimit is enforced at render time by the Twig template, not during tree loading. The full tree is always loaded into memory; the template simply stops recursing past the limit.Current route detection
TheCurrentRouteTreeDecorator service adds two boolean keys to each tree node after the tree is assembled:
| Node key | When true | CSS class applied (from Menu entity) |
|---|---|---|
isCurrent | The item’s path and its query params are a subset of the current request | classCurrent on the <a> |
hasCurrentInBranch | This node or any of its descendants has isCurrent = true | classBranchExpanded on the <li> |
hasCurrentInBranch propagates up the tree so the full path from root to the active item can be highlighted and kept expanded.
isCurrent uses path matching with query subset checking: the link’s path must equal the request path, and every query parameter stored on the link must be present in the request with the same value. The request may have additional query parameters without affecting the match.PSR-6 cache
If a PSR-6CacheItemPoolInterface is wired to the MenuTreeLoader, the raw query results are serialized and cached. The cache key is derived from:
- Cache hit path
- Cache miss path
- Deserialize the raw SQL rows from cache.
- Hydrate
MenuandMenuItemobjects from the rows (no DB queries). - Resolve config and permission checker.
- Build and prune the tree.
- Return result.
Summary
Menu entities
The fields on Menu and MenuItem that drive tree loading.
Permissions
How items are filtered and empty parents pruned.
Context resolution
How the right Menu row is selected before loading begins.
Twig functions
How to render the tree in your templates.