Zum Hauptinhalt springen
Schneespur
Dokumentation durchblättern

Module entwickeln

Interfaces-Referenz

Die Interfaces, über die ein Modul austauschbare Fähigkeiten bereitstellt — Dispatch, Benachrichtigungen, Wetter, PDF, Backup und mehr, jeweils mit Beispiel und Registrierung.

Dieses Kapitel beschreibt die Interfaces, die ein Modul implementieren kann, um austauschbare Fähigkeiten bereitzustellen. Jedes Interface beschreibt einen klar umrissenen Vertrag — etwa „so wird ein Einsatz einem Fahrer zugewiesen” oder „so wird eine Benachrichtigung verschickt” — und jedes hat eine zugehörige Registry, in der Sie Ihre Implementierung anmelden.

Das Muster: Interface plus Registry

Der Kern ist immer gleich: Sie schreiben eine Klasse, die ein Core-Interface implementiert, und tragen sie unter einem eindeutigen Slug in der passenden Registry ein. Der Core ruft später Ihre Klasse über das Interface auf, ohne sie konkret zu kennen.

Warum dieser Umweg über ein Interface? So bleibt der Core austauschbar gegen Ihre Erweiterung, ohne dass Sie eine Core-Datei anfassen. Der Core programmiert gegen den Vertrag (das Interface), nicht gegen eine bestimmte Klasse. Dadurch lassen sich Strategien, Kanäle oder Backends nebeneinander betreiben und über die Oberfläche umschalten — und Updates des Cores bleiben konfliktfrei.

Die Registrierung gehört in die boot()-Phase Ihres ServiceProviders (siehe Das ServiceProvider-Muster). Die genaue API jeder Registry steht unter Registries.


DispatchStrategyInterface

Datei: app/Services/Dispatch/DispatchStrategyInterface.php Registry: DispatchStrategyRegistry Zweck: Einen Algorithmus zur Einsatzzuteilung definieren

Eine Dispatch-Strategie beantwortet die Frage: Welcher Fahrer aus dem verfügbaren Pool übernimmt diesen Einsatz? Der Core kennt die Frage, aber nicht die Antwort — die liefern Sie. So lässt sich die Zuteilung an die Realität eines Stützpunkts anpassen, etwa „nächster Fahrer”, „Fahrer mit passendem Fahrzeug” oder „fester Bezirk”.

namespace App\Services\Dispatch;

use App\Models\Job;
use App\Models\User;
use Illuminate\Support\Collection;

interface DispatchStrategyInterface
{
    /**
     * Assign a job to a driver from the available pool.
     *
     * @param Collection<int, User> $drivers Available drivers
     * @return User|null The assigned driver, or null if none suitable
     */
    public function assign(Job $job, Collection $drivers): ?User;

    /**
     * Whether this strategy can handle the given job type.
     */
    public function canHandle(Job $job): bool;

    /**
     * Human-readable label for admin UI.
     */
    public function label(): string;
}

assign() gibt den zugeteilten Fahrer zurück oder null, wenn keiner passt — der Core entscheidet dann, wie er mit einem unzugeteilten Einsatz umgeht. canHandle() erlaubt es, eine Strategie nur für bestimmte Einsatzarten greifen zu lassen. label() liefert den Namen, der in der Oberfläche zur Auswahl steht.

Beispiel-Implementierung

Die folgende Strategie ordnet einem Einsatz den geografisch nächsten Fahrer zu, gemessen am zuletzt gemeldeten GPS-Punkt:

namespace Schneespur\Module\SmartDispatch\Dispatch;

use App\Models\Job;
use App\Models\User;
use App\Services\Dispatch\DispatchStrategyInterface;
use Illuminate\Support\Collection;

class NearestDriverStrategy implements DispatchStrategyInterface
{
    public function assign(Job $job, Collection $drivers): ?User
    {
        // Find the driver closest to the job's customer object
        $object = $job->customerObject;
        if (!$object || !$object->lat || !$object->lon) {
            return $drivers->first();
        }

        return $drivers->sortBy(function (User $driver) use ($object) {
            $lastPoint = $driver->gpsPoints()->latest('timestamp')->first();
            if (!$lastPoint) return PHP_INT_MAX;
            return $this->haversine($lastPoint->lat, $lastPoint->lon, $object->lat, $object->lon);
        })->first();
    }

    public function canHandle(Job $job): bool
    {
        return true;
    }

    public function label(): string
    {
        return 'Nearest Driver';
    }
}

Beachten Sie den Rückfall: Fehlen Koordinaten am Objekt, gibt die Strategie schlicht den ersten Fahrer zurück, statt zu scheitern. Solche Rückfälle halten die Zuteilung auch bei unvollständigen Daten lauffähig.

Registrierung

app(DispatchStrategyRegistry::class)->register('nearest_driver', NearestDriverStrategy::class);

NotificationChannelInterface

Datei: app/Services/Notification/NotificationChannelInterface.php Registry: NotificationChannelRegistry Zweck: Benachrichtigungen über einen bestimmten Transportweg zustellen (E-Mail, Telegram, SMS, Slack usw.)

Ein Benachrichtigungs-Kanal kapselt genau einen Transportweg. Der Core meldet „Einsatz abgeschlossen” — wie diese Meldung beim Empfänger ankommt, entscheidet der jeweilige Kanal. So können mehrere Wege parallel bestehen, und jeder Betreiber wählt den, der zu seinem Betrieb passt. Mehr zum Benachrichtigungssystem steht unter Benachrichtigungen.

namespace App\Services\Notification;

use App\Models\Job;

interface NotificationChannelInterface
{
    /**
     * Send a notification for a completed job.
     *
     * @param Job $job The completed job
     * @param string $type Notification type (e.g. 'job_completed')
     * @param array $context Additional context (recipients, etc.)
     */
    public function send(Job $job, string $type, array $context): void;

    /**
     * Human-readable channel name.
     */
    public function name(): string;

    /**
     * Unique channel identifier.
     */
    public function slug(): string;

    /**
     * Whether this channel is currently active/configured.
     */
    public function isEnabled(): bool;
}

isEnabled() ist hier mehr als eine Formsache: Ein Kanal soll nur dann angeboten werden, wenn er wirklich konfiguriert ist. So vermeiden Sie, dass eine Benachrichtigung ins Leere läuft, weil etwa ein Token fehlt.

Beispiel-Implementierung

namespace Schneespur\Module\Telegram\Notification;

use App\Models\Job;
use App\Models\Setting;
use App\Services\Notification\NotificationChannelInterface;
use Illuminate\Support\Facades\Http;

class TelegramChannel implements NotificationChannelInterface
{
    public function send(Job $job, string $type, array $context): void
    {
        $botToken = Setting::get('telegram.bot_token');
        $chatId = Setting::get('telegram.chat_id');

        $message = sprintf(
            "Einsatz abgeschlossen: %s bei %s",
            $job->type->label(),
            $job->customer?->name ?? 'Unbekannt'
        );

        Http::post("https://api.telegram.org/bot{$botToken}/sendMessage", [
            'chat_id' => $chatId,
            'text' => $message,
        ]);
    }

    public function name(): string
    {
        return 'Telegram';
    }

    public function slug(): string
    {
        return 'telegram';
    }

    public function isEnabled(): bool
    {
        return !empty(Setting::get('telegram.bot_token'))
            && !empty(Setting::get('telegram.chat_id'));
    }
}

Hier liest der Kanal seine Zugangsdaten über Setting::get() aus den Modul-Einstellungen und meldet sich nur als aktiv, wenn beide Werte gesetzt sind. Das ist das oben genannte Muster in der Praxis.

Registrierung

app(NotificationChannelRegistry::class)->register('telegram', TelegramChannel::class);

TwoFactorMethodInterface

Datei: app/Services/Auth/TwoFactorMethodInterface.php Registry: TwoFactorMethodRegistry Zweck: Ein Verfahren für die Zwei-Faktor-Authentifizierung bereitstellen

Über dieses Interface ergänzen Sie ein zweites Anmeldeverfahren — etwa TOTP, also zeitbasierte Einmal-Codes aus einer Authenticator-App. Der Core kümmert sich um den Anmeldefluss; Ihr Verfahren liefert das Aktivieren, Deaktivieren und Prüfen pro Benutzer.

namespace App\Services\Auth;

use App\Models\User;

interface TwoFactorMethodInterface
{
    public function slug(): string;
    public function name(): string;
    public function enable(User $user): void;
    public function disable(User $user): void;
    public function verify(User $user, string $code): bool;
    public function isEnabled(User $user): bool;
}

Anders als die meisten anderen Interfaces sind enable(), disable(), verify() und isEnabled() hier benutzerbezogen: Zwei-Faktor-Status hängt immer an einem konkreten User, nicht an einer globalen Konfiguration.

Registrierung

app(TwoFactorMethodRegistry::class)->registerMethod('totp', TotpMethod::class);

BackupTargetInterface

Datei: app/Services/Backup/BackupTargetInterface.php Registry: BackupTargetRegistry Zweck: Backups an einem bestimmten Ziel sichern und von dort wiederherstellen

Ein Backup-Ziel beschreibt, wohin eine Sicherung geschrieben und von wo sie zurückgeholt wird — lokales Verzeichnis, S3-kompatibler Speicher und Ähnliches. Da Selbsthoster sehr unterschiedliche Infrastruktur betreiben, ist das Ziel bewusst austauschbar. Mehr dazu unter Speicher und Backup.

namespace App\Services\Backup;

interface BackupTargetInterface
{
    public function slug(): string;
    public function label(): string;
    public function store(string $sourcePath): bool;
    public function restore(string $identifier, string $destinationPath): bool;
    public function isConfigured(): bool;
}

store() und restore() geben einen Wahrheitswert zurück, damit der Core einen fehlgeschlagenen Sicherungslauf erkennen und melden kann, statt ihn stillschweigend zu übergehen.

Registrierung

app(BackupTargetRegistry::class)->register('s3', S3BackupTarget::class);

StorageBackendInterface

Datei: app/Services/Storage/StorageBackendInterface.php Registry: StorageBackendRegistry Zweck: Dateispeicherung (lokal, S3 usw.)

Während ein Backup-Ziel ganze Sicherungen ablegt, regelt ein Storage-Backend die laufende Dateiablage — etwa Einsatz-Fotos oder erzeugte PDFs. Über dieses Interface bestimmen Sie, wo diese Dateien liegen und wie auf sie zugegriffen wird.

namespace App\Services\Storage;

interface StorageBackendInterface
{
    public function slug(): string;
    public function label(): string;
    public function store(string $relativePath, string $contents): void;
    public function retrieve(string $relativePath): ?string;
    public function delete(string $relativePath): bool;
    public function exists(string $relativePath): bool;
    public function url(string $relativePath): string;
    public function isConfigured(): bool;
}

url() liefert eine adressierbare Adresse zur Datei — wichtig, weil ein externes Backend Dateien unter ganz anderen Adressen ausliefert als die lokale Ablage. Der Core muss diese Adresse nicht kennen, er fragt sie beim Backend ab.

Registrierung

app(StorageBackendRegistry::class)->register('s3', S3StorageBackend::class);

WeatherProviderInterface

Datei: app/Services/Weather/WeatherProviderInterface.php Registry: WeatherProviderRegistry Zweck: Wetterdaten von einer externen API abrufen

Wetterdaten sind ein Kernbestandteil des Einsatznachweises — sie belegen, unter welchen Bedingungen geräumt oder gestreut wurde. Über dieses Interface binden Sie eine eigene Wetterquelle an, falls Sie eine andere als die mitgelieferte nutzen möchten. Mehr zum Zusammenspiel von Wetter und Planung steht unter Wetter und Planung.

namespace App\Services\Weather;

interface WeatherProviderInterface
{
    public function fetchCurrent(float $lat, float $lon): ?WeatherData;

    /**
     * @return array{ok: bool, message: string, latency_ms: int}
     */
    public function testConnection(float $lat, float $lon): array;

    public function name(): string;
    public function requiresApiKey(): bool;
}

testConnection() gibt absichtlich ein strukturiertes Ergebnis mit ok, message und latency_ms zurück: So kann die Oberfläche dem Betreiber eine verständliche Rückmeldung geben, ob die Quelle erreichbar ist, statt nur einen Erfolg oder Fehler. requiresApiKey() signalisiert, ob für die Quelle ein Schlüssel hinterlegt werden muss.

Registrierung

app(WeatherProviderRegistry::class)->register('custom_api', CustomWeatherProvider::class);

PdfRendererInterface

Datei: app/Services/Pdf/PdfRendererInterface.php Registry: PdfRendererRegistry Zweck: PDFs aus Blade-Views erzeugen

Der PDF-Einsatznachweis ist ein zentrales Ergebnis der Software. Über dieses Interface lässt sich austauschen, mit welcher Engine aus einer Blade-View ein PDF wird — etwa, um eine andere Render-Engine einzusetzen. Mehr zu Berichten und PDFs unter PDF-Berichte.

namespace App\Services\Pdf;

interface PdfRendererInterface
{
    public function slug(): string;
    public function label(): string;

    /**
     * @param array{paper?: string, orientation?: string, isRemoteEnabled?: bool, footer?: array{left: string, right: string}} $options
     * @return string Raw PDF binary
     */
    public function render(string $view, array $data, array $options = []): string;

    /**
     * @return string Raw PDF binary with footer applied
     */
    public function renderFooter(string $html, string $leftText, string $rightText): string;
}

render() gibt das fertige PDF als rohe Binärdaten zurück, nicht als Datei — wo es landet, entscheidet danach das Storage-Backend. Das options-Array hält Format, Ausrichtung und Fußzeile zusammen, sodass der Aufruf knapp bleibt.

Registrierung

app(PdfRendererRegistry::class)->register('wkhtmltopdf', WkhtmltopdfRenderer::class);

ReportFormatInterface

Datei: app/Services/Report/ReportFormatInterface.php Registry: ReportFormatRegistry Zweck: Daten in einem bestimmten Dateiformat exportieren

Während der PDF-Renderer ein festes Ausgabeformat bedient, beschreibt ein Report-Format einen beliebigen Export — etwa eine Tabelle als XLSX oder CSV. So kann derselbe Berichtstyp in mehreren Formaten ausgegeben werden, je nachdem, was der Empfänger braucht.

namespace App\Services\Report;

interface ReportFormatInterface
{
    public function slug(): string;
    public function label(): string;

    /** @return string[] Report type slugs this format can export */
    public function supportedReportTypes(): array;

    /** @return string File content as string */
    public function generate(string $reportType, mixed $subject, array $params = []): string;

    public function mimeType(): string;
    public function fileExtension(): string;
}

supportedReportTypes() ist der Filter: Ein Format meldet, welche Berichtstypen es überhaupt ausgeben kann, damit die Oberfläche nur sinnvolle Kombinationen anbietet. mimeType() und fileExtension() sorgen dafür, dass die erzeugte Datei beim Download korrekt benannt und ausgeliefert wird.

Registrierung

app(ReportFormatRegistry::class)->register('xlsx', ExcelReportFormat::class);

ScheduledTaskInterface

Datei: app/Services/Scheduler/ScheduledTaskInterface.php Registry: ScheduledTaskRegistry Zweck: Eine Cron-Aufgabe definieren

Über dieses Interface bringt ein Modul eine wiederkehrende Aufgabe in den Zeitplan ein — etwa eine tägliche Zusammenfassung oder einen regelmäßigen Abruf. Der Core übernimmt die Ausführung zum richtigen Zeitpunkt; Sie liefern nur, was zu tun ist und wann.

namespace App\Services\Scheduler;

interface ScheduledTaskInterface
{
    public function slug(): string;
    public function label(): string;
    public function schedule(): string;   // cron expression (e.g. '*/5 * * * *')
    public function handle(): void;       // task execution
    public function isEnabled(): bool;
    public function source(): string;     // 'core' or module slug
}

schedule() gibt einen gewöhnlichen Cron-Ausdruck zurück, sodass Sie die Frequenz vollständig selbst bestimmen. source() macht nachvollziehbar, ob eine Aufgabe vom Core oder von einem Modul stammt — hilfreich, wenn ein Betreiber im Zeitplan nachsehen will, woher ein Eintrag kommt.

Registrierung

app(ScheduledTaskRegistry::class)->register('telegram-digest', TelegramDigestTask::class);

DiagnosticReporterInterface

Datei: app/Services/Diagnostic/DiagnosticReporterInterface.php Registry: DiagnosticReporterRegistry Zweck: Fehler und Abstürze an eine externe Überwachung melden

Über dieses Interface leiten Sie Fehlerereignisse an einen externen Dienst weiter — etwa zur Überwachung des laufenden Betriebs. Da Selbsthoster ihre Installation eigenverantwortlich betreiben, ist die Meldestelle bewusst austauschbar und kann komplett abgeschaltet bleiben. Mehr dazu unter Diagnose und Logging.

namespace App\Services\Diagnostic;

interface DiagnosticReporterInterface
{
    /**
     * @param string $type Event type (e.g. 'exception', 'cron_failed', 'module_boot_failed')
     * @param array $payload Sanitized event data
     * @param array $context Additional context (route, user role, version, etc.)
     */
    public function report(string $type, array $payload = [], array $context = []): void;

    public function isEnabled(): bool;

    /** @return array{ok: bool, message: string, latency_ms: int} */
    public function testConnection(): array;
}

Beachten Sie die Anmerkung „Sanitized event data” am payload: Gemeldet werden bereinigte Ereignisdaten, keine ungefilterten Inhalte. Das passt zur datenschutzfreundlichen Haltung — eine externe Überwachung soll Fehler erkennen, nicht personenbezogene Daten abziehen. Wie beim Wetter-Provider liefert testConnection() ein strukturiertes Ergebnis für eine klare Rückmeldung in der Oberfläche.

Registrierung

app(DiagnosticReporterRegistry::class)->register('sentry', SentryReporter::class);

LifecycleFieldHandler

Datei: app/Contracts/LifecycleFieldHandler.php Registry: LifecycleFieldRegistry (als persist-Wert eines Beitrags) Zweck: Ein vom Modul beigesteuertes Lebenszyklus-Feld speichern, wenn ein Fahrer einen Schicht- oder Einsatzschritt abschließt

Hinzugekommen in 1.1.6. Über diesen Erweiterungspunkt hängt ein Modul ein eigenes Feld in den Ablauf eines Fahrers ein — etwa eine Eingabe, die beim Abschluss eines Einsatzes abgefragt und festgehalten wird. Das Feld wird an einem Punkt im Lebenszyklus eines Fahrers registriert (über das LifecyclePoint-Enum, z. B. LifecyclePoint::JobEnd); zum jeweiligen Zeitpunkt ruft der Core Ihren persist-Handler auf.

Warum ein eigenes Interface dafür? Die Validierungsregeln und die Anzeige eines solchen Feldes meldet das Modul über die Registry an — der Handler bekommt deshalb bereits geprüfte Daten und kümmert sich nur noch um die Persistenz. So bleibt die Speicherlogik ein klar umrissener, type-geprüfter Baustein, den der Core ausführt, ohne das Modul konkret zu kennen. Die Anmeldung des Feldes (Validierung, View, Persist-Handler) gehört in die LifecycleFieldRegistry; gerendert wird das Feld über die Blade-Direktive @lifecycleFields (siehe Assets und Frontend).

namespace App\Contracts;

use App\Models\User;
use Illuminate\Database\Eloquent\Model;

interface LifecycleFieldHandler
{
    /**
     * @param Model $entity    The lifecycle entity (e.g. WorkShift or Job)
     * @param array $validated The validated request data (your namespaced field keys included)
     * @param User  $user      The driver
     */
    public function handle(Model $entity, array $validated, User $user): void;
}

handle() erhält die Lebenszyklus-Entität (etwa eine WorkShift oder einen Job), die bereits geprüften Anfragedaten samt Ihrer namensraum-präfigierten Feld-Keys sowie den Fahrer. Ein persist-Handler darf eine Closure, ein über den Container aufgelöster Klassenname oder ein beliebiges Objekt mit einer handle()-Methode sein — dieses Interface zu implementieren ist die ausdrückliche, type-geprüfte Form. Die Handler laufen innerhalb einer Datenbank-Transaktion; wirft ein Handler eine Ausnahme, wird sie an die Diagnose gemeldet und protokolliert, ohne den Ablauf des Fahrers zu unterbrechen.

Registrierung

app(LifecycleFieldRegistry::class)->registerField(
    \App\Enums\LifecyclePoint::JobEnd,
    'lager_salt_used',
    ['view' => 'lager::fields.salt_used', 'persist' => RecordSaltUsage::class],
);

Der Erweiterungspunkt selbst steht bereit. Die Module, die ihn nutzen — etwa ein geplantes Inventar-Modul (das im Beispiel verbrauchtes Streugut festhält) oder ein geplantes Grünpflege-Modul — sind noch nicht verfügbar und bleiben als kommend gekennzeichnet.