Skip to main content
Wintertrace
Browse the documentation

Building modules

Navigation & dashboard

Add navigation items and dashboard widgets from a module: the registry calls, ordering, permissions and what a widget can render.

A module can extend the UI in three places: the admin sidebar, the customer portal menu, and the dashboard. All three work through registries filled in your module’s boot() phase (see The ServiceProvider pattern) — no core files are touched.

Adding a navigation item to the admin area

Navigation items go through the NavigationRegistry. A full entry shows all available fields:

$nav = app(NavigationRegistry::class);

$nav->addItem(
    group: 'system',           // group key (see table below)
    slug: 'my-module-settings', // unique identifier
    label: 'my-module::messages.nav_label', // TRANSLATION KEY — resolved at render (see warning below)
    route: 'admin.my-module.settings', // Laravel named route
    icon: 'M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0z', // raw SVG path — see "Icon format"
    order: 200,                // position within group
    permission: 'settings.view', // null = always visible
    routeCheck: 'admin.my-module.settings', // hide item if route is missing — see "routeCheck"
    activePattern: 'admin.my-module.*', // route name pattern for active state
);

Three of these fields contain pitfalls that are worth examining individually: label, icon, and routeCheck.

label is a translation KEY — never call __() at registration ⚠️

The most important detail: pass the raw translation key as label, not the already-translated text.

Why: Navigation is registered during boot()before the per-user or per-customer locale (SetUserLocale / EnsureDriver / EnsureCustomer) and the application-wide default_locale are applied. At boot only the fallback locale (de) is active. The NavigationRegistry is built for exactly this: it stores the raw label and resolves it lazily at render time via __() (see app/Services/Extension/ResolvesNavigationLabels.php, used by both the admin and portal registries). A single registry instance therefore serves every request in the correct language.

The trap: Passing an already-translated value defeats the lazy resolution and freezes the label to the boot locale (German). The page body continues to translate correctly (its Blade calls __() at render time), so only the menu label stays German — a confusingly half-translated UI.

// ❌ WRONG — __() runs at boot, freezes the label to German.
//    The page body still translates (its Blade calls __() at render time),
//    so only the menu label stays German — a confusing half-translated UI.
$nav->addItem(label: __('my-module::messages.nav_label'), ...);

// ❌ ALSO WRONG — a literal display string is never translated at all.
$nav->addItem(label: 'My Module', ...);

// ✅ CORRECT — pass the raw key; the core resolves it per request.
$nav->addItem(label: 'my-module::messages.nav_label', ...);

Define the key in every language file (lang/de/messages.php, lang/en/messages.php, …). A missing key falls back to the key string itself. A half-translated menu almost always has one of two root causes: __() called at registration, or a missing translation key. The same rule applies to addGroup() labels and to PortalNavigationRegistry::addItem() (see portal navigation below). This is a module-side contract: the core cannot un-freeze a value your module already translated before handing it over.

Icon format — raw SVG path geometry, not a Heroicon name ⚠️

The icon field expects the raw geometry of an SVG path, not the name of an icon component. The admin sidebar renderer draws the icon as path geometry inside a fixed <svg viewBox="0 0 24 24">:

@foreach(explode('||', $item['icon']) as $path)
    <path stroke-linecap="round" stroke-linejoin="round" d="{{ $path }}" />
@endforeach

icon: must therefore be the content of the d= attribute of an SVG path — the raw geometry, for example from the “Copy SVG” of a Heroicons outline icon, with the <path d="…"> wrapper stripped. It is not a component name. Passing 'heroicon-o-cog' renders <path d="heroicon-o-cog" /> — invalid path data, so a blank icon with no error. For icons that use multiple paths, join the individual d strings with ||.

routeCheck — guard against missing routes

routeCheck should normally be set to the item’s own route name. This pattern prevents a single module from bringing down the entire admin area — so it is worth understanding the “why”.

The renderer only emits the <a href="..."> link when Route::has() returns true for the stored route. With routeCheck unset, the link renders unconditionally. If the route does not exist, the route() call throws a RouteNotFoundException — and that exception does not only affect this one menu item: it crashes every admin page with a 500, because the menu is rendered on each page.

This happens in two realistic situations: on installations that cache routes (route:cache) and when modules are installed or removed at runtime. In both cases, an expected route may temporarily be absent. With routeCheck set, the core silently hides the affected menu item rather than crashing the UI. Background on route caching: Module lifecycle.

The admin area has five predefined groups. Pass one of these keys in group to place your item in the appropriate section:

KeyLabelOrderContents
topDashboard0Dashboard link
stammdatenStammdaten10Customers, drivers, vehicles
einsaetzeEinsätze20Jobs, work shifts
auswertungenAuswertungen30Reports, exports
systemSystem40Settings, users, modules, alerts

These labels are the system’s default German values; per-locale translations override them.

Adding a custom group

If the existing groups do not fit, create your own. The label is also a translation key here:

$nav->addGroup('my-module-group', 'my-module::messages.nav_group', 25); // label = translation key, resolved at render
// Then add items to group 'my-module-group'

The third parameter (25) is the sort position. Using a value between two core groups places your group exactly where you want it — 25 sits between einsaetze (20) and auswertungen (30).

The badge field attaches a small marker to the menu item — for example to show the count of open alerts:

$nav->addItem(
    group: 'system',
    slug: 'my-module-alerts',
    label: 'my-module::messages.nav_alerts', // translation key, not literal text
    route: 'admin.my-module.alerts',
    icon: 'M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0z', // raw SVG path — see "Icon format"
    order: 190,
    badge: '<span class="badge bg-red-500 text-white text-xs px-1.5 py-0.5 rounded-full">3</span>',
);

Permission-gated navigation

Setting permission means only users whose roles include that permission can see the item:

$nav->addItem(
    group: 'auswertungen',
    slug: 'advanced-reports',
    label: 'my-module::messages.nav_reports', // translation key, not literal text
    route: 'admin.my-module.reports',
    icon: 'M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0z', // raw SVG path — see "Icon format"
    permission: 'my-module.reports.view', // only users with this permission see this
);

How to define your own permissions is covered in Permissions & roles.

Portal navigation

Modules can also add items to the customer portal menu (desktop and mobile) via the PortalNavigationRegistry — the portal counterpart to the admin NavigationRegistry (since version 1.1.3):

$nav = app(\App\Services\Extension\PortalNavigationRegistry::class);

$nav->addItem(
    slug: 'contracts',
    label: 'my-module::portal.nav_contracts', // TRANSLATION KEY (resolved at render)
    route: 'portal.contracts.index',
    order: 25,
    activePattern: 'portal.contracts.*',
    condition: fn (\App\Models\Customer $c) => true, // optional per-customer visibility
);

Several differences from admin navigation follow from the portal’s context:

  • No permissions — the portal uses the customer guard, which has no Gates. Instead of permission, use the optional condition closure (receives the Customer) to hide an item.
  • label is still a translation key, not translated text — the portal locale is per-request, so the layout resolves it with __() at render time.
  • No icon — portal tabs are text only.
  • Items appear automatically in both the desktop navigation and the mobile menu.

The five core items (home, jobs, reports, notifications, profile) are registered by the core. More detail in Portal dashboards and on the PortalNavigationRegistry in Registries.

Dashboard widgets

Alongside navigation items, a module can place its own tiles on the dashboard via the DashboardWidgetRegistry:

$widgets = app(DashboardWidgetRegistry::class);

$widgets->registerWidget('my-module-status', [
    'label' => 'Module Status',
    'view' => 'my-module::widgets.status-card',
    'order' => 150,
    'size' => 'half',  // 'full' or 'half'
]);

Widget with a data callback

If the widget needs to display live numbers, provide a dataCallback. It runs at render time so the values are always current:

$widgets->registerWidget('my-module-stats', [
    'label' => 'Module Statistics',
    'view' => 'my-module::widgets.stats',
    'dataCallback' => function () {
        return [
            'total_sent' => DB::table('mod_notifications')->count(),
            'failed' => DB::table('mod_notifications')->where('status', 'failed')->count(),
        ];
    },
    'order' => 160,
    'size' => 'half',
]);

The callback’s return value is available in the Blade view as $data:

{{-- resources/views/widgets/stats.blade.php --}}
<div class="bg-white rounded-lg shadow p-4">
    <h3 class="text-sm font-medium text-gray-500">{{ $label }}</h3>
    <div class="mt-2 flex justify-between">
        <span class="text-2xl font-bold">{{ $data['total_sent'] }}</span>
        @if($data['failed'] > 0)
            <span class="text-red-600">{{ $data['failed'] }} fehlgeschlagen</span>
        @endif
    </div>
</div>

Conditional widget

condition shows a widget only when it is needed — for example a warning that only appears when there is a problem:

$widgets->registerWidget('cron-warning', [
    'label' => 'Cron Warning',
    'view' => 'my-module::widgets.cron-warning',
    'order' => 20,
    'size' => 'full',
    'condition' => fn () => !CronService::isHealthy(),
]);

Permission-gated widget

Like navigation items, permission limits visibility to users who hold that permission:

$widgets->registerWidget('admin-only-widget', [
    'label' => 'Admin Widget',
    'view' => 'my-module::widgets.admin-only',
    'order' => 200,
    'permission' => 'settings.view', // only shown to users with this permission
]);

Widget sizes

The size field controls how much of the dashboard row the widget occupies:

SizeBehaviour
'full'Takes the full width of the dashboard
'half'Takes half the width (two widgets side by side)

Error handling

If a dataCallback throws an exception, the dashboard does not crash. Instead:

  • The widget is still rendered.
  • $data is null.
  • $error is true.
  • A warning is logged.

Your widget view should handle this case cleanly:

@if($error)
    <div class="text-red-600 text-sm">Widget data could not be loaded.</div>
@else
    {{-- Normal widget content --}}
@endif

This keeps the dashboard usable even when a single module widget cannot load its data. The complete API for all registries used here is documented in Registries.