Dokumentation durchblättern
Module entwickeln
Beispielmodul im Detail
Das mitgelieferte Referenzmodul unter modules/example/ zeigt jeden Erweiterungspunkt an lauffähigem Code — dieser Leitfaden erklärt Aufbau und Hintergrund Abschnitt für Abschnitt.
Der Anwendung liegt unter modules/example/ ein vollständiges Referenzmodul bei. Es
demonstriert jeden Erweiterungspunkt an einem Stück — von der Navigation über Filter und
Template-Slots bis zu Routen und API. Diese Seite begleitet dieses Modul: Sie zeigt den Code
und erklärt vor allem, warum die einzelnen Stellen so aufgebaut sind.
Das Modul ist standardmäßig deaktiviert und lädt nur, wenn EXAMPLE_MODULE_ENABLED=true in
der .env gesetzt ist. So liegt eine vollständige Vorlage bei, ohne im Normalbetrieb
mitzulaufen. Wie dieser Wächter funktioniert und warum produktive Module ihn nicht
verwenden sollten, steht unter ServiceProvider.
Verzeichnisstruktur
Ein Modul ist ein abgeschlossener Ordner. Schon an der Struktur lässt sich ablesen, welche
Fähigkeiten es mitbringt: ein src/-Verzeichnis mit ServiceProvider und Klassen, resources/views/
für Blade-Vorlagen, database/migrations/ für eigene Tabellen, dist/ für gebaute Assets
und lang/ für Übersetzungen.
modules/example/
├── module.json # Module manifest
├── src/
│ ├── ExampleServiceProvider.php # Main entry point
│ ├── Http/Controllers/
│ │ ├── ExampleSettingsController.php # Admin settings page
│ │ └── ExampleApiController.php # REST API endpoint
│ ├── Dispatch/
│ │ └── FirstAvailableStrategy.php # Dispatch strategy implementation
│ ├── Auth/
│ │ └── DummyTwoFactorMethod.php # 2FA method implementation
│ └── Notification/
│ └── DummyLogChannel.php # Notification channel implementation
├── resources/views/
│ ├── settings.blade.php # Settings page view
│ ├── slot-demo.blade.php # Admin slot demo content
│ ├── driver-slot-demo.blade.php # Driver layout slot demo
│ ├── portal-slot-demo.blade.php # Portal layout slot demo
│ └── widgets/
│ └── example-card.blade.php # Dashboard widget view
├── database/migrations/
│ └── 2026_01_01_000001_create_mod_example_logs_table.php
├── dist/
│ ├── manifest.json # Asset manifest
│ └── example-abc123.css # Compiled CSS
└── lang/
├── de/messages.php # German translations
└── en/messages.php # English translations
module.json
Die module.json ist das Manifest. Sie nennt den Namespace, den ServiceProvider als
Einstiegspunkt und die Mindestversion, gegen die das Modul gebaut wurde. Über
requires und conflicts lassen sich Abhängigkeiten und Unverträglichkeiten zu anderen
Modulen ausdrücken. Die Felder im Detail beschreibt das
Modul-Manifest.
{
"name": "Example Module",
"version": "1.0.0",
"namespace": "Schneespur\\Module\\Example",
"service_provider": "Schneespur\\Module\\Example\\ExampleServiceProvider",
"description": "Reference module demonstrating all extension points.",
"min_schneespur_version": "1.0.0",
"requires_permissions": [],
"default_enabled": false,
"requires": {},
"conflicts": []
}
default_enabled steht hier bewusst auf false: Das Referenzmodul soll nach der
Installation nicht ungefragt mitlaufen, sondern erst dann, wenn jemand es gezielt zum Lernen
aktiviert.
ExampleServiceProvider — annotiert
Der ServiceProvider ist der einzige Einstiegspunkt des Moduls. Hier zeigt sich das Grundprinzip:
In register() passiert nichts, weil dieses Modul nur über Registries erweitert und keine
eigenen Services in den Container bindet. Die gesamte Arbeit liegt in boot() — dort sind alle
anderen Provider bereits registriert, sodass das Auflösen der Registries sicher ist. Warum diese
Trennung zwischen den beiden Phasen wichtig ist, erklärt der
Modul-Lebenszyklus.
class ExampleServiceProvider extends ServiceProvider
{
public function register(): void
{
// Nothing to bind — this module only extends via registries
}
public function boot(): void
{
// Guard: only load when explicitly enabled via .env
if (! $this->shouldBoot()) {
return;
}
// Register the Blade view namespace
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'example-module');
// Register into every extension point
$this->registerSettings(); // ModuleManager settings
$this->registerNavigation(); // Admin sidebar
$this->registerWidget(); // Dashboard widget
$this->registerFilters(); // Filter hooks
$this->registerSlots(); // Template slots
$this->registerEventListeners(); // Domain events
$this->registerNotificationChannels(); // Notification transport
$this->registerTwoFactorMethods(); // 2FA method
$this->registerDispatchStrategy(); // Job dispatch algorithm
$this->registerRoutes(); // Admin web routes
$this->registerApiRoutes(); // Module API endpoints
}
}
Die boot()-Methode ist bewusst in kleine, sprechende Teilmethoden zerlegt. Jede registriert
genau einen Erweiterungspunkt. Das ist kein technischer Zwang, sondern eine Konvention, die das
Modul lesbar hält: Wer wissen will, wie ein bestimmter Punkt funktioniert, springt direkt in die
zuständige Methode.
Einstellungen registrieren
protected function registerSettings(): void
{
app(ModuleManager::class)->registerSettings('example', [
'greeting' => 'Hello from Example Module',
'enabled_features' => 'all',
]);
// Creates settings: example.greeting, example.enabled_features
// Skips if already exist (won't overwrite user changes)
}
Jeder Schlüssel wird mit dem Modul-Slug example vorangestellt gespeichert, also als
example.greeting und example.enabled_features. Dieses Präfix ist der Grund, warum sich die
Einstellungen eines Moduls beim Entfernen sauber aufräumen lassen — und warum vorhandene Werte
nicht überschrieben werden: Einmal gesetzte Einstellungen bleiben bei einem erneuten Boot
unangetastet. Mehr dazu unter
ServiceProvider.
Navigation registrieren
protected function registerNavigation(): void
{
$nav = app(NavigationRegistry::class);
$nav->addItem(
group: 'system', // add to System group
slug: 'example', // unique ID
label: 'Example', // display text
route: 'admin.example.settings', // target route
icon: 'heroicon-o-puzzle-piece', // icon
order: 200, // sort position (after core items)
);
}
Der Eintrag landet in der Gruppe system der Verwaltungs-Navigation. Der order-Wert von 200
sorgt dafür, dass er nach den Kern-Einträgen erscheint — Module ordnen sich bewusst hinter den
Standardpunkten ein, statt sie zu verdrängen. Die vollständige API von
NavigationRegistry steht unter
Navigation und Dashboard.
Dashboard-Widget
protected function registerWidget(): void
{
$widgets = app(DashboardWidgetRegistry::class);
$widgets->registerWidget('example-card', [
'label' => 'Example Module Active',
'view' => 'example-module::widgets.example-card',
'order' => 200,
'size' => 'half',
]);
}
Das Widget verweist über view auf eine Blade-Vorlage im Namespace example-module, der zuvor
in boot() registriert wurde. size steuert die Breite auf dem Dashboard. Auch hier gilt die
Reihenfolge über order.
Filter-Hooks
protected function registerFilters(): void
{
$filters = app(FilterRegistry::class);
// Add item to navigation via filter (alternative to NavigationRegistry)
$filters->register('schneespur.navigation.items', function (array $grouped): array {
$grouped['modules'][] = [
'group' => 'modules',
'slug' => 'example-filter',
'label' => 'Example Filter',
'route' => 'admin.example.settings',
'icon' => 'heroicon-o-funnel',
'order' => 250,
'permission' => null,
'route_check' => null,
'active_pattern' => 'admin.example.settings',
'badge' => null,
];
return $grouped;
}, 150);
// Add widget via dashboard filter
$filters->register('schneespur.dashboard.kpis', function (array $widgets): array {
$widgets[] = [
'key' => 'example-filter-widget',
'label' => 'Filter Demo',
'view' => 'example-module::widgets.example-card',
'order' => 250,
'size' => 'half',
];
return $widgets;
}, 150);
// Observe notification recipients
$filters->register('schneespur.job.notification.recipients', function (array $recipients, $job): array {
Log::info('ExampleModule: notification recipients filter', [
'job_id' => $job->id ?? null,
'recipient_count' => count($recipients),
]);
return $recipients;
}, 150);
}
Filter sind die zweite Art, einen Punkt einzuhängen: Statt direkt in eine Registry zu schreiben,
verändert ein Filter einen Wert, während der Core ihn durchreicht. Die ersten beiden Beispiele
zeigen das an Navigation und Dashboard — derselbe Effekt wie über die jeweilige Registry, nur über
den Filterweg. Das dritte Beispiel verändert nichts, sondern beobachtet nur die
Empfängerliste einer Benachrichtigung und gibt sie unverändert zurück. Genau das ist der
Unterschied zwischen verändernden und beobachtenden Filtern. Die Zahl 150 am Ende ist die
Priorität, über die sich die Reihenfolge mehrerer Filter steuern lässt. Details unter
Filter-Hooks.
Template-Slots
protected function registerSlots(): void
{
$slots = app(SlotRegistry::class);
$slots->append('admin.content.after', 'example-module::slot-demo');
$slots->append('driver.content.after', 'example-module::driver-slot-demo');
$slots->append('portal.content.after', 'example-module::portal-slot-demo');
}
Slots sind benannte Stellen in den Layouts, an denen ein Modul eigene Blade-Inhalte einhängt,
ohne die Layout-Dateien des Core zu verändern. Das Beispiel bedient bewusst alle drei Oberflächen
— Verwaltung, Fahrer und Portal — und zeigt damit, dass ein Modul in jeden dieser Bereiche
hineinwirken kann. Welche Slots es gibt und wie append gegenüber replace wirkt, steht unter
Template-Slots.
Event-Listener
protected function registerEventListeners(): void
{
$events = $this->app['events'];
$events->listen(JobCompleted::class, function (JobCompleted $event) {
Log::info('ExampleModule: JobCompleted', [
'job_id' => $event->job->id,
'weather_available' => $event->weatherAvailable,
]);
});
$events->listen(WorkShiftStarted::class, function (WorkShiftStarted $event) {
Log::info('ExampleModule: WorkShiftStarted', [
'shift_id' => $event->workShift->id,
'user_id' => $event->user->id,
]);
});
// Also listens to: CustomerUpdated, UserLoggedIn, ModuleEnabled
}
Über Events reagiert ein Modul auf das, was im Kern geschieht — etwa darauf, dass ein Einsatz abgeschlossen oder eine Schicht begonnen wurde. Das Referenzmodul schreibt in diesen Beispielen nur eine Log-Zeile, weil es zeigen soll, wann welches Event eintrifft und welche Daten es mitbringt. In einem echten Modul stünde an dieser Stelle die eigentliche Reaktion. Welche Events der Kern auslöst, listet Events.
Benachrichtigungs-Kanal
protected function registerNotificationChannels(): void
{
app(NotificationChannelRegistry::class)->register('dummy-log', DummyLogChannel::class);
}
Ein Benachrichtigungs-Kanal ist ein Transportweg für Meldungen. Das Modul registriert hier einen
zusätzlichen Kanal unter dem Slug dummy-log. Die Klasse selbst erfüllt das
NotificationChannelInterface und schreibt eine Benachrichtigung lediglich ins Laravel-Log —
nützlich zum Ausprobieren, ohne tatsächlich E-Mail oder Push zu versenden:
class DummyLogChannel implements NotificationChannelInterface
{
public function send(Job $job, string $type, array $context): void
{
Log::info('DummyLogChannel: notification dispatched', [
'job_id' => $job->id,
'type' => $type,
'recipient_count' => count($context['recipients'] ?? []),
]);
}
public function name(): string { return 'Log (Demo)'; }
public function slug(): string { return 'dummy-log'; }
public function isEnabled(): bool { return true; }
}
Das Muster ist überall gleich: Eine Klasse erfüllt das vom Core vorgegebene Interface, und der ServiceProvider trägt sie in die passende Registry ein. Mehr zu Kanälen unter Benachrichtigungen, mehr zu den Interfaces unter Interfaces.
Zwei-Faktor-Authentifizierung
protected function registerTwoFactorMethods(): void
{
app(TwoFactorMethodRegistry::class)->registerMethod('dummy-2fa', DummyTwoFactorMethod::class);
}
Auch ein Zwei-Faktor-Verfahren lässt sich über ein Modul ergänzen. Die Demo-Klasse akzeptiert in
verify() jeden Code und ist deshalb ausschließlich zum Ausprobieren gedacht — niemals für den
Produktivbetrieb:
class DummyTwoFactorMethod implements TwoFactorMethodInterface
{
private static array $enabled = [];
public function slug(): string { return 'dummy-2fa'; }
public function name(): string { return 'Dummy 2FA'; }
public function enable(User $user): void { self::$enabled[$user->id] = true; }
public function disable(User $user): void { unset(self::$enabled[$user->id]); }
public function verify(User $user, string $code): bool { return true; }
public function isEnabled(User $user): bool { return self::$enabled[$user->id] ?? false; }
}
Das verify(), das einfach true zurückgibt, ist ausdrücklich eine Vereinfachung für die
Demonstration. Ein echtes Verfahren prüft den Code hier gegen ein geteiltes Geheimnis oder einen
externen Dienst.
Dispatch-Strategie
protected function registerDispatchStrategy(): void
{
app(DispatchStrategyRegistry::class)->register('first_available', FirstAvailableStrategy::class);
}
Eine Dispatch-Strategie legt fest, nach welchem Verfahren ein Einsatz einem Fahrer zugewiesen wird. Die Demo-Strategie wählt schlicht den ersten Fahrer aus der Liste:
class FirstAvailableStrategy implements DispatchStrategyInterface
{
public function assign(Job $job, Collection $drivers): ?User
{
return $drivers->first();
}
public function canHandle(Job $job): bool { return true; }
public function label(): string { return 'First Available (Demo)'; }
}
Über canHandle() kann eine Strategie entscheiden, ob sie für einen bestimmten Einsatz überhaupt
zuständig ist. So lassen sich später mehrere Strategien nebeneinander betreiben. Der Zusammenhang
mit der Einsatzplanung steht unter Wetter und Planung.
Web-Routen
protected function registerRoutes(): void
{
Route::middleware(['web', 'auth'])
->prefix('admin/example')
->name('admin.example.')
->group(function () {
Route::get('settings', [ExampleSettingsController::class, 'index'])
->name('settings');
});
}
Die Web-Routen eines Moduls liegen unter einem eigenen Präfix (admin/example) und einem eigenen
Namens-Präfix (admin.example.). Das hält die Routen des Moduls von denen des Core getrennt und
verhindert Namenskollisionen. Die auth-Middleware stellt sicher, dass nur angemeldete Nutzer die
Einstellungsseite erreichen.
API-Routen
protected function registerApiRoutes(): void
{
app(ModuleApiRegistrar::class)->routes('example', 1, function () {
Route::get('status', [ExampleApiController::class, 'status'])
->name('status');
});
}
// Creates: GET /api/mod/example/v1/status
// Middleware: module.api:example (Bearer token auth)
Modul-API-Routen registriert man über den ModuleApiRegistrar. Er setzt automatisch den Pfad
/api/mod/example/v1/... zusammen — mit Modul-Slug und Versionsnummer im Pfad — und legt die
passende Middleware mit Bearer-Token-Prüfung darüber. Dadurch bekommt jedes Modul einen
abgegrenzten, versionierten API-Bereich. Mehr unter
Routen und APIs.
Migration
Schema::create('mod_example_logs', function (Blueprint $table) {
$table->id();
$table->string('level')->default('info');
$table->text('message');
$table->json('context')->nullable();
$table->timestamps();
});
Eigene Tabellen eines Moduls tragen das Präfix mod_ und den Modul-Slug im Namen
(mod_example_logs). Diese Benennung macht auf einen Blick sichtbar, dass die Tabelle zu einem
Modul gehört und nicht zum Kern. Wie Migrationen eines Moduls ausgeführt und zurückgerollt werden,
beschreibt Datenbank-Migrationen.
Asset-Manifest
[
{"type": "css", "file": "example-abc123.css"}
]
Das Manifest verweist auf die gebauten Frontend-Dateien im dist/-Ordner. Der Hash im Dateinamen
(example-abc123.css) sorgt dafür, dass Browser eine neue Version nicht aus dem Cache liefern.
Wie Module Assets bauen und einbinden, steht unter
Assets und Frontend.
Übersetzungen
// lang/de/messages.php
return [
'hello' => 'Hallo aus dem Beispielmodul!',
'description' => 'Dies ist das Referenzmodul.',
'greeting' => 'Willkommen',
];
// lang/en/messages.php
return [
'hello' => 'Hello from the Example Module!',
'description' => 'This is the reference module.',
'greeting' => 'Welcome',
];
Übersetzungen liegen je Sprache unter lang/ und werden vom ModuleManager automatisch geladen.
Sie sind mit dem Modul-Slug als Namespace versehen und werden so referenziert:
__('example::messages.hello')
Sie müssen die Übersetzungen nicht selbst registrieren — das übernimmt der ModuleManager.
Mehr dazu unter Übersetzungen.
Wie es weitergeht
Dieses Referenzmodul ist als Nachschlagewerk gedacht: Jeder Erweiterungspunkt steht hier einmal sauber daneben. Wenn Sie ein eigenes Modul von Grund auf aufbauen möchten, führt Sie der Schnelleinstieg Schritt für Schritt durch ein kleineres Beispiel. Der vollständige Quellcode der Software ist öffentlich auf GitHub einsehbar.