object.bg/menubundle
A Symfony bundle for defining, managing, and rendering menus in your applications. Build hierarchical navigation, configure menu items, and output them cleanly in templates with a flexible, extendable API.
Run the framework check before anything else. Despite the object.bg/menubundle framing, this is a Symfony bundle, not a Laravel package — Bundle suffix, bundles.php registration, Doctrine entities, KnpMenuBundle for rendering, Sonata Admin for the CRUD surface. There is no service provider, no facade, no config/menus.php, no php artisan vendor:publish. Plan accordingly:
composer require object.bg/menubundle, register Warxcell\MenuBundle\WarxcellMenuBundle plus SonataAdminBundle, KnpMenuBundle, DoctrineBundle in config/bundles.php, generate schema (bin/console doctrine:migrations:diff — there are no shipped migrations), wire the Sonata admin services, then bin/console debug:container | grep -i menu to find the provider service ID. That provider is the only thing your runtime code touches.MenuProvider + entity mapping, then implement the same contract natively with Eloquent and a Filament/Nova admin. That port is 2–4 engineer-days for a mid-level developer and is the only path where you own the code.Where to look first in the source: the Doctrine ORM mappings (Resources/config/doctrine/*.orm.xml) and the MenuProvider class/interface. Those two artifacts define the entire public contract — field names and provider signature are the API. Everything else is Sonata Admin plumbing.
First use case (either path): create a menu named main with two or three items (one internal route, one external URL), then render it. In Symfony that means handing the provider's Menu object to knp_menu_render() in Twig. In Laravel, that means a Menu::findByHandle('main') returning a cached tree your Blade component walks recursively. If that works end-to-end, you understand 90% of what this package does.
Provider → tree → view is the whole loop. Everything else in the bundle (Sonata admin classes, service YAML) exists to feed this one call. Structure your application code to depend on the provider, never on the entities directly. If you port to Laravel, define the equivalent interface first:
interface MenuProvider {
public function getMenu(string $handle, ?string $locale = null): MenuTree;
}
One menu record per context, not per page. Model main, footer, account-sidebar as distinct menus and let the admin own each one. Do not build one mega-tree with visibility flags — filtering a stored tree is more code and more bugs than storing three trees. This is the single most important modelling decision and it survives the Symfony→Laravel port unchanged.
Active-state via route name, not URL. Store the route name on each item (home, pricing, account.settings) and resolve active state at render time — KnpMenu's setCurrentUri() plus a voter on the Symfony side, Route::currentRouteName() comparison in a Blade component on the Laravel side. Route names survive domain changes, locale prefixes, and trailing-slash policy; hardcoded URLs do not. This is the reason the package's "active state detection" feature is worth copying.
Cache the assembled tree, invalidate on write. The provider runs on every request that renders chrome, so wrap it:
Cache::remember("menu:{$handle}:{$locale}", 3600, fn () => $this->buildTree($handle));
Invalidate from the admin write path — a Sonata postPersist/postUpdate/postRemove hook in Symfony, a Filament resource observer or Eloquent model event in Laravel. Never invalidate on a timer alone; the stale-menu-after-edit bug is the classic failure in this domain.
Load the whole tree in one query, assemble in PHP. A recursive parent_id schema invites N+1 on every render. Fetch all rows for the menu, index by id, link children arrays in a second pass. Do not lazy-load children per node. If you skip this, a five-level menu with 40 items becomes 40+ queries per page.
Seed programmatically — do not click through admin on every environment. Fixtures or a seeder that creates the canonical main / footer structure beats manual setup because it is diffable, reviewable, and reproducible in CI. Bonus: it forces route names into constants or enum cases, so a renamed controller action surfaces as a build failure rather than a 404 in production nav.
Extend at the admin/render layer, not the entity. Column layout, drag-reorder UX, and permission gating live in your Sonata admin subclass (or Filament resource). Treat the entity as read-only from application code, and treat the Blade/Twig template as the extension point for markup changes — icons, aria-current, active classes.
Blade rendering for the Laravel port. A single recursive <x-menu-item> component with @foreach ($item->children as $child) handles arbitrary depth, and you get publishable-template semantics for free by putting it under resources/views/components. That is what "publishable customizable templates" should mean in your stack.
vendor:publish. Any guide that tells you to composer require this into a Laravel app and register a service provider is wrong; there is nothing to register — Laravel will autoload the classes and then fail the moment you touch a Doctrine entity or a Sonata service.sonata-project/admin-bundle explicitly and treat every bump as a migration project.Menu + MenuItem entity pair plus KnpMenuBundle is genuinely most of what this package is.doctrine:mapping:info and Resources/config/doctrine/*.orm.xml before running schema:update, so you know what tables you are about to create.MappedSuperclass, adding an icon or css_class field means forking the mapping. Assuming extendability and being wrong is a refactor.debug:container for the provider service ID, doctrine:mapping:info for entity registration, debug:router when links 404, and Sonata's logs when the CRUD screen white-screens (usually a missing service definition or a template override that did not get registered).ROLE_ADMIN (or stricter) gates the menu screen, that it is not reachable in production without that gate, and that the auth model is reviewed before launch. A misconfigured admin route here is a public-content-takeover finding, not a cosmetic bug.parent_id permits cycles created through the UI. Add a depth cap and a visited-set check in the tree builder, or a single bad admin edit takes down every page that renders chrome.How can I help you explore Laravel packages today?