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

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.