chamber-orchestra/menu-bundle
Composer 安装命令:
composer require 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
关键字:
README 文档
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.
chamber-orchestra/menu-bundle 适用场景与选型建议
chamber-orchestra/menu-bundle 是一款 基于 PHP 开发的 Composer 扩展包,目前已累计 765 次下载、GitHub Stars 达 0, 最近一次更新时间为 2026 年 02 月 20 日, 在 PHP 生态内属于活跃度较高的组件。
它主要适用于以下技术方向: 「symfony」 「php」 「authorization」 「menu」 「twig」 「caching」 等业务场景。在实际项目中,围绕这些方向常见需要落地的问题包括:接口对接、性能调优、并发安全、与既有框架(Laravel / ThinkPHP / Yii / Webman 等)的兼容适配,以及生产环境的日志埋点与稳定性保障。
我们在过去多个企业项目中使用过 chamber-orchestra/menu-bundle 或与其功能相近的方案,如果你在选型或落地过程中遇到问题,例如 版本兼容、二次改造、私有化封装、与内部系统对接、生产 BUG 排查,欢迎联系我们协助评估。
基于 chamber-orchestra/menu-bundle 在你已有业务上做功能扩展、字段裁剪、UI 适配、与内部账号 / 权限 / 日志系统的深度对接。
线上偶发问题、内存泄漏、慢查询、并发异常等排查修复;针对高流量场景做缓存、队列、索引层面的调优。
承接完整的项目从需求 → 设计 → 开发 → 上线 → 长期运维;也可按月提供技术保姆服务。
与 chamber-orchestra/menu-bundle 相关的其它包
同方向 / 同关键字的高下载量 PHP Composer 包推荐,方便对比选型:
Ory-Hydra OAuth 2.0 Client Provider for The PHP League OAuth2-Client
The bundle for easy using json-rpc api on your project
A lightweight and powerful OAuth 2.0 authorization and resource server library with support for all the core specification grants. This library will allow you to secure your API with OAuth and allow your applications users to approve apps that want to access their data from your API.
Bundle Symfony DaplosBundle
Laravel JWT auth service package
Zoho OAuth 2.0 Client Provider for The PHP League OAuth2-Client
统计信息
- 总下载量: 765
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 37
- 依赖项目数: 1
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-02-20