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

Nova Menu Builder Laravel Package

optimistdigital/nova-menu-builder

A Laravel Nova package for building and managing menus with a drag-and-drop interface. Create nested menus, link to models or custom URLs, and localize menu items directly from Nova's admin panel.

View on GitHub
Deep Wiki
Context7

Getting Started

Hard gate first. This package is a Nova tool. If Nova isn't already installed and licensed, there is no product here — you're looking at a menus + menu_items schema and a bespoke admin screen, and that admin screen is the expensive half. Confirm Nova 4/5 is live before reading further.

Install using the canonical vendor name. The repo was renamed optimistdigital/nova-menu-builder → outl1ne/nova-menu-builder, and the PHP namespaces changed with it (OptimistDigital\MenuBuilder → Outl1ne\MenuBuilder). Most tutorials and Stack Overflow answers still reference the dead vendor. Check Packagist for the current tag, then pin an exact version rather than a caret — this package has a shallow integration surface but a real upgrade tail, and you don't want Composer deciding when you find out:

composer require outl1ne/nova-menu-builder
php artisan vendor:publish --provider="Outl1ne\MenuBuilder\MenuBuilderServiceProvider"
php artisan migrate

Register the tool in app/Providers/NovaServiceProvider.php:

public function tools()
{
    return [
        new \Outl1ne\MenuBuilder\Nova\MenuBuilderTool,
    ];
}

Then php artisan optimize:clear and hard-reload the panel. If the tool shows but assets 404, run php artisan nova:publish before assuming registration failed.

Where to look first, in this order:

  1. config/nova-menu-builder.php — this file is the mental model. locales, the menus registry (slug, display name, max_depth), and menu_item_types (the classes offered in the item-type dropdown). Everything else is detail.
  2. src/MenuItemTypes/ — the built-in Link / Text types document the extension contract better than the README does.
  3. src/Models/Menu.php and src/Models/MenuItem.php — confirm what the relations are actually called (menuItems, children), whether ordering is applied by default, and whether any recursive accessor ships. Do this before writing a Blade partial; the answer determines how much recursion is your job.

First use case (30-minute smoke test). Declare one header menu in config, create it in the admin UI, add a custom-URL item and a linked-model item, nest one child under it, then:

\Outl1ne\MenuBuilder\Models\Menu::with('menuItems.children')
    ->where('slug', 'header')->first()->toArray();

If you get a nested tree, you understand the shape. If you get flat root items, note it — recursion is on you.

Then create the seam before any view code exists. One class, roughly an hour of work:

// app/Menus/MenuResolver.php
public function for(string $slug, ?string $locale = null): array

Views call the resolver. Views never touch MenuItem, never call ->children, never see Eloquent. This converts the abandonment risk from "rewrite the admin UI" into "swap one class."

Implementation Patterns

Config is your menu registry; the slug is your public contract. Treat slugs (header, footer, legal) as an API that view code and tests depend on. Treat the display name as purely editorial. Keeping definitions in config — rather than letting editors create menus on the fly — stops a stray Header2 from becoming load-bearing before anyone notices.

Resolve to plain arrays, never models. The package hands you Eloquent models with relations; your views should not know that. Map recursively into a stable, serializable structure:

[
    ['label' => 'Products', 'url' => '/products', 'active' => false, 'children' => [...]],
]

This is also the only sane thing to cache — never push models-with-relations into the cache store.

Cache the resolved tree; invalidate with observers. The package invalidates nothing for you, and Cache::rememberForever() without an observer is how stale navigation ships silently.

// app/Observers/MenuItemObserver.php
public function saved()   { Cache::forever('menus:version', now()->timestamp); }
public function deleted() { Cache::forever('menus:version', now()->timestamp); }

Prefer a version-stamped key (menus:v{$version}:header:en) over enumerating and forgetting individual keys — with multiple locales and depths, key enumeration always eventually misses one. Register the observers in a service provider against both Menu and MenuItem.

Locale fallback is yours to define. Multi-locale support means one menu row per locale; the package makes no decision about what happens when de has no header. Implement an explicit chain in the resolver (de → en → first available) and write it down somewhere a PM can read.

Active state belongs in the resolver, not Blade. Do the request()->url() / route-name comparison inside the resolver so templates stay dumb and the logic stays unit-testable.

Filter at resolve time, before caching. Drop disabled items and items whose URL resolved to null (deleted linked model) so the frontend never receives dead links:

->filter(fn ($item) => $item->enabled && $url !== null)

Custom menu item types are the main extension point. One class per concept — "Page link", "Product link", "Route link" — extending the package's abstract base. The shape is roughly: an identifier, a display name, a getType() (text vs link vs custom, which determines whether the editor shows a URL field or your own fields), getFields() returning Nova fields, getRules() for validation, and a link/URL builder. Verify exact signatures against src/MenuItemTypes/BaseMenuItemType.php in your pinned version, then register in config('nova-menu-builder.menu_item_types').

Keep these types thin. Store an ID in the data column, resolve the model lazily at render time, and return null when it's gone:

public function getUrl(): ?string
{
    return \App\Models\Page::find($this->data['page_id'] ?? null)?->url();
}

Frontend integration. Three patterns, pick per surface:

  • Blade: a component <x-site.menu slug="header" /> wrapping the resolver. Cache lives in the resolver, not the component.
  • Inertia: share the resolved arrays from HandleInertiaRequests — a handful of small arrays, cheap to serialize, and it keeps the Vue layer free of any endpoint.
  • JSON API: write your own resource. The package ships no endpoint; if you're headless, ask honestly whether the admin screen still justifies the dependency.

Backfill, then cut over behind a flag. Script the existing hardcoded nav into menu records so go-live isn't blocked on someone hand-entering sixty footer links. Put a menus.dynamic boolean in a config file; the component falls back to static markup until one clean release cycle has passed.

Testing. The resolver is the test surface. Build fixtures with factories (or seed the models directly), assert the array shape including nesting and active flags, and separately assert that saving an item bumps the cache version. Don't bother testing the Nova admin UI.

Gotchas and Tips

The rename is a live Composer trap. Namespaces changed, so pasted snippets fatal with class-not-found. Grep your codebase for OptimistDigital and optimistdigital before trusting anything from a blog post.

Migrations will collide. menus and menu_items are generic table names. Check a production-shaped staging database before the first migrate; if they exist, rename via config (menus_table_name, menu_items_table_name) rather than patching a migration that has already run somewhere. A half-applied migration is the classic day-one incident here.

Ordering is per-parent, not global. Always apply ->orderBy('order') on the relation. Without it, tree order is nondeterministic — and so is everything you cached. If the tree renders flat, you're almost certainly reading root items (parent_id = null) and not recursing; confirm whether the model ships a recursive accessor before writing your own.

Deleting parents orphans children. Confirm the FK behaviour on parent_id. Orphaned children break recursion, and a menu can render blank with no exception anywhere. Add onDelete('cascade') or a deleting hook.

data is a schemaless JSON column. Renaming or removing a field in a custom item type silently orphans stored values — editor shows empty fields, site shows a broken link, no error. Version your data keys or write a migration before any shape change.

"Missing label" is almost always a locale mismatch. Translatable fields are JSON under the hood, and translatability here typically rides on a sibling Outl1ne dependency. Dump the raw attribute (getRawOriginal('name'), getTranslations('name')) before you go debugging the resolver.

Custom URLs are an XSS and open-redirect vector. They're free text. Allowlist schemes in your type's getRules() on save, and escape on render with {{ }}. Never {!! $item->url !!}. Pair target="_blank" with rel="noopener noreferrer".

Authorize the tool. A low-privilege Nova user who can edit links can point "Login" anywhere on the internet. Gate the tool or a policy — treat menu editing as a privileged content action, same tier as publishing.

max_depth is a UI affordance, not a DB constraint. If you have any other write path (seeders, imports, an internal API), validate depth in an observer.

Upgrades are the risk, not the install. 242 stars and single-agency maintenance means no community patch path. Pin exact versions, keep a CI job that boots the panel against the next Nova major, and name an owner for the fork path before an upgrade blocks you. MIT plus a shallow surface makes fork-and-own cheap — decide that ahead of time, not in the middle of a Nova release. Watch especially for the cached frontend happily serving stale navigation while you're mid-upgrade; it makes the breakage easy to miss.

Debugging shortcut. Menu::with('menuItems.children')->where('slug','header')->first()->toArray() shows exactly what the resolver is working with, independent of cache. If tinker is right and the site is wrong, the bug is your cache key or your observer — not the package.

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.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons