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
Installs: 1
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
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
- 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 - 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 - Twig integration —
render_menu()function with fully customisable templates - 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 |
| twig/twig | ^3.0 |
Installation
composer require chamber-orchestra/menu-bundle
Register the bundle
// config/bundles.php return [ // ... ChamberOrchestra\MenuBundle\ChamberOrchestraMenuBundle::class => ['all' => true], ];
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']) ->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 | Description |
|---|---|---|
label |
string |
Display text; falls back to item name if absent (LabelExtension) |
route |
string |
Route name; generates uri and appends to routes (RoutingExtension) |
route_params |
array |
Route parameters passed to the URL generator (RoutingExtension) |
route_type |
int |
UrlGeneratorInterface::ABSOLUTE_PATH (default) or ABSOLUTE_URL (RoutingExtension) |
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) |
badge |
int|\Closure |
Badge count; resolved post-cache by BadgeExtension (a runtime extension); stored in extras['badge'] |
attributes |
array |
HTML attributes merged onto the rendered element (CoreExtension) |
extras |
array |
Arbitrary extra data attached to the item (CoreExtension) |
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 |
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.
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 ], ]);
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.
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 %}
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 IconExtension implements ExtensionInterface { public function buildOptions(array $options): array { $options['attributes']['data-icon'] ??= $options['icon'] ?? null; unset($options['icon']); return $options; } }
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()); } } }
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 |
Twig Reference
{# Renders a navigation using the given template #} {{ render_menu('App\\Navigation\\MyNavigation', 'nav/my.html.twig') }} {# With extra options passed to build() #} {{ render_menu('App\\Navigation\\MyNavigation', 'nav/my.html.twig', {locale: app.request.locale}) }}
Template variables:
| 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.