chamber-orchestra / menu-bundle
Symfony 8 navigation menu bundle with fluent tree builder, route-based active-item matching, role-based access control, runtime extensions for dynamic badges, PSR-6 tag-aware caching, and Twig rendering
Package info
github.com/chamber-orchestra/menu-bundle
Type:symfony-bundle
pkg:composer/chamber-orchestra/menu-bundle
Requires
- php: ^8.5
- ext-ds: *
- doctrine/collections: ^2.0 || ^3.0
- symfony/cache-contracts: ^3.4
- symfony/config: ^8.0
- symfony/dependency-injection: ^8.0
- symfony/http-foundation: ^8.0
- symfony/http-kernel: ^8.0
- symfony/routing: ^8.0
- symfony/security-core: ^8.0
- symfony/translation-contracts: ^3.4
- twig/twig: ^3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^13.0
- symfony/cache: ^8.0
README
A Symfony 8 bundle for building navigation menus, sidebars, and breadcrumbs — fluent tree builder, route-based active-item matching, role-based access control, runtime extensions for dynamic badges, PSR-6 tag-aware caching, and Twig rendering.
Features
- Fluent builder API —
add(),children(),end()for deeply-nested trees - Route-based matching —
RouteVotermarks the current item and its ancestors active; route values are treated as regex patterns - Custom voters — implement
VoterInterfaceto add custom matching logic alongside the built-inRouteVoter - Role-based access —
Accessorgates items by Symfony security roles; results are memoized per request - PSR-6 caching —
AbstractCachedNavigationcaches the item tree for 24 h with tag-based invalidation - Runtime extensions —
RuntimeExtensionInterfaceruns post-cache on every request for fresh dynamic data without rebuilding the tree - Badge support —
BadgeExtensionresolvesintand\Closurebadges at runtime; implementRuntimeExtensionInterfacefor service-injected dynamic badges - Counters —
CounterExtensionresolves multiple named counters (intor\Closure) at runtime - Icons —
IconExtensionmoves theiconoption intoextras['icon']at build time - Dividers —
DividerExtensionmarks items as dividers viaextras['divider']at build time - Visibility —
VisibilityExtensionresolvesvisible(bool or\Closure) at runtime intoextras['visible'] - Label translation —
TranslationExtensiontranslates item labels via Symfony'sTranslatorInterface(auto-disabled when no translator is available) - Breadcrumbs —
menu_breadcrumbs()Twig function returns the path from root to the current item - Raw tree access —
menu_get()Twig function returns the rootItemwithout rendering - Twig integration —
render_menu()function with fully customisable templates and optional default template - Bundle configuration — centralised config for default template, translation domain, and cache namespace
- Extension system — build-time
ExtensionInterfacefor cached option enrichment, runtimeRuntimeExtensionInterfacefor post-cache processing - DI autoconfiguration — implement an interface, done; no manual service tags required
Requirements
| Dependency | Version |
|---|---|
| PHP | ^8.5 |
| ext-ds | * |
| doctrine/collections | ^2.0 || ^3.0 |
| symfony/* | ^8.0 |
| symfony/translation-contracts | ^3.4 |
| twig/twig | ^3.0 |
Installation
composer require chamber-orchestra/menu-bundle
Register the bundle
// config/bundles.php return [ // ... ChamberOrchestra\MenuBundle\ChamberOrchestraMenuBundle::class => ['all' => true], ];
Configuration
# config/packages/chamber_orchestra_menu.yaml chamber_orchestra_menu: default_template: ~ # ?string — fallback template for render_menu() translation: domain: 'messages' # string — default translation domain for labels cache: namespace: '$NAVIGATION$' # string — cache key namespace prefix
All values are optional with sensible defaults.
Quick Start
1. Create a navigation class
<?php namespace App\Navigation; use ChamberOrchestra\MenuBundle\Menu\MenuBuilder; use ChamberOrchestra\MenuBundle\Navigation\AbstractCachedNavigation; final class SidebarNavigation extends AbstractCachedNavigation { public function build(MenuBuilder $builder, array $options = []): void { $builder ->add('dashboard', ['label' => 'Dashboard', 'route' => 'app_dashboard', 'icon' => 'fa-home']) ->add('blog', ['label' => 'Blog']) ->children() ->add('posts', ['label' => 'Posts', 'route' => 'app_blog_post_index']) ->add('tags', ['label' => 'Tags', 'route' => 'app_blog_tag_index']) ->end() ->add('settings', ['label' => 'Settings', 'route' => 'app_settings', 'roles' => ['ROLE_ADMIN']]); } }
The class is auto-tagged as a navigation service — no YAML/XML service definition needed.
2. Create a Twig template
{# templates/nav/sidebar.html.twig #} {% for item in root %} {% if accessor.hasAccess(item) %} <a href="{{ item.uri }}" class="{{ matcher.isCurrent(item) ? 'active' : '' }}"> {{ item.label }} </a> {% endif %} {% endfor %}
3. Render in Twig
{{ render_menu('App\\Navigation\\SidebarNavigation', 'nav/sidebar.html.twig') }}
Item Options
Options are passed as the second argument to MenuBuilder::add():
| Option | Type | Extension | Description |
|---|---|---|---|
label |
string |
LabelExtension |
Display text; falls back to item name if absent |
route |
string |
RoutingExtension |
Route name; generates uri and appends to routes |
route_params |
array |
RoutingExtension |
Route parameters passed to the URL generator |
route_type |
int |
RoutingExtension |
UrlGeneratorInterface::ABSOLUTE_PATH (default) or ABSOLUTE_URL |
routes |
array |
— | Additional routes that activate this item (supports regex) |
uri |
string |
— | Raw URI; set directly if not using route |
roles |
array |
— | Security roles all required to display the item (AND logic) |
icon |
string |
IconExtension |
Icon identifier; moved to extras['icon'] at build time |
divider |
bool |
DividerExtension |
When true, marks the item as a divider via extras['divider'] |
badge |
int|\Closure |
BadgeExtension |
Badge count; resolved post-cache; stored in extras['badge'] |
counters |
array<string, int|\Closure> |
CounterExtension |
Named counters; resolved post-cache; stored in extras['counters'] |
visible |
bool|\Closure |
VisibilityExtension |
Visibility flag; resolved post-cache; stored in extras['visible'] |
translation_domain |
string |
TranslationExtension |
Per-item translation domain override |
attributes |
array |
CoreExtension |
HTML attributes merged onto the rendered element |
extras |
array |
CoreExtension |
Arbitrary extra data attached to the item |
Section items
Pass section: true to mark an item as a non-linkable section heading:
$builder ->add('main', ['label' => 'Main Section'], section: true) ->children() ->add('dashboard', ['label' => 'Dashboard', 'route' => 'app_dashboard']) ->end();
Caching
Navigation classes form a hierarchy — extend the one that fits your use case:
AbstractNavigation (base: 0 TTL, no tags)
└── AbstractCachedNavigation (24 h TTL, 'chamber_orchestra_menu' tag)
| Base class | TTL | Tags | Use case |
|---|---|---|---|
AbstractCachedNavigation |
24 h | chamber_orchestra_menu |
Menu structures (recommended) |
AbstractNavigation |
0 | none | Base class, no caching across requests |
ClosureNavigation |
0 (configurable) | none | Quick one-off menus; optionally cacheable |
All navigations are deduped within the same request via NavigationFactory. When a PSR-6 CacheInterface (tag-aware) is wired in, AbstractCachedNavigation stores the tree across requests. Without one, an in-memory ArrayAdapter is used automatically.
Dynamic data (badges, counters) does not require sacrificing the cache — use runtime extensions instead.
<?php namespace App\Navigation; use ChamberOrchestra\MenuBundle\Menu\MenuBuilder; use ChamberOrchestra\MenuBundle\Navigation\AbstractCachedNavigation; use Symfony\Contracts\Cache\ItemInterface; final class MainNavigation extends AbstractCachedNavigation { public function __construct(private readonly string $locale) { parent::__construct(); } // Override the cache key if you need per-locale or per-user trees public function getCacheKey(): string { return 'main_nav_'.$this->locale; } // Fine-tune TTL and tags public function configureCacheItem(ItemInterface $item): void { $item->expiresAfter(3600); $item->tag(['navigation', 'main_nav']); } public function build(MenuBuilder $builder, array $options = []): void { $builder->add('home', ['label' => 'Home', 'route' => 'app_home']); } }
The default cache key is the fully-qualified class name; default TTL is 24 hours; default tag is chamber_orchestra_menu.
ClosureNavigation caching
ClosureNavigation is uncached by default (TTL 0), but you can opt in to caching by providing a unique cacheKey and a ttl:
use ChamberOrchestra\MenuBundle\Navigation\ClosureNavigation; // Uncached (default) $nav = new ClosureNavigation(function (MenuBuilder $builder): void { $builder->add('home', ['label' => 'Home', 'route' => 'app_home']); }); // Cached for 1 hour $nav = new ClosureNavigation( callback: function (MenuBuilder $builder): void { $builder->add('home', ['label' => 'Home', 'route' => 'app_home']); }, cacheKey: 'sidebar_nav', ttl: 3600, );
Each cached ClosureNavigation must have a unique cacheKey — without one, all instances share the same key and overwrite each other.
The cache namespace prefix defaults to $NAVIGATION$ and can be changed via bundle configuration.
Route Matching
RouteVoter reads _route from the current request and compares it against each item's routes array. Route values are treated as regex patterns, so you can highlight an entire section:
$builder->add('blog', [ 'label' => 'Blog', 'route' => 'app_blog_post_index', 'routes' => [ ['route' => 'app_blog_.*'], // all blog_* routes keep the item active ], ]);
Custom Voters
Implement VoterInterface to add custom matching logic. Custom voters are auto-tagged and used alongside RouteVoter:
<?php namespace App\Navigation\Voter; use ChamberOrchestra\MenuBundle\Matcher\Voter\VoterInterface; use ChamberOrchestra\MenuBundle\Menu\Item; final class QueryParamVoter implements VoterInterface { public function __construct(private readonly RequestStack $requestStack) { } public function matchItem(Item $item): ?bool { // Return true to mark current, false to reject, null to abstain $request = $this->requestStack->getCurrentRequest(); if (null === $request) { return null; } $expectedTab = $item->getOption('tab'); if (null === $expectedTab) { return null; } return $request->query->get('tab') === $expectedTab ? true : null; } }
Role-Based Access
The accessor variable is injected into every rendered template. Call hasAccess(item) to gate visibility:
{% if accessor.hasAccess(item) %}
<li>...</li>
{% endif %}
hasAccess() returns true when:
- the item has no
rolesrestriction, or - the current user has all of the required roles (AND logic).
hasAccessToChildren(collection) returns true when any child in the collection is accessible.
Icons
The built-in IconExtension moves the icon option into extras['icon'] at build time, so the value is cached with the tree:
$builder->add('dashboard', ['label' => 'Dashboard', 'icon' => 'fa-home']);
In Twig:
{% set icon = item.option('extras').icon|default(null) %}
{% if icon %}
<i class="{{ icon }}"></i>
{% endif %}
Dividers
The DividerExtension marks items as visual dividers at build time:
$builder->add('separator', ['divider' => true]);
In Twig:
{% if item.option('extras').divider|default(false) %}
<hr/>
{% else %}
<a href="{{ item.uri }}">{{ item.label }}</a>
{% endif %}
Visibility
The VisibilityExtension resolves the visible option at runtime. Pass a bool or a \Closure:
$builder ->add('beta_feature', ['label' => 'Beta', 'visible' => false]) ->add('promo', ['label' => 'Promo', 'visible' => fn (): bool => $this->featureFlags->isEnabled('promo')]);
In Twig:
{% if item.option('extras').visible|default(true) %}
<a href="{{ item.uri }}">{{ item.label }}</a>
{% endif %}
Badges
Via the badge option
The built-in BadgeExtension is a runtime extension that resolves the badge item option on every request. Pass an int or a \Closure:
$builder ->add('news', ['label' => 'News', 'badge' => 3]) ->add('inbox', ['label' => 'Inbox', 'badge' => fn (): int => $this->messages->countUnread()]);
Via a custom runtime extension
For service-injected dynamic data, implement RuntimeExtensionInterface. The tree stays cached; the extension runs post-cache on every request:
<?php namespace App\Navigation\Extension; use App\Repository\MessageRepository; use ChamberOrchestra\MenuBundle\Factory\Extension\RuntimeExtensionInterface; use ChamberOrchestra\MenuBundle\Menu\Item; final class InboxBadgeExtension implements RuntimeExtensionInterface { public function __construct(private readonly MessageRepository $messages) { } public function processItem(Item $item): void { if ('inbox' === $item->getName()) { $item->setExtra('badge', $this->messages->countUnread()); } } }
In Twig, read the badge via item.badge:
{% if item.badge is not null %}
<span class="badge">{{ item.badge }}</span>
{% endif %}
Counters
The CounterExtension resolves multiple named counters at runtime. Pass a array<string, int|\Closure>:
$builder->add('orders', [ 'label' => 'Orders', 'counters' => [ 'pending' => fn (): int => $this->orders->countPending(), 'shipped' => fn (): int => $this->orders->countShipped(), ], ]);
In Twig:
{% set counters = item.option('extras').counters|default({}) %}
{% for name, count in counters %}
<span class="counter counter--{{ name }}">{{ count }}</span>
{% endfor %}
Label Translation
The TranslationExtension translates item labels using Symfony's TranslatorInterface. It runs at runtime (post-cache) so translated labels are always fresh.
- Default domain: configured via
chamber_orchestra_menu.translation.domain(defaults tomessages) - Per-item override: set the
translation_domainoption on an item - Empty labels are skipped
- Auto-disabled when no
TranslatorInterfaceservice is available in the container
$builder ->add('scores', ['label' => 'nav.scores']) ->add('rehearsals', ['label' => 'nav.rehearsals', 'translation_domain' => 'navigation']);
Factory Extensions
Build-time extensions (cached)
Implement ExtensionInterface to enrich item options before the Item is created. Results are cached with the tree. Extensions are auto-tagged and sorted by priority (higher runs first; CoreExtension runs last at -10):
use ChamberOrchestra\MenuBundle\Factory\Extension\ExtensionInterface; final class TooltipExtension implements ExtensionInterface { public function buildOptions(array $options): array { if (isset($options['tooltip'])) { $extras = $options['extras'] ?? []; $extras['tooltip'] = $options['tooltip']; $options['extras'] = $extras; unset($options['tooltip']); } return $options; } }
Built-in build-time extensions
| Extension | Option | Stored in | Description |
|---|---|---|---|
RoutingExtension |
route, route_params, route_type |
uri, routes |
Generates URI from route |
LabelExtension |
label |
label |
Falls back to item name |
IconExtension |
icon |
extras['icon'] |
Icon identifier |
DividerExtension |
divider |
extras['divider'] |
Divider flag |
CoreExtension |
attributes, extras |
— | Defaults (priority -10, runs last) |
Runtime extensions (post-cache)
Implement RuntimeExtensionInterface to apply fresh data after every cache fetch. processItem() is called on every Item in the tree:
use ChamberOrchestra\MenuBundle\Factory\Extension\RuntimeExtensionInterface; use ChamberOrchestra\MenuBundle\Menu\Item; final class NotificationBadgeExtension implements RuntimeExtensionInterface { public function __construct(private readonly NotificationRepository $notifications) {} public function processItem(Item $item): void { if ('alerts' === $item->getName()) { $item->setExtra('badge', $this->notifications->countUnread()); } } }
Built-in runtime extensions
| Extension | Option | Stored in | Description |
|---|---|---|---|
BadgeExtension |
badge |
extras['badge'] |
Single badge count (int|\Closure) |
CounterExtension |
counters |
extras['counters'] |
Named counters map |
VisibilityExtension |
visible |
extras['visible'] |
Visibility flag (bool|\Closure) |
TranslationExtension |
translation_domain |
label (via setLabel()) |
Translates labels |
DI Autoconfiguration
Implement an interface and you're done — no manual service tags required:
| Interface | Auto-tag |
|---|---|
NavigationInterface |
chamber_orchestra_menu.navigation |
ExtensionInterface |
chamber_orchestra_menu.factory.extension |
RuntimeExtensionInterface |
chamber_orchestra_menu.factory.runtime_extension |
VoterInterface |
chamber_orchestra_menu.matcher.voter |
Breadcrumbs
The menu_breadcrumbs() Twig function returns the path from root to the current item (root excluded):
{% set crumbs = menu_breadcrumbs('App\\Navigation\\SidebarNavigation') %}
<nav aria-label="breadcrumb">
<ol>
{% for item in crumbs %}
<li{% if loop.last %} class="active"{% endif %}>
{% if not loop.last and item.uri %}
<a href="{{ item.uri }}">{{ item.label }}</a>
{% else %}
{{ item.label }}
{% endif %}
</li>
{% endfor %}
</ol>
</nav>
Returns an empty array when no item is currently active.
Twig Reference
{# Renders a navigation using the given template #} {{ render_menu('App\\Navigation\\MyNavigation', 'nav/my.html.twig') }} {# Uses the default_template from bundle config (template argument omitted) #} {{ render_menu('App\\Navigation\\MyNavigation') }} {# With extra options passed to the template #} {{ render_menu('App\\Navigation\\MyNavigation', 'nav/my.html.twig', {locale: app.request.locale}) }} {# Get the raw Item tree without rendering #} {% set root = menu_get('App\\Navigation\\MyNavigation') %} {# Get the breadcrumb path to the current item #} {% set crumbs = menu_breadcrumbs('App\\Navigation\\MyNavigation') %}
Template variables (available inside render_menu templates):
| Variable | Type | Description |
|---|---|---|
root |
Item |
Root item — iterate to get top-level items |
matcher |
Matcher |
Call isCurrent(item) / isAncestor(item) |
accessor |
Accessor |
Call hasAccess(item) / hasAccessToChildren(collection) |
Testing
composer install
composer test
License
MIT. See LICENSE.