Zum Hauptinhalt springen
Schneespur
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.

Der Admin-Bereich hat fünf vordefinierte Gruppen. Übergeben Sie bei group einen dieser Schlüssel, landet Ihr Menüpunkt in der passenden Sektion:

KeyLabel (DE)OrderInhalt
topDashboard0Dashboard-Link
stammdatenStammdaten10Kunden, Fahrer, Fahrzeuge
einsaetzeEinsätze20Aufträge, Schichten
auswertungenAuswertungen30Berichte, Exporte
systemSystem40Einstellungen, 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.

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 einer permission blenden Sie Punkte über die optionale condition-Closure aus; sie erhält den Customer.
  • label ist 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:

SizeVerhalten
'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.
  • $data ist null.
  • $error ist true.
  • 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.