Menus
Martis exposes a declarative, package-native navigation API.
Use it in two layers:
- resource-level menu items via
menuItem() - application-level menu composition via
Martis::mainMenu(...)
Resource Menu Items
Every resource can customize how it appears in navigation by overriding menuItem():
use Illuminate\Http\Request;use Martis\Menu\MenuItem;use Martis\Resource; class TicketResource extends Resource{ public function menuItem(Request $request): MenuItem { return MenuItem::resource(static::class) ->label('Help Desk') ->icon('lifebuoy') ->path('/support/help-desk'); }}If you do not override menuItem(), Martis generates a resource item automatically from:
label()singularLabel()subtitle()icon()group()displayInNavigation()
Main Menu Builder
Use the Martis facade to compose the main menu:
use Illuminate\Http\Request;use Martis\Facades\Martis;use Martis\Menu\Menu;use Martis\Menu\MenuItem;use Martis\Menu\MenuSection; Martis::mainMenu(function (Request $request, Menu $menu): Menu { return $menu->prepend( MenuSection::make('Quick Links', [ MenuItem::link('Dashboard', '/')->icon('squares-four'), MenuItem::externalLink('Documentation', 'https://example.com/docs')->icon('book-open'), ])->collapsable(false) );});A menu is cached per user and locale (the navigation cache layer, 1 minute by default). A resolver that also depends on the host, a tenant header or another value of the request needs Martis::cacheScopeUsing(), or the user gets the menu built first for whichever tenant: see Scoping the keys by host or tenant.
Available Builders
Menu
Menu::make([...])append(...)prepend(...)sections([...])
MenuSection
MenuSection::make(?string $label = null, array $items = [])—$labelis optional; omit it for an unnamed clusteritems([...])add(MenuItem|MenuGroup|class-string $item)— accepts leaves OR a nestedMenuGroupicon(...)collapsable(bool)path(?string $url)— when set, the section header is rendered as a link to this URL (see Clickable headers)section(?string $section)— assign this group to a higher-level section divider (see Sections)canSee(...)withMeta([...])
MenuGroup
Mid-level cluster nested inside a MenuSection. See Nested MenuGroup.
MenuGroup::make(string $label, array $items = [])items([...])add(...)icon(...)collapsable(bool)path(?string $url)— clickable group label; see Clickable headerscanSee(...)withMeta([...])
MenuItem
MenuItem::make($label, $url)— alias forlink()MenuItem::link($label, $url): a path below the Martis base path, opened without a full reload. It can be a page your app registered (v2.2.0+); for a page a Tool guards,MenuItem::tool(ToolClass::class)->path('/findings')shows the entry only to the users the Tool is visible to.MenuItem::externalLink($label, $url): a link to another site. The panel renders the URL as the link'shrefonly when it is a web URL (http(s)), a path, ormailto:/tel:; since v2.4.0 any other scheme (javascript:,data:, ...) leaves the link without anhref. The command palette entry for the same item opens amailto:/tel:URL in a new tab too, and refuses any other non-web scheme with a console error.MenuItem::resource(ResourceClass::class)MenuItem::tool(ToolClass::class | $toolInstance)— see Tool menu itemsMenuItem::dashboard(DashboardClass::class | $dashboardInstance)— see Dashboard, Lens & Filter itemsMenuItem::lens(ResourceClass::class, LensClass::class)— see Dashboard, Lens & Filter itemsMenuItem::filter(string $label, ResourceClass::class)->applies(FilterClass::class, $value)— see Dashboard, Lens & Filter itemslabel(...)icon(...)path(...)external(bool)withBadge(string $text, string $tone = 'neutral')— decorative chip ("New", "Beta", "Pro"). See Decorative badgescanSee(...)withMeta([...])
The same MenuItems and MenuGroups feed the command palette through Martis::commandPalette(): see Components → App commands.
Tool menu items
Since v1.8.20, every registered Tool is auto-grouped into the sidebar by default. A Tool that declares withMenuSection('Operations') lands in the "Operations" section; everything else goes under the localised "Tools" header (translation key martis::messages.tools_section, default English label Tools). You only need to call MenuItem::tool(...) when you build a fully custom main menu via Martis::mainMenu(...) and want a Tool placed alongside hand-rolled links.
A Tool that calls withSystemSection() (v1.35.0+) skips the auto-grouping entirely and renders inside the bundled System section (the one holding the audit log and the "System cache" link), after the System-section resources and before the cache link unless you order the section (see Order inside the System section); its menuSection() is ignored. The Martis::mainMenu(...) dedup applies to it too: place it manually with MenuItem::tool(...) and the System section will not repeat it. A Tool that merely returns the label "System" from menuSection() is not merged (no label matching). See Tools → Place a Tool under "System".
use App\Martis\Tools\HealthCheck;use Martis\Menu\MenuItem;use Martis\Menu\MenuSection; // Custom main menu — auto-Tools sections are produced upstream and// passed in via `$menu`. `MenuItem::tool(...)` lets you cherry-pick.Martis::mainMenu(function ($request, $menu) { return $menu->prepend( MenuSection::make('Pinned', [ MenuItem::tool(HealthCheck::class), MenuItem::tool(HealthCheck::class)->label('Status')->icon('pulse'), ]) );});MenuItem::tool() accepts either a class-string or a tool instance, and reads name(), uriKey(), icon(), and authorizedToSee() lazily at request time. The rendered menu always reflects the live state of the tool — including authorization checks, which silently drop the item when the user is not allowed to see it.
Any combination of label(), icon(), and path() overrides the tool's defaults. Otherwise the item resolves to /tools/<uriKey> automatically.
Order inside the System section (v1.38.0+)
The bundled System section collects three kinds of entries: resources with belongsToSystemSection(), Tools with withSystemSection() and the "System cache" link. Each entry carries a weight and the section lists them lowest first:
| Entry | Weight | Default |
|---|---|---|
| System-section resource | systemSectionOrder(): int (override on the resource) | 100 |
| System-section Tool | withSystemSection(order: 10), or override systemSectionOrder() | 100 |
| "System cache" link | config('martis.cache.admin_ui_order') | 1000 |
Entries with the same weight keep the natural order: resources in registration order, then Tools in discovery order. So with no weight set anywhere the section renders exactly as before v1.38.0 (resources, then Tools, then the cache link), and setting one weight moves only that entry.
// Settings and System Events first, then the audit resources, the cache link last.class Settings extends Tool{ public function __construct() { parent::__construct(name: __('Settings'), uriKey: 'settings'); $this->withIcon('sliders')->withSystemSection(order: 10); }} class SystemEvents extends Tool{ public function __construct() { parent::__construct(name: __('System Events'), uriKey: 'system-events'); $this->withIcon('pulse')->withSystemSection(order: 20); }} class AuditLogResource extends Resource{ public function belongsToSystemSection(): bool { return true; } public function systemSectionOrder(): int { return 30; } // optional: 100 already sorts it after the Tools}// config/martis.php: move the cache link to the top of the section'cache' => [ // ... 'admin_ui_order' => 0,],The bundled ActionEventResource keeps the default 100; give your own entries a weight above or below it to place them around the audit log. Weights only order the System section. Per-entry authorisation is unchanged (an entry the user may not see is dropped, and the section disappears when nothing is left), the navigation cache stays keyed per user and locale, and the command palette (⌘K) keeps listing resources and Tools alphabetically with the "System" tag.
Building the section yourself. A host that wants full control can still assemble a "System" section in Martis::mainMenu(...). Every System-section resource or Tool it places is left out of the bundled section, and since v1.38.0 so is the cache link: when the resolved menu already holds an in-app link to /system/cache, Martis does not append its own, so the host's section is the only "System" header once it places every System entry (an entry left out still docks in the bundled section). Gate the link the way the bundled one is gated:
use Illuminate\Support\Facades\Gate; MenuItem::link(__('martis::messages.cache_admin_title'), '/system/cache') ->icon('database') ->canSee(fn ($request) => config('martis.cache.admin_ui', true) && Gate::forUser($request->user())->allows('manage-martis-cache')),Default Tools section
The auto-grouping pass runs before your custom Martis::mainMenu(...) resolver, so the resolver receives both the resource sections and the Tool sections in $menu. You can mutate, reorder, or filter them — same ergonomics as Resources. Manually placing a Tool with MenuItem::tool(...) after the auto-grouping ran will produce a duplicate entry; either drop the manual placement, or build sections from scratch and rely on MenuItem::tool(...) exclusively.
Dashboard, Lens & Filter items
Three first-class factories save you from hand-writing URLs and replicating
authorization checks. All three resolve lazily at request time, mirroring
the lazy semantics of MenuItem::tool().
use App\Martis\Dashboards\SalesDashboard;use App\Martis\Filters\StatusFilter;use App\Martis\Resources\TicketResource;use App\Martis\Resources\OverdueLens;use Martis\Menu\MenuItem;use Martis\Menu\MenuSection; MenuSection::make('Operations', [ // Resolves /dashboards/<uriKey>; respects Dashboard::authorizedToSee(). MenuItem::dashboard(SalesDashboard::class), // Resolves /resources/tickets/lens/overdue; respects both // Resource::authorizedToViewAny() and Lens::authorizedToSee(). MenuItem::lens(TicketResource::class, OverdueLens::class), // Builds /resources/tickets?filters={"status":"open"} (URL-encoded). MenuItem::filter('Open tickets', TicketResource::class) ->applies(StatusFilter::class, 'open') ->icon('lifebuoy'),]);Each factory accepts label(), icon(), path(), withBadge() and
canSee() like any other MenuItem. The factory-emitted URL is a
sensible default — call path() to override it.
The serialised type distinguishes them on the wire:
| Factory | Emitted type | URL pattern |
|---|---|---|
MenuItem::tool() | tool | /tools/<uriKey> |
MenuItem::dashboard() | dashboard | /dashboards/<uriKey> |
MenuItem::lens() | lens | /resources/<resource>/lens/<lensUriKey> |
MenuItem::filter() | filter | /resources/<resource>?filters=<json> (URL-encoded JSON) |
Which item is active
One sidebar item is active for the page, the deepest match. A filter item is active when the page is its resource's index with the same ?filters= payload, and the resource's own item is not active next to it. A ?filters= that no filter item of the menu carries (a dashboard tile or an email linking to a filtered index, a filter set in the Filters panel) keeps the resource's item active (v2.16.1+), so the user always sees where they are; the highlight follows the Filters panel as the URL changes. Up to v2.16.0 any ?filters= left the resource's item inactive, and such a page had no active item at all. A section or group with a path() on the same page stays inactive while an item inside it is active, as on the unfiltered index.
The index opened from a filter item shows the applied filters as pills next to the Filters button, with the filter panel collapsed (v2.18.3; it used to open the panel and push the table down). The user opens the panel by clicking the button.
Decorative badges
MenuItem::withBadge('New', 'success') paints a small textual chip next
to the item label. Distinct from the resource count badge:
the count badge is numeric and tenancy-aware; this one is purely decorative
and consumer-controlled.
MenuSection::make('Workspace', [ MenuItem::link('What is new', '/changelog')->withBadge('New', 'success'), MenuItem::link('AI Console', '/ai')->withBadge('Beta', 'warning'), MenuItem::resource(TicketResource::class)->withBadge('Pro', 'accent'),]);Available tones: neutral (default), info, success, warning,
danger, accent. They map to the same semantic palette used by the
Badge field.
Clickable headers
By default a MenuSection (or MenuGroup) header is a collapse toggle.
Calling ->path('/url') turns the header label into a link to that URL —
useful when the cluster has a dedicated landing page (Reports overview,
Settings index) you want users to reach with one click:
MenuSection::make('Reports', [ MenuItem::link('Daily', '/reports/daily'), MenuItem::link('Weekly', '/reports/weekly'),])->path('/reports'); // header now links to /reportsThe same API exists on MenuGroup. When path() is set, the collapse
chevron is omitted: the header is a single tap target.
Nested MenuGroup
A MenuSection can host MenuGroup containers as items, giving you a
third level of nesting for dense sidebars:
MenuSection ── label, optional section divider └── MenuGroup ── mid-level cluster (icon, collapsable, path) └── MenuItem (resource / link / lens / dashboard / ...)Reach for MenuGroup when a section gets too long to scan at a glance —
typical example, a "Settings" section with separate Auth, Tenancy and
Billing clusters:
use Martis\Menu\MenuGroup;use Martis\Menu\MenuItem;use Martis\Menu\MenuSection; MenuSection::make('Settings', [ MenuGroup::make('Auth', [ MenuItem::resource(UserResource::class), MenuItem::resource(RoleResource::class), MenuItem::resource(PermissionResource::class), ])->icon('lock-key')->path('/settings/auth'), MenuGroup::make('Tenancy', [ MenuItem::resource(WorkspaceResource::class), MenuItem::resource(InviteResource::class), ])->icon('buildings'), MenuItem::link('General', '/settings/general'),]);The frontend renders nested groups as a smaller, indented sub-cluster
under the parent section header, with their own collapse toggle. Mixing
flat MenuItem entries and nested MenuGroup entries inside the same
section is allowed and renders in declared order.
MenuGroup is currently 2-level only — a MenuGroup cannot itself
contain another MenuGroup. If you need deeper nesting open an issue
with the use case.
Authorization and Visibility
Resource menu items still respect:
displayInNavigation()authorizedToViewAny($request)
Declarative link items can be hidden with canSee(...).
Sections
Groups can sit under a higher-level section divider (think "Resources", "Platform") to split a dense sidebar into clusters without nesting another collapsible. Sections render as small all-caps headings above their groups and stay visually subtler than group labels so the two levels remain distinguishable.
Declared only on MenuSection inside a custom Martis::mainMenu(...)
builder — i.e. the same service provider where you build the menu.
Run every user-facing string through __(...) so the heading localises
with the rest of the UI:
Martis::mainMenu(function ($request, $menu) { $workspace = __('menu.section_workspace'); $platform = __('menu.section_platform'); return $menu->sections([ MenuSection::make(__('menu.operations'), [...])->section($workspace), MenuSection::make(__('menu.talents'), [...])->section($workspace), MenuSection::make(__('menu.audit'), [...])->section($platform), MenuSection::make(__('menu.settings'), [...])->section($platform), ]);});Leave ->section(...) off to skip the section heading for that group.
Count Badges
Each resource publishes a count badge next to its label (Users 1,284).
The value comes from the same indexQuery() hook that powers the listing
page, so tenancy and policy scopes apply automatically.
Opt out per-resource:
public static function showMenuCount(): bool{ return false;}Customise the value (useful for "unread", "pending", or computed counts):
use Illuminate\Http\Request; public static function menuCount(Request $request): ?int{ return Ticket::where('assignee_id', $request->user()->id) ->whereNull('resolved_at') ->count();}Return null from menuCount() to hide the badge without turning off
showMenuCount().
Disable badges globally with:
// config/martis.php'navigation' => [ 'counts' => ['enabled' => false],],or the MARTIS_NAV_COUNTS=false env var. The global switch wins over
per-resource opt-ins.
When a badge is missing
A badge can be absent for four reasons: the resource opted out via
showMenuCount(), menuCount() returned null, the user fails
authorizedToViewAny(), or menuCount() threw. The last one is the
easy one to misread: a throwing counter (a tenant scope with no tenant
resolved, a missing table, a timeout) hides only its own badge, and the
rest of the sidebar renders normally, so it looks like a styling glitch
when it is really a server-side exception on the same query that powers
the index page.
Since v1.32.4 that failure is no longer silent:
- It is sent through
report()as aMartis\Exceptions\MenuCountFailedExceptionwhose message names the Resource / Tool class and the badge key (resource:tickets,tool:standards), with the original exception attached asgetPrevious(). It lands wherever your exception handler reports to (log channel, Sentry, Flare, ...), and you can target that single class inwithExceptions()(dontReport,level,reportable) without touching the underlying exception types. - With dev tools on (
martis.dev.tools_enabled, the default inlocal/testing),GET /martis/api/navigation/badgesadds a reserved_failedkey listing each broken counter with a one-line description of its exception, so you can see at a glance which counters blew up (see Badges-Only API). The key is never emitted in production and is never a count; the SPA ignores it.
Turn the reporting off (back to silently skipping the badge) with:
// config/martis.php'navigation' => [ 'counts' => ['report_failures' => false], // MARTIS_NAV_COUNTS_REPORT_FAILURES=false],Both /api/navigation and /api/navigation/badges are cached under
the navigation cache layer (default TTL 1 minute; the badge map can use
a TTL in seconds, see Caching the counts), so a badge keeps
missing for the rest of that TTL after the underlying cause is fixed.
Run php artisan martis:cache:clear navigation to refresh immediately.
Caching the counts (v2.20.0)
Every count is computed when the user's badge map is built, so N admins run it N times per TTL. Three opt-ins change that, all documented in Cache:
menuCountCache()on a Resource (static) or Tool (instance):['scope' => 'shared'|'user', 'ttl' => <seconds>]caches that one count by itself, shared by every admin (a row total) or per user.martis.cache.navigation.badge_ttl_seconds(MARTIS_CACHE_BADGE_TTL_SECONDS): the badge map's TTL in seconds instead of whole minutes.Martis::menuCountsUsing(fn (Request $request, Collection $resources): array => ...): answer several resources' counts in one query. A class the resolver omits keeps its ownmenuCount(); a failure falls back to everymenuCount()and is reported.
Defaults reproduce today's behaviour. A shared count's key still carries the Martis::cacheScopeUsing() scope.
Compact notation
Counts above the configured threshold render in compact notation (10K,
1.2M) so dense sidebars stay readable. Default threshold is 10000:
// config/martis.php'navigation' => [ 'count_compact_threshold' => env('MARTIS_NAV_COUNT_COMPACT_THRESHOLD', 10000),],Lower the value (e.g. 1000) to compact earlier, or set it to a very
large number to disable compaction entirely.
Counts format in the user's Martis locale (v2.1.0): 1234 reads 1234 for pt_PT and 1,234 for en_US, and the compact form follows the same locale.
Live polling
The frontend keeps counts fresh through a lightweight badges endpoint that does not re-pull the full navigation tree.
/martis/api/navigationis fetched once per session (and on route mutations). Menu structure rarely changes in production, so there is no auto-poll./martis/api/navigation/badgesis polled at the configured cadence and returns a flat{ uriKey: count }map. The SPA merges it into the cached navigation tree on each tick. 5–10× cheaper server-side than the full tree.
'navigation' => [ 'badges_poll_interval' => (int) env('MARTIS_NAV_BADGES_POLL_MS', 300000), // v1.8.8],The default is 300 000 ms (5 minutes). Set it to 0 to disable
badge polling entirely — counts then refresh only on full navigation
or on the user's own mutations (which invalidate the badges query via
the React Query MutationCache).
Breaking change in v1.8.8.
MARTIS_NAV_POLL_MS/navigation.poll_intervalwas removed. Renaming toMARTIS_NAV_BADGES_POLL_MSis enough to migrate, but consider raising the cadence (the new default is 5 min, up from 60 s) because badges-only is cheap and menu structure rarely changes mid-session.
Navigation API Response
GET /martis/api/navigation returns an array of menu sections:
[ { "label": "Quick Links", "icon": null, "collapsable": false, "items": [ { "type": "link", "label": "Dashboard", "url": "/", "icon": "squares-four", "external": false } ] }, { "label": "Support", "icon": null, "collapsable": true, "items": [ { "type": "resource", "uriKey": "tickets", "label": "Help Desk", "singularLabel": "Ticket", "subtitle": "Customer support queue", "titleAttribute": "id", "softDeletes": false, "group": "Support", "icon": "lifebuoy", "url": "/support/help-desk", "external": false, "count": 42 } ], "section": "Resources" }, { "label": "Platform", "icon": "stack", "collapsable": true, "section": "Platform", "items": [ { "type": "resource", "uriKey": "queues", "label": "Queues", "url": "/resources/queues", "count": 0 } ] }]items is the canonical shape consumed by the Martis frontend. The list
is heterogeneous: a section's items may contain leaf items (type ∈
link | resource | tool | dashboard | lens | filter) and nested groups
(type === "group") mixed in declared order.
A nested MenuGroup serialises as:
{ "type": "group", "label": "Auth", "icon": "lock-key", "collapsable": true, "path": "/settings/auth", "items": [ { "type": "resource", "uriKey": "users", "label": "Users", "url": "/resources/users", "count": 0 }, { "type": "resource", "uriKey": "roles", "label": "Roles", "url": "/resources/roles", "count": 0 } ]}Decorative badges attached via withBadge() add a badge field on the
emitted item:
{ "type": "link", "label": "AI Console", "url": "/ai", "icon": "sparkle", "external": false, "badge": { "text": "Beta", "tone": "warning" }}Badges-Only API (v1.8.8)
GET /martis/api/navigation/badges returns a flat { key: count } map
keyed by "{type}:{uriKey}" (resource: or tool:, so a resource and
a tool sharing a uriKey never conflate, v1.29.0). Resources that opt
out of showMenuCount() or fail per-user authorization are excluded;
broken menuCount() calls are skipped so a single counter cannot take
the whole endpoint down, and reported through the app's exception
handler (see When a badge is missing).
{ "resource:users": 1284, "resource:invoices": 7, "tool:standards": 42}With dev tools on (martis.dev.tools_enabled), counters whose
menuCount() threw are listed under the reserved _failed key. It is
diagnostics only, never emitted in production, and ignored by the SPA:
{ "resource:users": 1284, "_failed": { "resource:tickets": "App\\Tenancy\\TenantNotResolvedException: No tenant is resolved in the current context" }}The endpoint shares the navigation cache layer with /api/navigation
but lives under a separate badges: cache prefix, so flushing one
does not poison the other (php artisan martis:cache:clear navigation
still wipes both, and the counts cached with menuCountCache()). Default poll cadence is 300 000 ms (5 minutes);
override per environment with MARTIS_NAV_BADGES_POLL_MS or per
boot via config('martis.navigation.badges_poll_interval', ...).