Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Menu Builder Bundle Laravel Package

forci/menu-builder-bundle

A Symfony bundle that integrates the MenuBuilder library, giving you a flexible object-oriented API for defining, rendering, and manipulating navigation menus in Twig templates. Configure menus in PHP, nest items with permissions, and output clean HTML.

View on GitHub
Deep Wiki
Context7

Getting Started

Understand what this is before installing anything. forci/menu-builder-bundle is a Symfony bundle, not a Laravel package. There is no ServiceProvider, no facade, no Blade integration. composer require forci/menu-builder-bundle will pull in symfony/framework-bundle and knplabs/knp-menu-bundle, neither of which boots cleanly in a Laravel app. The bundle itself is a thin fluent wrapper around knplabs/knp-menu — its real value is the MenuBuilder API, not the Symfony integration.

Read in this order:

  1. README.md — the fluent API surface (addItem, addChild, setLabel, setUri, setAttribute, etc.).
  2. src/.../MenuBuilder.php — the class you're actually evaluating. It's small. Read all of it.
  3. Resources/config/services.xml — shows what Symfony DI wiring you'd need to reimplement.
  4. composer.json — PHP/Symfony constraints. This often decides whether "port" or "skip" is the answer.

The three realistic paths, ranked:

  1. Port the builder API onto standalone knplabs/knp-menu in app/Menu/. ~1 hour of work, keeps the value, drops the Symfony coupling. Recommended.
  2. Read it as reference only and write a plain Laravel menu service with zero third-party deps. Best if menus are simple and static.
  3. Use a Laravel-native package (spatie/menu, lavary/laravel-menu) if you need something working today.

Minimal working port (path 1):

composer require knplabs/knp-menu
// app/Menu/MenuBuilder.php
namespace App\Menu;

use Knp\Menu\FactoryInterface;
use Knp\Menu\ItemInterface;

class MenuBuilder
{
    public function __construct(private FactoryInterface $factory) {}

    public function mainMenu(array $options = []): ItemInterface
    {
        $menu = $this->factory->createItem('root');

        $menu->addChild('Home', ['route' => 'home']);
        $menu->addChild('Blog', ['uri' => '/blog']);

        $account = $menu->addChild('Account', ['uri' => '#']);
        $account->addChild('Profile', ['route' => 'profile']);

        return $menu;
    }
}

Bind the factory in a service provider:

// app/Providers/AppServiceProvider.php
use Knp\Menu\MenuFactory;
use Knp\Menu\FactoryInterface;

$this->app->singleton(FactoryInterface::class, fn () => new MenuFactory());

First use case to target: a top-nav with one level of nesting and active-route detection. That's enough to validate the port and gives you a template to build on.

Implementation Patterns

Wrap the builder behind a service, not in controllers. Menus are structural — they get rendered from layouts, error pages, and API responses. Put menu construction in app/Menu/ and inject it, so you can swap the implementation (KnpMenu, in-house, later a DB-backed version) without touching controllers or views.

Use factory methods per menu. Don't build one mega-builder. One method per menu context (mainMenu(), footerMenu(), adminSidebar(), userDropdown()) is easier to test, cache, and override per-locale/per-tenant later.

Active-route detection — reimplement it explicitly. The Symfony bundle leans on Symfony's RequestStack and router to mark the current item. In Laravel you do this yourself, once:

public function mainMenu(Request $request): ItemInterface
{
    $menu = $this->factory->createItem('root');
    $item = $menu->addChild('Blog', ['route' => 'blog.index']);
    $item->setCurrent($request->routeIs('blog.*'));
    return $menu;
}

Doing this in the builder (not the template) keeps active-state logic testable and out of Blade.

Render with a dedicated Blade component. Because KnpMenu's built-in renderers emit Knp-flavored markup, write one small Blade partial and drive it from a recursive component. This gives you class-name control, Tailwind/Bootstrap neutrality, and aria-current handling:

{{-- resources/views/components/menu.blade.php --}}
@props(['items', 'depth' => 0])
<ul {{ $attributes->merge(['class' => $depth === 0 ? 'nav' : 'nav sub']) }}>
    @foreach ($items as $item)
        <li @class(['active' => $item->isCurrent(), 'has-children' => $item->hasChildren()])>
            <a href="{{ $item->getUri() }}" @if($item->isCurrent()) aria-current="page" @endif>
                {{ $item->getLabel() }}
            </a>
            @if($item->hasChildren())
                <x-menu :items="$item->getChildren()" :depth="$depth + 1" />
            @endif
        </li>
    @endforeach
</ul>

Bridge KnpMenu routes to Laravel URIs at build time. KnpMenu has a route concept but expects a Symfony router. Either pass uri => route('blog.index') from your builder (simplest), or implement a custom UriGenerator bound to Laravel's UrlGenerator. The uri approach is less code and less to debug.

Cache the item tree when menus are dynamic. If you graduate to DB-backed menus, build the ItemInterface tree once and cache the serialized structure keyed by menu_slug + locale + user_role. Invalidate on the underlying model's saved/deleted events via an observer. Menu trees are read-heavy and change rarely — this is where the biggest win is.

Permissions and visibility belong in the builder, not the template. Filter items in PHP:

if (! $user?->can('view-reports')) {
    $item->setDisplay(false);
}

Hiding via CSS is the classic mistake — it leaks endpoints and confuses screen readers.

Gotchas and Tips

Don't composer require the bundle to "try it." It will attempt to install Symfony framework components into a Laravel app and can create a dependency resolution mess. Clone the repo or composer require --dev in a throwaway branch if you want to read it running. Even better: just read the source. It's a few hundred lines.

License is NOASSERTION — treat it as all-rights-reserved. Before you copy any code out of the repo into app/, get a human to read LICENSE (or confirm the absence). If you're re-implementing the pattern (fluent addItem/addChild) from scratch, you're fine. Pasting the class file verbatim is a legal question, not an engineering one.

The builder is mutable, not immutable. addChild() returns the new item and mutates the parent. That means sharing a $menu instance across requests or tests will carry state forward. Always build fresh per request (or cache the serialized result, not the live object). This is the single most common bug when porting.

setAttribute vs setLinkAttribute vs setChildrenAttribute. KnpMenu distinguishes attributes on the <li>, the <a>, and the wrapping <ul> respectively. Putting class on the wrong one silently no-ops. When writing your Blade component, be explicit about which attribute bag you render where, otherwise your styling will "work in dev, break in prod" as menu depth changes.

Cycle guard, always. If you go DB-backed, a malformed parent_id produces infinite recursion. Guard at build time:

private function assertNoCycles(Collection $items): void
{
    $checked = [];
    foreach ($items as $item) {
        $path = [];
        $cur = $item;
        while ($cur) {
            if (in_array($cur->id, $path, true)) {
                throw new \RuntimeException("Menu cycle at item {$item->id}");
            }
            $path[] = $cur->id;
            $cur = $items->firstWhere('id', $cur->parent_id);
        }
    }
}

Add a hard max_depth config (e.g. 3–5) and truncate beyond it. A deeply nested menu is a design smell and a DoS vector.

Watch for N+1 when loading children. Naïve recursive loading in a menu builder fires one query per node. Eager-load the whole flat item set for a menu (MenuItem::where('menu_id', $id)->orderBy('position')->get()) and assemble the tree in memory. This is O(1) queries regardless of depth.

Route/URL generation is a common porting trap. If you keep KnpMenu's route option expecting Laravel to resolve it, you'll get exceptions or wrong URLs. Decide up front: uri in the builder (simplest), or a custom URI generator. Document it, because the next dev will try the other one.

Active-state matching on wildcards is easy to get wrong. A "Blog" item with route=blog.index should be active on blog.show too. Use Route::is('blog.*') for parent items, and exact routeIs('blog.index') for the leaf. Mix these up and either nothing highlights or everything does.

Test the rendered markup, not the builder. A builder test asserts $menu->getChild('Blog') exists — useful, but the failure mode is always in rendering (wrong indentation, missing aria-current, broken hover classes). Write one snapshot test per menu that renders the Blade component and asserts the flattened <a> text/href pairs. That single test catches structural regressions cheaply.

Extension point if you ever grow it: the place to add behavior is a MenuDefinition (a config/DB record) plus a MenuRenderer interface. Keep the builder as a pure in-memory assembler and put persistence, caching, and permissions around it. That boundary is what lets you swap KnpMenu out later without a refactor — and it's the same boundary the original bundle gets wrong by wedding the assembler to Symfony's DI container.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route