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


README

PHP Composer PHPStan PHP-CS-Fixer Latest Stable Version License: MIT PHP 8.5+ Symfony 8.0

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 APIadd(), children(), end() for deeply-nested trees
  • Route-based matchingRouteVoter marks the current item and its ancestors active; route values are treated as regex patterns
  • Role-based accessAccessor gates items by Symfony security roles; results are memoized per request
  • PSR-6 cachingAbstractCachedNavigation caches the item tree for 24 h with tag-based invalidation
  • Runtime extensionsRuntimeExtensionInterface runs post-cache on every request for fresh dynamic data without rebuilding the tree
  • Badge supportBadgeExtension resolves int and \Closure badges at runtime; implement RuntimeExtensionInterface for service-injected dynamic badges
  • Twig integrationrender_menu() function with fully customisable templates
  • Extension system — build-time ExtensionInterface for cached option enrichment, runtime RuntimeExtensionInterface for 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 roles restriction, 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.