Skip to main content
Wintertrace
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],
);