Skip to main content
A Section’s routes() and navigation() hooks are two halves of the same feature: routes() registers the server side of a page, and navigation() puts an entry in the sidebar that points at it. Everything here is drawn from lunarphp/panel-addon-example, which registers a full example of both — see Building an Add-on for how the surrounding package is scaffolded and compiled.

Registering routes

routes() on a Section or SectionExtension returns a Closure rather than eagerly registering routes, because PanelManager only runs it inside the panel’s own route group — so it automatically picks up the panel’s URL prefix, guard, and middleware. From src/ExampleSection.php:
The panel’s own Authenticate middleware only proves the visitor is signed-in staff — it does not gate individual routes. Gate a route with can: middleware and declare the same permission handle on the navigation item pointing at it, so what a user sees and what they can reach stay in lockstep. An add-on that extends an existing area (as here, extending Customers) reuses that area’s permission handle; an add-on with its own domain seeds and uses its own handle.

Building the Inertia page

Naming and registering the component

The Inertia component name passed to Inertia::render() is namespaced to match the key the add-on’s JS registers the page under: example-addon::Widgets/Index corresponds to a WidgetsIndexPage registered as 'example-addon::Widgets/Index' via window.LunarPanel.registerPages(). See Building an Add-on for the registration call.

The automatic layout

An add-on page renders inside the real panel chrome — nav sidebar, mobile drawer, collapse toggle — without importing or wrapping anything. The panel wraps every add-on page in its PanelLayout (the persistent layout) automatically, applied through the same layout registry window.LunarPanel.registerLayout() uses: PanelLayout is that registry’s default entry, and a page that declares no layout of its own gets it for free.

Using the standard page scaffolding

The panel exposes a page-building set at runtime through @lunarphp/panel — layout and chrome (PageHeader, PageZone, Breadcrumbs, SettingsShell), data (DataTable, Pagination, PageEmpty, StatusBadge), filters and stats (FilterDropdown, KpiCard), form inputs (TextInput, Select, Checkbox, and so on), overlays (Dialog, Slideout, ConfirmDialog, Tooltip, SideCard, Tabs), and Button/Icon. The add-on’s Vite plugin externalizes the @lunarphp/panel import to the panel’s own components (window.LunarPanelUI) exactly the way it externalizes vue, so nothing is duplicated. From resources/js/pages/Widgets/Index.vue:
PageHeader carries the shared page-action ellipsis, so header actions an add-on (or the host) registers for this page appear automatically without the page opting in. PageZone declares slot zones on the page — so other add-ons can inject into it, the same mechanism this package uses against the first-party Customers page. usePage() and <Link> work because @inertiajs/vue3 is externalized to the panel’s own Inertia instance (window.InertiaVue3); the add-on never bundles a second copy, which would read uninitialized state. DataTable is the same component every first-party listing page uses, and renders a page’s own rows the same way Widgets/Index.vue does:
A named #cell-{key} slot overrides how that column renders each cell; columns without a slot render their raw row value as text. Each row shares its column values plus an _actions map of per-row URLs — an action only renders on rows whose _actions map resolved a URL for its key. This is the add-on’s own table; to add columns or actions to a table owned by another section instead, use a TableExtension (see Extending the Admin Panel).

Adding a settings screen

The panel’s Settings section extends the same way as the main sidebar: settingsNavigation() mirrors navigation() but drives the Settings sidebar, and its routes live under a settings/... prefix. From src/ExampleSection.php:
An add-on can create its own group (as here) or add items to a first-party one (e.g. general). The item appears in the Settings sidebar for any staff member holding the permission; the settings entry route redirects to the first settings page the user can see, so an add-on item is reachable even if it’s the only one. The page itself renders inside <SettingsShell>, which scaffolds the whole screen the way first-party settings pages get it: the Settings sidebar, a Settings > {title} breadcrumb trail, the standard page header (title, optional description, #actions buttons, and the shared page-action ellipsis), flash message display, and a centered content column (wide for the full listing width top-level pages use). From resources/js/pages/Settings/Index.vue:
SettingsShell is the page’s entire chrome, so the page must opt out of the PanelLayout the panel would otherwise auto-apply — the defineOptions({ layout: ... }) no-op above. Skipping this renders the page with a doubled sidebar: the main nav wrapped around the settings shell’s own.
navigation(NavigationRegistry $registry) and settingsNavigation(NavigationRegistry $registry) share the same registry API:
  • $registry->group($key, $label, $priority = 50, $position = null) — creates a sidebar group if it doesn’t already exist. Add items to a group created by another section (e.g. a first-party general group) simply by using its key.
  • $registry->addItem($groupKey, new NavigationItem(...)) — adds an item to a group, creating the group with a default label if it doesn’t exist yet.
  • $registry->addTopLevelItem(new NavigationItem(...)) — adds an item outside any group.
  • $registry->addChildItem($parentItemKey, new NavigationItem(...)) — nests an item under another item, wherever that item currently lives.
NavigationItem takes: Labels, icons, and permission handles work identically in settingsNavigation(). Keep the permission handle on a navigation item and the can: middleware on the route it points at in sync — the panel does not derive one from the other.