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.