Dokumentation durchblättern
Module entwickeln
Navigation & Dashboard
Wie ein Modul Menüpunkte in Admin- und Kundenportal sowie Widgets auf das Dashboard bringt — Übersetzungs-Schlüssel, Icon-Format und der routeCheck gegen 500er-Fehler.
Module erweitern die Oberfläche an drei Stellen: das Menü im Admin-Bereich, das Menü im
Kundenportal und die Widgets auf dem Dashboard. Alle drei laufen über Registries, die das
Modul in der boot()-Phase seines ServiceProviders
befüllt — ohne eine Core-Datei anzufassen.
Einen Menüpunkt im Admin-Bereich anlegen
Menüpunkte gehen über die NavigationRegistry. Ein vollständiger Eintrag zeigt alle
verfügbaren Felder:
$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
);
Drei dieser Felder bergen Stolperfallen, die wir im Folgenden einzeln erklären: label,
icon und routeCheck.
label ist ein Übersetzungs-SCHLÜSSEL — niemals __() bei der Registrierung
Das wichtigste Detail zuerst: Bei label übergeben Sie den rohen Übersetzungs-Schlüssel,
nicht den fertig übersetzten Text.
Warum: Die Navigation wird während boot() registriert — also bevor die Sprache des
jeweiligen Nutzers oder Kunden feststeht. Zum Boot-Zeitpunkt ist nur die Fallback-Sprache
(de) aktiv. Die NavigationRegistry ist genau dafür gebaut: Sie speichert das rohe
label und löst es erst beim Rendern über __() auf. So bedient eine einzige
Registry-Instanz jede Anfrage in der korrekten Sprache.
Die Falle: Übergeben Sie einen bereits übersetzten Wert, hebeln Sie diese späte
Auflösung aus und frieren den Text auf die Boot-Sprache (Deutsch) ein. Der Seiteninhalt
übersetzt dann weiterhin korrekt (sein Blade ruft __() beim Rendern), nur der Menüpunkt
bleibt deutsch — eine verwirrend halb übersetzte Oberfläche.
// ❌ 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', ...);
Definieren Sie den Schlüssel anschließend in jeder Sprachdatei (lang/de/messages.php,
lang/en/messages.php, …). Ein fehlender Schlüssel fällt auf die Schlüssel-Zeichenkette
selbst zurück. Ein halb übersetztes Menü hat deshalb fast immer eine von zwei Ursachen: ein
__() schon bei der Registrierung, oder ein fehlender Übersetzungs-Schlüssel.
Dasselbe gilt für addGroup()-Labels und für PortalNavigationRegistry::addItem() (siehe
unten). Das ist ein Vertrag auf Modul-Seite: Der Core kann einen Wert nicht wieder
„auftauen”, den ein Modul vor der Übergabe bereits übersetzt hat.
Icon-Format — rohe SVG-Pfad-Geometrie, kein Heroicon-Name
Das icon-Feld erwartet die rohe Geometrie eines SVG-Pfades, nicht den Namen einer
Icon-Komponente. Der Sidebar-Renderer im Admin-Bereich zeichnet das Icon als Pfad-Geometrie
in ein festes <svg viewBox="0 0 24 24">:
@foreach(explode('||', $item['icon']) as $path)
<path stroke-linecap="round" stroke-linejoin="round" d="{{ $path }}" />
@endforeach
icon: muss also der Inhalt des d=-Attributs eines SVG-Pfades sein — die rohe Geometrie,
etwa aus dem „Copy SVG” eines Heroicons-Outline-Icons, ohne den
umschließenden <path d="…">. Es ist kein Komponenten-Name: Übergeben Sie
'heroicon-o-cog', rendert der Renderer <path d="heroicon-o-cog" /> — ungültige
Pfaddaten, also ein leeres Icon, und zwar ohne Fehlermeldung. Für Icons aus mehreren Pfaden
verbinden Sie die einzelnen d-Zeichenketten mit ||.
routeCheck — Schutz vor fehlenden Routen
routeCheck setzen Sie normalerweise auf den eigenen Routennamen des Menüpunkts. Dieses
Muster verhindert, dass ein einzelnes Modul den gesamten Admin-Bereich lahmlegt — deshalb
lohnt sich ein genauer Blick auf das „Warum”.
Der Renderer gibt den Link <a href="..."> nur dann aus, wenn Route::has() für die
hinterlegte Route true liefert. Ist routeCheck nicht gesetzt, rendert er den Link
bedingungslos. Existiert die Route dann einmal nicht, wirft der route()-Aufruf eine
RouteNotFoundException — und die fällt nicht nur für diesen einen Menüpunkt an, sondern
legt jede Admin-Seite mit einem 500er lahm, weil das Menü auf jeder Seite gerendert
wird.
Genau das passiert in zwei realistischen Situationen: auf Installationen, die Routen
zwischenspeichern (route:cache), und wenn Module zur Laufzeit installiert oder entfernt
werden. In beiden Fällen kann eine erwartete Route vorübergehend fehlen. Mit routeCheck
blendet der Core den betroffenen Menüpunkt dann still aus, statt die Oberfläche
abstürzen zu lassen. Hintergrund zum Routen-Caching:
Modul-Lebenszyklus.
Navigations-Gruppen
Der Admin-Bereich hat fünf vordefinierte Gruppen. Übergeben Sie bei group einen dieser
Schlüssel, landet Ihr Menüpunkt in der passenden Sektion:
| Key | Label (DE) | Order | Inhalt |
|---|---|---|---|
top | Dashboard | 0 | Dashboard-Link |
stammdaten | Stammdaten | 10 | Kunden, Fahrer, Fahrzeuge |
einsaetze | Einsätze | 20 | Aufträge, Schichten |
auswertungen | Auswertungen | 30 | Berichte, Exporte |
system | System | 40 | Einstellungen, Nutzer, Module, Warnungen |
Eine eigene Gruppe anlegen
Reichen die vorhandenen Gruppen nicht, legen Sie eine eigene an. Auch hier ist das label
ein Übersetzungs-Schlüssel:
$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'
Der dritte Parameter (25) ist die Sortier-Position. Mit einem Wert zwischen zwei
Core-Gruppen schieben Sie Ihre Gruppe genau an die gewünschte Stelle — 25 liegt etwa
zwischen einsaetze (20) und auswertungen (30).
Menüpunkt mit Badge
Über das badge-Feld hängen Sie eine kleine Markierung an den Menüpunkt, etwa für die Zahl
offener Warnungen:
$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: 'heroicon-o-bell',
order: 190,
badge: '<span class="badge bg-red-500 text-white text-xs px-1.5 py-0.5 rounded-full">3</span>',
);
Menüpunkt mit Berechtigung
Setzen Sie permission, sehen den Punkt nur Nutzer, deren Rollen diese Berechtigung
enthalten:
$nav->addItem(
group: 'auswertungen',
slug: 'advanced-reports',
label: 'my-module::messages.nav_reports', // translation key, not literal text
route: 'admin.my-module.reports',
icon: 'heroicon-o-chart-bar',
permission: 'my-module.reports.view', // only users with this permission see this
);
Wie Sie eigene Berechtigungen definieren, steht unter Berechtigungen & Rollen.
Navigation im Kundenportal
Module können auch das Menü im Kundenportal erweitern (Desktop und mobil) — über die
PortalNavigationRegistry, das Gegenstück zur Admin-NavigationRegistry (seit 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
);
Gegenüber der Admin-Navigation gelten einige Unterschiede, die aus dem anderen Kontext des Portals folgen:
- Keine Berechtigungen — das Portal nutzt den
customer-Guard, der keine Gates kennt. Statt einerpermissionblenden Sie Punkte über die optionalecondition-Closure aus; sie erhält denCustomer. labelist auch hier ein Übersetzungs-Schlüssel, kein fertiger Text — die Portal-Sprache steht pro Anfrage fest, also löst das Layout den Schlüssel beim Rendern mit__()auf.- Kein Icon — Portal-Reiter sind reiner Text.
- Punkte erscheinen automatisch in beiden Ansichten: in der Desktop-Navigation und im mobilen Menü.
Die fünf Kern-Punkte (home, jobs, reports, notifications, profile) registriert
der Core. Mehr dazu unter Portal-Dashboards und zur
PortalNavigationRegistry unter Registries.
Dashboard-Widgets
Neben Menüpunkten kann ein Modul eigene Kacheln auf das Dashboard bringen. Das läuft über
die 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 mit Daten-Callback
Soll das Widget Zahlen anzeigen, hinterlegen Sie einen dataCallback. Er wird beim Rendern
ausgeführt, sodass die Werte immer aktuell sind:
$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',
]);
In der Blade-View steht das Ergebnis des Callbacks als $data bereit:
{{-- 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>
Widget mit Bedingung
Über condition zeigen Sie ein Widget nur dann, wenn es gebraucht wird — etwa eine Warnung,
die erst bei einem Problem erscheint:
$widgets->registerWidget('cron-warning', [
'label' => 'Cron Warning',
'view' => 'my-module::widgets.cron-warning',
'order' => 20,
'size' => 'full',
'condition' => fn () => !CronService::isHealthy(),
]);
Widget mit Berechtigung
Wie bei Menüpunkten begrenzt permission die Sichtbarkeit auf Nutzer mit der jeweiligen
Berechtigung:
$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-Größen
Das size-Feld steuert die Breite auf dem Dashboard:
| Size | Verhalten |
|---|---|
'full' | nimmt die volle Dashboard-Breite ein |
'half' | nimmt die halbe Breite ein (zwei Widgets nebeneinander) |
Fehlerbehandlung
Wirft ein dataCallback eine Exception, stürzt das Dashboard nicht ab. Stattdessen:
- Das Widget wird trotzdem gerendert.
$dataistnull.$erroristtrue.- Eine Warnung wird protokolliert.
Ihre Widget-View sollte diesen Fall sauber abfangen:
@if($error)
<div class="text-red-600 text-sm">Widget data could not be loaded.</div>
@else
{{-- Normal widget content --}}
@endif
So bleibt das Dashboard auch dann benutzbar, wenn ein einzelnes Modul-Widget seine Daten gerade nicht laden kann. Die vollständige API aller hier genutzten Registries steht unter Registries.