Laravel Community Tools by Tighten
Laravel Nova icon Nova Packages

Installation Instructions

composer require kitloom/nova-admin-menu
php artisan migrate

Then hand the menu to the package from your NovaServiceProvider:

use Kitloom\NovaAdminMenu\Facades\AdminMenu;
use Laravel\Nova\Menu\Menu;
use Laravel\Nova\Nova;

Nova::mainMenu(fn (Request $request, Menu $menu) => AdminMenu::sort($menu, $request));

A Admin Menu tool appears in the sidebar. Everything else is done there.

Readme

Nova Admin Menu

Manage the Laravel Nova admin menu from the admin panel, instead of describing it in NovaServiceProvider and deploying for every change.

Everything Nova renders in the sidebar — tools, resources, dashboards and the entries they nest inside themselves — can be rearranged from a screen, and what the admin changes is stored as an override on top of whatever the application builds.

  • A drag and drop screen for the whole menu, nesting included
  • Move entries between sections, not just up and down within one
  • Rename entries and change the icon of top level sections
  • Add sections and links of your own
  • Hide entries you do not want in the sidebar, without touching any code
  • Restrict an entry to the permissions you name, whatever your permission system is
  • Storage is a contract: one small table by default, or plug in your own
  • No changes to your resource classes

The menu, as the admin arranges it

Sections and links the admin added, entries moved into them, the sidebar on the left showing the result.

Adding an entry

If this package saves you an afternoon, you can buy me a coffee.

Requirements

  • PHP 8.2+
  • Laravel Nova 5 — built against 5.7, verified on 5.10

Installation

composer require kitloom/nova-admin-menu
php artisan migrate

Then sort the menu from your NovaServiceProvider:

use Kitloom\NovaAdminMenu\Facades\AdminMenu;
use Laravel\Nova\Menu\Menu;
use Laravel\Nova\Nova;

Nova::mainMenu(fn (Request $request, Menu $menu) => AdminMenu::sort($menu, $request));

If you build menu entries of your own, merge them in first and sort the result:

Nova::mainMenu(function (Request $request, Menu $menu) {
    $items = array_merge($myCustomSections, $menu->items->all());

    $menu->items = new MenuCollection(AdminMenu::sortItems($items, $request));

    return $menu;
});

Editing the menu

The package registers a Nova tool at /nova-admin-menu showing the menu as a tree. Drag the rows, hit save, and the weights are recalculated from the position — in steps of 100, so there is room to slot things in by hand later. Nothing to wire up: the tool, its route and its sidebar entry are registered by the service provider.

Drop an entry on the top or bottom edge of a row to place it next to that row, or on the middle of a section to put it inside. An entry that ended up somewhere the menu would not have put it is marked moved.

Each row has an edit button for renaming the entry and, on a top level section, for setting its icon, picked from a list of the 324 names Nova can actually draw. Choosing the empty option puts the original back.

That list is generated from the icon components Nova bundles rather than copied from the heroicons docs, so it never offers something that would render blank. Regenerate it after a Nova upgrade:

grep -o '\b[A-Z][A-Za-z0-9]*Icon\b' vendor/laravel/nova/public/vendor.js | sort -u
``` Icons apply to sections only: `MenuItem`, `MenuGroup` and `MenuList` render
without one in Nova, so there is nothing to override on them.

**Add entry** creates one of your own: a section to group things under, a group heading inside a
section, or a link. A link pointing at something starting with `http://` or `https://` opens as an
external link. Created entries are ordinary rows on this screen — drag, rename, hide and restrict
them like anything else — and only they can be deleted; entries the application provides can only
be hidden.

Nothing checks that a link goes anywhere. That is the admin's call, as is who gets to see it.

**Reset** in the header throws every override away after a confirmation — the order, the moves,
renamed entries, icons, hidden entries and permissions — and reloads the page. The menu goes back
to what the application builds on its own. There is no undo.

Each row also has a **hide** button. A hidden entry disappears from the sidebar — a hidden section
takes its children with it — but stays listed on this screen, struck through, so it can be brought
back. That is the difference from `menu.hide_paths` in the config, which removes an entry outright
and gives no way to restore it from the UI.

```php
// config/nova-admin-menu.php
'tool' => [
    'register' => true,
    'path' => 'nova-admin-menu',
    'title' => 'Admin Menu',
    'show_in_sidebar' => true,
    'step' => 100,
],

Only classes that are actually in the menu are accepted by the save endpoint, so a crafted payload cannot create arbitrary settings rows.

Your own screen

use Kitloom\NovaAdminMenu\Facades\AdminMenu;

foreach (AdminMenu::entries()->all($request) as $entry) {
    // $entry['class'], $entry['label'], $entry['type']
    $weight = AdminMenu::weightForClass($entry['class']);
}

The tool's own endpoints return the menu as a tree instead, which is what nested entries need:

GET  /nova-vendor/nova-admin-menu/entries
POST /nova-vendor/nova-admin-menu/entries   {"order": ["<identity>", ...]}

Storage

Overrides live in one table, nova_menu_overrides, a row per changed entry:

column
identity how the entry is named — see below
weight position among its siblings
hidden entry is kept out of the sidebar
permissions names the viewer needs one of, empty for everyone
label, icon replace what the menu itself renders
parent_identity section the entry was moved into, or __top__ for the top level
type, path set only on entries created here: section, group or item, and where a link goes

A parent is written only when it differs from where the menu itself puts the entry. Storing the natural one would freeze the structure, and a later version of a tool adding a child would spend its life fighting a stale override.

The whole table is loaded in one query and cached. To keep the overrides somewhere else, implement Kitloom\NovaAdminMenu\Contracts\MenuOverrideRepository and name it in nova-admin-menu.repository.

Publish the config to change any of this:

php artisan vendor:publish --tag=nova-admin-menu-config

Restricting entries

Each row takes a list of permission names. An entry is shown when the viewer has any one of them; an empty list means everyone. A restricted section takes its children with it.

The names mean nothing to this package — one application checks them against a role table, another against gates — so the application says what they mean:

// AppServiceProvider or NovaServiceProvider
AdminMenu::authorizeUsing(fn (Request $request, array $permissions) => $request->user()->isSuperAdmin()
    || collect($permissions)->contains(fn (string $p) => $request->user()->hasPermissionTo($p)));

Without that the package falls back to Gate::any(), which suits a stock application or Spatie's permissions. Check which one you need: a permission system that registers no gates — pktharindu's, for instance — will fail every check through the fallback, and restricted entries then disappear even for a super admin.

To fill the suggestion list on the editing screen:

AdminMenu::permissionsUsing(fn () => Permission::pluck('name')->all());

This hides links, it does not protect pages

The entry disappears from the sidebar; the page behind it is authorised separately by Nova, through the resource's policy or the tool's own canSee. Anyone who knows the URL still gets whatever that authorisation allows. Use this to keep menus tidy per role — and where it has to be a restriction, gate the resource too.

The check is applied by dropping entries from the menu, never by attaching a canSee callback: AuthorizedToSee::canSee replaces the callback, so attaching one would throw away whatever the tool had already set, and a section closed to a role would quietly open up. Filtering can only ever remove.

An entry restricted to someone else stays on the editing screen — otherwise there would be no way to lift the restriction.

Moving entries, and what it does to visibility

Nova rebuilds the menu from the tools on every request, so a move is an override replayed each time. It has to survive the menu changing underneath it, and it does:

the target is gone the entry stays where the menu put it
the target cannot hold children — a MenuItem has no items the move is ignored
a section is dropped inside its own descendant the move is ignored
a section is emptied by moves Nova stops rendering it — MenuSection.vue draws nothing without children or a path

Three levels

Nova renders the menu recursively, so nesting works — but it styles MenuSection for the top level: icon slot, full size text, no indent. A section dropped inside another one would read as a second root rather than a sub-menu.

Nova's own vocabulary for a heading inside a section is MenuGroup, so that is what a nested section is drawn as:

nested section drawn as
with children MenuGroup — the small upper-case heading
just a link MenuItem

A group has neither icon nor link, so a section loses both on the way down. This is a rendering step, run after every identity is resolved: nothing stored changes, and dragging the section back out restores it.

A MenuList is never a drop target — its Vue component renders MenuItem children and nothing else, so anything else put inside would silently vanish.

Visibility is the part to think about. Nova filters the menu at every level: an entry is shown only when its own canSee passes and every section above it passes too. Moving an entry therefore changes which rules wrap it — out of a restricted section, its link becomes visible to more people. The page behind the link is authorised separately by Nova, so this is about who learns the entry exists, not about who can open it.

The package cannot warn you precisely: canSee is a closure, two of them cannot be compared, and either can only be evaluated for the admin currently looking. The screen says so plainly instead. To take the feature off the table:

'menu' => ['allow_reparenting' => false],

How entries are named

Nova hands the mainMenu callback fully built menu entries with no reference back to whatever produced them, so a weight has to be tied to something stable on the entry itself. The package tries three things, in order:

Identity Stable across
1 the class behind the entry, e.g. App\Nova\Tools\Posts renames of labels and paths
2 its path, e.g. menu_path:/resources/posts renames of labels, locale changes
3 its label, e.g. menu_label:Reports nothing — a locale switch loses it

An entry created on the screen has none of these: nothing in the application produces it. It gets an id of its own, menu_custom:<uuid>, and is rebuilt from its row on every request.

Only top level entries can be traced back to a class: the package asks every registered tool, resource and dashboard what it would render and indexes the result by path and by label. A tool builds its own children, though, and nothing connects those to a class — which is why nested entries are named by path. That is what makes submenus sortable at all.

The third case is rare and worth avoiding: it applies to entries with no path, such as a MenuGroup. The tool marks those with a "by label" badge.

Renaming is applied after the identity is resolved, and the editing screen always shows the label the menu itself produces. Without that, renaming an entry of the third kind would change what it is identified by, and everything stored against it would be orphaned on the next request.

Class names keep the key shape they have always had, so weights stored by earlier versions are still found after upgrading.

Testing

composer test

License

MIT.

Screenshots

Back to Top
Added 44 minutes ago
Last updated 1 Hour Ago
Version v0.9.0
Composer
kitloom/nova-admin-menu
GitHub stars 0
Packagist downloads 0

Favorites

0 users favorited

Rating

N/A
(out of 5)
★★★★★
★★★★
★★★
★★
0 ratings

Author

Brought to you by Tighten

Issues/Feature Requests Stats Package Ideas