EN
Getting started

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

  1. Define the routes with names, e.g. projects.index (the list) and projects.show (the detail page).

  2. Add a line to the menu array in main.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, tel and in-app URLs are accepted. Anything else becomes #.
  • active: Write a route name or a /path pattern; 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 without active never appear active on any page.
  • badge: On an item with children, true shows the number of children.
  • target: With any value, the link opens with a full page load. "_self" opens it in the same tab.
  • children: children on 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 at resources/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 the route field isn't defined, or the url uses an unsafe scheme.
  • The item doesn't appear active: Add the route pattern of its subpages to the active field, 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

Acun UIDesigned for people.