Menu
The panel menu lives in a single JSON file; the vertical menu, the horizontal menu, the mobile drawer and the ⌘K search are all built from it.
Quick example
{
"menu": [
{ "section": "General" },
{ "title": "Dashboard", "icon": "home", "route": "dashboard" },
{ "title": "Customers", "icon": "users", "route": "customers.index", "active": "customers.*" }
]
}
In the theme, this file is at resources/menu/main.json, and the layout reads it on every page. Every page you add to the file shows up in the menu and in search. The link URL is generated from the route name, and the active item is found by the route name too.
The JSON menu (acun:menu and Acun\Ui\Menu\Menu) is in the free acunsoft/acun-ui-free package.
Adding a page to the menu
-
Define the routes with names, e.g.
projects.index(the list) andprojects.show(the detail page). -
Add a line to the
menuarray inmain.json:{ "title": "Projects", "icon": "folder", "route": "projects.index", "active": "projects.*" }
The page appears in the menu and in ⌘K search. Thanks to "active": "projects.*", "Projects" also stays active on the project detail page.
Fields
| Field | What it does | Example |
|---|---|---|
section |
A section heading; top level only. | "section": "Records" |
title |
The item's text; it's a translation key. | "title": "Customers" |
icon |
An acun:icon name (Heroicons); an unknown name shows no icon. |
"icon": "users" |
route |
A Laravel route name; if it isn't defined, the link becomes #. |
"route": "customers.index" |
params |
The route parameters. | "params": { "user": 1 } |
url |
A URL instead of a route; only safe URLs are accepted. | "url": "/reports/2025" |
active |
The pages on which the item appears active; * is a wildcard. |
"active": "customers.*" |
badge |
A number or short label on the right. | "badge": "New" |
target |
_blank opens the link in a new tab. |
"target": "_blank" |
children |
Child items; at most three levels. | "children": [ … ] |
Details:
- url: Only
http,https,mailto,teland in-app URLs are accepted. Anything else becomes#. - active: Write a route name or a
/pathpattern; pass an array for several. If omitted, the item is active only on its own page: the same route name or the same URL. External URLs withoutactivenever appear active on any page. - badge: On an item with children,
trueshows the number of children. - target: With any value, the link opens with a full page load.
"_self"opens it in the same tab. - children:
childrenon the third level are ignored. An item with children isn't a link but a group that expands and collapses. If one of its children is active, the group also appears active and expanded.
A section heading with no items below it and a group with no children left aren't shown. Unknown fields in the definition are ignored.
Examples
{ "title": "My Profile", "icon": "user", "route": "users.show", "params": { "user": 1 } },
{ "title": "Reports", "icon": "chart-bar", "route": "reports.index", "active": ["reports.*", "/exports/*"] },
{ "title": "Tickets", "icon": "inbox", "route": "tickets.index", "badge": "New" },
{ "title": "Documentation", "icon": "book-open", "url": "https://example.com/docs", "target": "_blank" },
{ "title": "Archive", "icon": "archive-box", "badge": true, "children": [
{ "title": "Sales", "route": "archive.sales" },
{ "title": "Years", "children": [
{ "title": "2025", "url": "/archive/2025" }
] }
] }
In order: a route with parameters; a route pattern and a path pattern together; a fixed badge; an external link that opens in a new tab; a three-level nested menu. In the last one, "badge": true shows the number of children (2).
Translating the menu
Titles and section names are translation keys. For "title": "Siparişler", all you need is "Siparişler": "Orders" in the lang/en.json file. A title without a translation is shown as is. See Languages and translation for details.
How the layout draws the menu
In the theme, the menu file is read at the top of the layout file and drawn in the sections under layouts/admin/sections/:
{{-- layouts/admin/vertical.blade.php --}}
@php($menu = \Acun\Ui\Menu\Menu::fromFile('main'))
{{-- layouts/admin/sections/sidebar.blade.php --}}
<acun:slot:nav><acun:menu :menu="$menu" /></acun:slot:nav>
{{-- layouts/admin/sections/navbar.blade.php --}}
<acun:slot:search><acun:command-palette :items="$menu->searchItems()" :placeholder="__('Search pages…')" /></acun:slot:search>
In the horizontal layout, the same file is drawn with horizontal in the top bar and in its vertical form in the mobile drawer:
<acun:slot:nav><acun:menu :menu="$menu" horizontal /></acun:slot:nav>
<acun:slot:mobileNav><acun:menu :menu="$menu" /></acun:slot:mobileNav>
No service provider or View::share is needed; the file is read only in the layout that draws the menu. acun:menu accepts one of these: a file name (menu="main"), a file path, an array or a Menu object. If you also use the menu for search, read it once and pass the object to both places.
Section headings aren't written in the horizontal menu. Instead, the sections prop can gather each section into one dropdown: true always groups, false never does. The default, auto, groups when there are more than six items at the top level.
Multiple menus
Each menu is a separate file, and you pass its name to fromFile. For example, the admin panel and the customer panel can use different menus:
resources/menu/
├── main.json admin panel
├── customer.json customer panel
└── horizontal.json a separate menu for the horizontal layout (optional)
@php($menu = \Acun\Ui\Menu\Menu::fromFile('customer'))
If the horizontal menu should differ from the vertical one, create horizontal.json and use Menu::fromFile('horizontal') in the horizontal layout. Otherwise both layouts share the same file, and you update the menu in one place.
Building the menu from the database
The menu definition can also be an array. For example, you can keep a menu for each customer account in the database:
$menu = \Acun\Ui\Menu\Menu::resolve(['menu' => $account->menu_items]);
Even when the data comes from users, dangerous URLs such as javascript: aren't rendered; url and href values are limited to safe schemes. An unknown icon name doesn't break the page either; the icon is simply not shown.
Authorization
The menu doesn't check permissions: every item in the definition is rendered. To show a different menu based on roles or permissions, filter the definition yourself in PHP:
$items = json_decode(file_get_contents(resource_path('menu/main.json')), true)['menu'];
$items = array_values(array_filter($items, fn ($item) => ($item['title'] ?? '') !== 'Users' || auth()->user()->isAdmin()));
$menu = \Acun\Ui\Menu\Menu::resolve($items);
In a hand-written menu, wrap the item in Blade's @can directive. Hiding an item from the menu doesn't protect the page; protect the routes separately with your own middleware or policies.
Errors
- Menu file not found:
fromFile('main')looks for the file atresources/menu/main.json. Check the name and location, or pass a full file path. - JSON error: If the file isn't valid JSON, reading stops with an error, e.g. for an extra comma after the last item. The error message tells you what kind of problem it is.
- The link becomes
#: The route name in theroutefield isn't defined, or theurluses an unsafe scheme. - The item doesn't appear active: Add the route pattern of its subpages to the
activefield, e.g.customers.*.
Command palette
acun:command-palette is the search box in the top bar. It opens with ⌘K or Ctrl+K and can be navigated with the keyboard. It matches case by the page's language; on a Turkish page, "İSTANBUL" matches "istanbul".
| Prop | What it does |
|---|---|
items |
The list; each item takes a label, href, group and hint. |
placeholder |
The text in the box; defaults to "Search…" in the page's language. |
empty |
The text shown when nothing matches; defaults to "No results found". |
shortcut |
false turns off the ⌘K shortcut. |
Menu::searchItems() builds the list from the menu. To add pages that aren't in the menu, extend the array:
<acun:command-palette :items="[...$menu->searchItems(), ['label' => __('Help'), 'href' => route('help'), 'group' => __('Other')]]" />
The palette also opens when a command-palette-open event is dispatched on the window. Unsafe href values become #.
Hand-written menu
Instead of JSON, you can also use the menu components directly: acun:nav.heading, acun:nav.item, acun:nav.group, acun:nav.sub-item, acun:nav.sub-group, acun:nav.coming-soon. For the horizontal menu there are acun:nav.horizontal-item and acun:nav.horizontal-group. You'll find examples on the component pages.
Advanced: transforming items
If you create the Menu object directly, you can pass two optional functions. transform is applied to every item before it's rendered, including section headings and children. isActive lets you decide yourself whether an item is active; if it returns null, the default rule applies.
use Acun\Ui\Menu\Menu;
$items = json_decode(file_get_contents(resource_path('menu/main.json')), true)['menu'];
$menu = new Menu(
$items,
transform: fn (array $item) => ($item['route'] ?? null) === 'tickets.index' ? [...$item, 'badge' => $openTicketCount] : $item,
isActive: fn (array $item) => null,
);
Titles are already translated automatically; you don't need to call __() in transform.
Learn more
- Your first page: a page, a route and a menu entry together.
- Layouts: the slots the menu goes into.
- Languages and translation: translating menu titles.