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

outl1ne/nova-menu-builder

Flexible menu builder for Laravel Nova. Create and manage hierarchical menus with an intuitive drag-and-drop interface, supporting custom links, model references, and nested items. Attach menus to models and render them on your frontend with ease. Everything you need for simple, powerful menu manage...

View on GitHub
Deep Wiki
Context7

Getting Started

1. Confirm compatibility before anything else. This is a Nova tool, not a standalone package. Check the composer.json constraint in the package against your licensed Nova major (v4/v5) before composer require outl1ne/nova-menu-builder. A mismatch is the only genuinely hard failure here — everything else is a quick fix.

composer require outl1ne/nova-menu-builder

2. Publish, migrate, and read the config in that order.

php artisan vendor:publish --provider="Outl1ne\MenuBuilder\MenuBuilderServiceProvider"
php artisan migrate

If the tagged publish misses the config (tag names have drifted between majors), run vendor:publish with no tag and select interactively. Then open config/nova-menu-builder.php before writing any code — it is the whole integration surface: menu_item_types, locales, menu_model, menu_item_model, nova_resource. Every "why doesn't this show up in the builder" question later resolves back to this file.

3. Create one real menu in Nova. Give it a stable slug (main-menu), drag in a few LinkItem and TextItem entries, nest one. The bundled types cover most header/footer needs on day one.

4. Dump the shape before you write a partial. In Tinker:

$menu = \Outl1ne\MenuBuilder\Models\Menu::whereSlug('main-menu')->first();
dump($menu?->formatForDisplay());

Do this once with each item type present. Then write one recursive Blade partial against that dumped structure and reuse it for header, footer, sidebar, and mobile. That partial is your entire rendering layer for the rest of the project's life.

Implementation Patterns

Wrap menu access in a helper or view composer — never call the model from Blade. One function gives you a single place to cache, a single place to log N+1s, and a single place to swap the source later.

// app/Support/helpers.php
function menu(string $slug, ?string $locale = null): array
{
    $locale ??= app()->getLocale();

    return Cache::remember("menu.{$slug}.{$locale}", now()->addDay(), function () use ($slug, $locale) {
        $menu = \Outl1ne\MenuBuilder\Models\Menu::with('items.children')
            ->whereSlug($slug)
            ->first();

        return $menu?->formatForDisplay($locale) ?? [];
    });
}

Bust that cache on model events, wired on day one. Reorders fire saves on MenuItem, not just Menu, so listen to both:

// app/Providers/AppServiceProvider.php
Menu::saved(fn () => Cache::flush());        // or targeted forget()
Menu::deleted(fn () => Cache::flush());
MenuItem::saved(fn () => Cache::flush());
MenuItem::deleted(fn () => Cache::flush());

Targeted keys (menu.{$slug}.{$locale}) are better than flush() once you have more than one menu; a small helper that forgets all locale variants of a slug is the usual compromise.

Custom menu item types are where the real value lives. Extend BaseMenuItemType for anything where the URL should be derived rather than typed — pages, products, categories. This is what makes navigation survive slug changes:

class PageMenuItemType extends BaseMenuItemType
{
    public static function getType(): string { return 'page'; }
    public static function getName(): string { return 'Page'; }

    public static function getFields(): array
    {
        return [Select::make('Page')->options(Page::pluck('title', 'id'))];
    }

    public static function getRules(): array
    {
        return ['data.page_id' => 'required|exists:pages,id'];
    }

    public static function getDisplayValue($value): string
    {
        return Page::find($value['page_id'] ?? null)?->title ?? '—';
    }
}

Register it by appending the class to menu_item_types in config. Then config:clear.

Locale-aware trees. With more than one entry in locales, the builder shows tabs and items are scoped per locale. Always pass the active locale into formatForDisplay() and keep a fallback slug convention (main-menu → main-menu.default) so a page renders when a translation is missing. Binding the locale to app()->getLocale() inside the helper keeps editors from publishing into the wrong tree.

Customize via config pointers, not by forking. Point menu_model, menu_item_model, or nova_resource at your own subclasses for soft deletes, accessors, or a stricter policy. vendor/ stays updatable.

Seed your menus. A MenuSeeder that creates main-menu with a handful of items makes local dev, CI, and tests deterministic. Don't require every new developer to click through Nova to get a working nav.

API/SPA consumers: serialize formatForDisplay() behind your own endpoint. A small controller returning the cached array keeps the payload shape, versioning, and caching under your control and means the frontend never learns the raw row schema.

Gotchas and Tips

  • Cache invalidation is your responsibility, not the package's. This is the number-one source of "my change didn't show up" reports. Wire the listeners before the first demo, not after the first complaint.

  • Don't trust the documented formatForDisplay() shape. Keys vary by item type, and url may be computed by a custom type rather than read from a column. Always dump real output in Tinker and write the partial against that.

  • config:cache silently swallows new item types. After editing menu_item_types or locales, run config:clear (or re-run config:cache). Otherwise custom types simply never appear in the builder modal, with no error anywhere.

  • Validation lives in getRules(), not on the Nova field. If a rule references a data. key that doesn't match what getFields() actually writes, items save successfully and render blank. Debug at the model level, not in the Nova UI.

  • Eager-load or cache, don't do the naive thing. Menu::with('items.children')->whereSlug(...) handles the common two-level case; a hand-rolled $menu->items → ->children chain N+1s badly. The cached formatted array sidesteps the whole problem — prefer caching over per-request eager-loading.

  • Slug uniqueness is on you. The UI doesn't enforce a global constraint, so main-menu, main_menu, and mainmenu will all coexist in the wild. Normalize casing in your helper and add a unique index to the menus table in a follow-up migration.

  • Tree edits are destructive. Drag/drop and delete mutate order and parent_id directly. Manually reordering rows in the DB leaves the builder showing a stale tree until refresh — edit through the UI, and tell editors the same.

  • Ship a MenuPolicy early. The default Nova resource is permissive. Most teams want nav editing restricted to admins or content leads; retrofitting permissions after content is live is painful.

  • Decide now what happens when a linked model is deleted. If you use a Page/Product type, add a model observer that cascade-deletes the menu item, or render an explicit fallback in getDisplayValue(). Undecided means broken nav in production.

  • Pin Nova and this package together. Because the package tracks a closed-source framework, treat Nova upgrades as planned work rather than a routine composer update. MIT means forking is legal; only the Laravel depth makes it practical — budget a named maintainer.

  • The extension triangle to memorize: nova_resource + menu_model + menu_item_model in config (customize the admin), getDisplayValue() on a custom type (controls the builder's tree label), and saved/deleted listeners (controls freshness). Those three touchpoints cover most real-world customization without ever editing vendor/.

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