Browse the documentation
Building modules
Interfaces reference
The contracts a module implements to be recognised by the core — what each interface guarantees and the methods you must provide.
This page describes the interfaces a module can implement to provide pluggable capabilities. Each interface defines a clearly bounded contract — “this is how a job is assigned to a driver” or “this is how a notification is sent” — and each has a corresponding registry where you register your implementation.
The pattern: interface plus registry
The mechanism is always the same: you write a class that implements a core interface and register it under a unique slug in the appropriate registry. The core later calls your class through the interface, without knowing its concrete type.
Why the indirection through an interface? It keeps the core decoupled from your extension without touching a single core file. The core programs against the contract (the interface), not against a specific class. This lets strategies, channels, or backends coexist and be switched through the UI — and core updates remain conflict-free.
Registration belongs in the boot() phase of your ServiceProvider (see The ServiceProvider pattern). The exact API of each registry is documented in Registries.
DispatchStrategyInterface
File: app/Services/Dispatch/DispatchStrategyInterface.php
Registry: DispatchStrategyRegistry
Purpose: Define a job assignment algorithm
A dispatch strategy answers the question: which driver from the available pool takes this job? The core knows the question but not the answer — you provide it. This lets you adapt the assignment logic to the reality of a particular depot: “nearest driver”, “driver with matching vehicle”, “fixed zone”, and so on.
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() returns the assigned driver or null if none is suitable — the core then decides how to handle an unassigned job. canHandle() lets a strategy apply only to certain job types. label() provides the name shown for selection in the UI.
Example implementation
The following strategy assigns the geographically nearest driver, measured by the most recently reported GPS point:
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';
}
}
Note the fallback: if no coordinates are available on the object, the strategy simply returns the first driver rather than failing. Fallbacks like this keep assignment working even with incomplete data.
Registration
app(DispatchStrategyRegistry::class)->register('nearest_driver', NearestDriverStrategy::class);
NotificationChannelInterface
File: app/Services/Notification/NotificationChannelInterface.php
Registry: NotificationChannelRegistry
Purpose: Deliver notifications through a specific transport (email, Telegram, SMS, Slack, etc.)
A notification channel encapsulates exactly one transport. The core signals “job completed” — how that signal reaches the recipient is the channel’s concern. Multiple channels can coexist, and each operator picks the one that suits their setup. More on the notification system can be found in Notifications.
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() is more than a formality here: a channel should only be offered when it is genuinely configured. This prevents a notification from silently disappearing because a token is missing.
Example implementation
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(
"Job completed: %s at %s",
$job->type->label(),
$job->customer?->name ?? 'Unknown'
);
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'));
}
}
The channel reads its credentials from Setting::get() and only reports itself as active when both values are present — the pattern described above, applied in practice.
Registration
app(NotificationChannelRegistry::class)->register('telegram', TelegramChannel::class);
TwoFactorMethodInterface
File: app/Services/Auth/TwoFactorMethodInterface.php
Registry: TwoFactorMethodRegistry
Purpose: Provide a two-factor authentication method
This interface lets you add a second authentication method — for example TOTP, the time-based one-time codes generated by an authenticator app. The core handles the login flow; your method supplies the per-user enable, disable, and verify logic.
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;
}
Unlike most other interfaces, enable(), disable(), verify(), and isEnabled() are per-user: two-factor status is always tied to a specific User, not a global configuration.
Registration
app(TwoFactorMethodRegistry::class)->registerMethod('totp', TotpMethod::class);
BackupTargetInterface
File: app/Services/Backup/BackupTargetInterface.php
Registry: BackupTargetRegistry
Purpose: Store/restore backups to a specific destination
A backup target describes where a backup is written and from where it is retrieved — a local directory, S3-compatible storage, or similar. Because self-hosters run very different infrastructure, the destination is deliberately swappable. More detail in Storage & 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() and restore() return a boolean so the core can detect a failed backup run and report it rather than passing over it silently.
Registration
app(BackupTargetRegistry::class)->register('s3', S3BackupTarget::class);
StorageBackendInterface
File: app/Services/Storage/StorageBackendInterface.php
Registry: StorageBackendRegistry
Purpose: File storage (local, S3, etc.)
Whereas a backup target stores complete snapshots, a storage backend handles day-to-day file storage — job photos, generated PDFs, and similar assets. This interface determines where those files live and how they are accessed.
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() returns an addressable link to the file — important because an external backend serves files from entirely different locations than local storage. The core does not need to know these locations; it asks the backend.
Registration
app(StorageBackendRegistry::class)->register('s3', S3StorageBackend::class);
WeatherProviderInterface
File: app/Services/Weather/WeatherProviderInterface.php
Registry: WeatherProviderRegistry
Purpose: Fetch weather data from an external API
Weather data is a core part of service documentation — it records the conditions under which clearing or gritting took place. This interface lets you connect your own weather source if you prefer a different provider. More on how weather and scheduling interact in Weather & Scheduling.
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() deliberately returns a structured result with ok, message, and latency_ms: this lets the UI give the operator a clear, readable indication of whether the source is reachable, rather than just a success or failure flag. requiresApiKey() signals whether an API key needs to be configured.
Registration
app(WeatherProviderRegistry::class)->register('custom_api', CustomWeatherProvider::class);
PdfRendererInterface
File: app/Services/Pdf/PdfRendererInterface.php
Registry: PdfRendererRegistry
Purpose: Generate PDFs from Blade views
The PDF service report is a central output of the software. This interface lets you swap the engine that turns a Blade view into a PDF — for example to use an alternative render engine. More on reports and PDFs in PDF Reports.
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() returns the finished PDF as raw binary data, not as a file — where it is stored is decided afterwards by the storage backend. The options array carries paper format, orientation, and footer together, keeping the call compact.
Registration
app(PdfRendererRegistry::class)->register('wkhtmltopdf', WkhtmltopdfRenderer::class);
ReportFormatInterface
File: app/Services/Report/ReportFormatInterface.php
Registry: ReportFormatRegistry
Purpose: Export data in a specific file format
Whereas the PDF renderer serves a fixed output format, a report format covers any kind of export — a table as XLSX, CSV, and so on. This means the same report type can be produced in multiple formats depending on what the recipient needs.
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() acts as a filter: a format declares which report types it can handle, so the UI only presents meaningful combinations. mimeType() and fileExtension() ensure the generated file is named and delivered correctly on download.
Registration
app(ReportFormatRegistry::class)->register('xlsx', ExcelReportFormat::class);
ScheduledTaskInterface
File: app/Services/Scheduler/ScheduledTaskInterface.php
Registry: ScheduledTaskRegistry
Purpose: Define a cron task
This interface lets a module add a recurring task to the schedule — for example a daily summary or a periodic data fetch. The core handles execution at the right time; you supply only what to do and when.
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() returns a standard cron expression, giving you full control over frequency. source() makes it clear whether a task originates from the core or from a module — useful when an operator wants to trace where a scheduler entry comes from.
Registration
app(ScheduledTaskRegistry::class)->register('telegram-digest', TelegramDigestTask::class);
DiagnosticReporterInterface
File: app/Services/Diagnostic/DiagnosticReporterInterface.php
Registry: DiagnosticReporterRegistry
Purpose: Report errors/crashes to external monitoring
This interface forwards error events to an external service — for example for monitoring a running installation. Because self-hosters operate their own installations, the reporting endpoint is deliberately swappable and can be left entirely disabled. More detail in Diagnostics & 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;
}
Note the “Sanitized event data” annotation on payload: only scrubbed event data is reported, never raw content. External monitoring should detect errors, not extract personal data. Like the weather provider, testConnection() returns a structured result for clear feedback in the UI.
Registration
app(DiagnosticReporterRegistry::class)->register('sentry', SentryReporter::class);
LifecycleFieldHandler
File: app/Contracts/LifecycleFieldHandler.php
Registry: LifecycleFieldRegistry (as a contribution’s persist value)
Purpose: Persist a module-contributed lifecycle field when a driver completes a shift or job step. Added in 1.1.6.
When a module adds a field to one of the driver lifecycle flows through the LifecycleFieldRegistry, it also needs somewhere to save what the driver entered. That is what a persist handler does. It may be a Closure, a class-string resolved from the container, or any object with a handle() method — but implementing this interface is the explicit, type-checked form, and the one to reach for once the logic grows beyond a one-liner.
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;
}
Handlers run inside a database transaction, so a partial write cannot leave half-saved data behind. If your handler throws, the failure is reported to diagnostics and logged — but it does not break the driver’s flow, so a faulty module field cannot strand a driver mid-shift. See LifecycleFieldRegistry for the full contribution model.
Registration
app(LifecycleFieldRegistry::class)->registerField(
\App\Enums\LifecyclePoint::JobEnd,
'lager_salt_used',
['view' => 'lager::fields.salt_used', 'persist' => RecordSaltUsage::class],
);