Skip to main content
Wintertrace
Browse the documentation

Building modules

Weather & scheduling

Use weather data and register scheduled tasks from a module, including dispatch strategies that decide when work runs.

Winter service depends on weather, and much of the application’s background work runs on a schedule: fetching data, deleting old records, generating reports. This page covers the two extension points for both — custom weather providers and custom scheduled tasks.

Weather providers

Why interchangeable providers

The application does not hard-wire weather data to a single source; instead it uses a swappable interface. Every provider returns the same data structure regardless of where the values come from. This means the data source can be changed without touching the rest of the application — and a module can add a new source without modifying any core file.

Every provider implements the same interface:

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;
}

The four methods split across two responsibilities: fetchCurrent() retrieves the actual weather values for a location; testConnection() checks in the background whether the source is reachable and how quickly it responds. name() and requiresApiKey() supply the information the UI needs for provider selection and API key input.

Built-in providers

Several providers are registered in the core. Which is appropriate for a given installation depends on the provider, its terms of use, and the location — the choice is made in the settings UI.

SlugProviderAPI key required
openmeteo_freeOpen-Meteo (free tier)No
openmeteo_apiOpen-Meteo (API tier)Yes
brightskyBrightSky (DWD data)No
met_norwayMet Norway (MET API)No

Check the terms of service of each provider before using it in production — some plans are explicitly restricted to non-commercial use. The interchangeable provider system exists precisely for these situations: if an available source does not meet your requirements, add your own through a module.

Building a weather provider module

A custom provider is a class that implements WeatherProviderInterface. The following example calls an external API and translates its response into a WeatherData object:

namespace Schneespur\Module\CustomWeather\Weather;

use App\Models\Setting;
use App\Services\Weather\WeatherData;
use App\Services\Weather\WeatherProviderInterface;
use Illuminate\Support\Facades\Http;

class CustomWeatherProvider implements WeatherProviderInterface
{
    public function fetchCurrent(float $lat, float $lon): ?WeatherData
    {
        $response = Http::get('https://api.custom-weather.com/current', [
            'lat' => $lat,
            'lon' => $lon,
            'key' => Setting::get('custom-weather.api_key'),
        ]);

        if (!$response->successful()) return null;

        $data = $response->json();

        return new WeatherData(
            temperature: $data['temp'],
            precipitation: $data['precip'],
            snowDepth: $data['snow_depth'] ?? null,
            windSpeed: $data['wind_speed'],
            humidity: $data['humidity'],
            weatherCode: $data['wmo_code'],
        );
    }

    public function testConnection(float $lat, float $lon): array
    {
        $start = microtime(true);
        try {
            $result = $this->fetchCurrent($lat, $lon);
            $ms = (int) ((microtime(true) - $start) * 1000);
            return [
                'ok' => $result !== null,
                'message' => $result ? 'Connection successful' : 'No data returned',
                'latency_ms' => $ms,
            ];
        } catch (\Throwable $e) {
            return [
                'ok' => false,
                'message' => $e->getMessage(),
                'latency_ms' => (int) ((microtime(true) - $start) * 1000),
            ];
        }
    }

    public function name(): string { return 'Custom Weather API'; }
    public function requiresApiKey(): bool { return true; }
}

Two points worth noting. First, fetchCurrent() returns null on failure rather than propagating an exception — the application can then fall back to another source instead of aborting. Second, testConnection() measures response time in milliseconds and reports any error as a readable message; these are exactly the values the UI displays when running a connection test.

Register the provider during the boot() phase of your ServiceProvider through the relevant registry:

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

The WeatherData value object

WeatherData is the shared value object into which every provider translates its response. Because all providers return the same structure, the rest of the application never needs to know where the data came from.

PropertyTypeDescription
temperaturefloatTemperature in °C
precipitationfloatPrecipitation in mm
snowDepth?floatSnow depth in cm
windSpeedfloatWind speed in km/h
humidityintRelative humidity %
weatherCodeintWMO weather code

Your provider is responsible for mapping the external API’s fields to exactly these properties and units. If a source returns temperatures in Fahrenheit, convert to °C inside the provider.

Convention: TTL units

Weather settings use minutes in the UI and seconds in the database (TTL = how long cached weather data remains valid). The controller handles the conversion in both directions. If your module works with weather TTL settings, follow the same convention so that values remain consistent.

Scheduled tasks

Why scheduled tasks

Some work should not run on demand but regularly in the background: deleting expired data daily, checking for updates hourly, processing the queue every minute. Scheduled tasks handle this. A module can contribute its own tasks without needing to understand the application’s cron setup — it only describes what should run and when.

Every task implements this interface:

interface ScheduledTaskInterface
{
    public function slug(): string;           // unique identifier
    public function label(): string;          // human-readable name
    public function schedule(): string;       // cron expression
    public function handle(): void;           // task execution
    public function isEnabled(): bool;        // whether task should run
    public function source(): string;         // 'core' or module slug
}

slug() is the unique identifier, label() is the human-readable name shown in the UI. schedule() defines when the task runs via a cron expression, handle() contains the actual work. isEnabled() lets a task silence itself depending on a setting, and source() attributes the task to either the core ('core') or a module slug — so it remains clear where a task comes from.

Built-in tasks

The core runs these tasks, among others:

SlugScheduleDescription
retention-deletedailyDeletes expired data per retention policy
update-checkdailyChecks for application updates
queue-work* * * * *Processes queued jobs
cron-heartbeat* * * * *Records cron activity for health checks

Building a scheduled task

The following example generates a report every morning at 06:00 and can be toggled via a setting:

namespace Schneespur\Module\Reports\Scheduler;

use App\Models\Setting;
use App\Services\Scheduler\ScheduledTaskInterface;

class DailyReportTask implements ScheduledTaskInterface
{
    public function slug(): string { return 'daily-report'; }
    public function label(): string { return 'Daily Report Generation'; }
    public function schedule(): string { return '0 6 * * *'; } // 06:00 daily

    public function handle(): void
    {
        // Generate and send daily report
        $this->generateReport();
    }

    public function isEnabled(): bool
    {
        return (bool) Setting::get('reports.daily_enabled', false);
    }

    public function source(): string { return 'reports'; }
}

isEnabled() reads reports.daily_enabled from settings. When it is false, the application skips the task without any code change — enabling and disabling happens through the UI.

Register the task in the boot() phase, through the relevant registry:

app(ScheduledTaskRegistry::class)->register('daily-report', DailyReportTask::class);

Tracking task runs

The registry records when a task last ran, whether it succeeded, and how long it took. This makes background work verifiable: a task that has not run successfully for days becomes visible.

$registry = app(ScheduledTaskRegistry::class);

// After running a task
$registry->recordRun('daily-report', 'success', null, 1234); // 1234ms

// On failure
$registry->recordRun('daily-report', 'failed', 'Connection timeout', 5000);

// Get last run info
$lastRun = $registry->lastRun('daily-report');
// → object{slug, status, error_message, duration_ms, ran_at}

// Get all tasks with status
$all = $registry->allWithStatus();
// → ['daily-report' => ['task' => ..., 'last_run' => ...]]

recordRun() logs a single run with its status, error message, and duration. lastRun() returns the last run of a given task; allWithStatus() returns all tasks with their most recent status — exactly the overview the UI displays.

Cron expression reference

The task schedule is a standard cron expression. The most common patterns:

ExpressionMeaning
* * * * *Every minute
*/5 * * * *Every 5 minutes
0 * * * *Every hour
0 6 * * *Daily at 06:00
0 0 * * 1Every Monday at midnight
0 0 1 * *First day of each month

The full API for the registries used here — WeatherProviderRegistry and ScheduledTaskRegistry — is documented under Registries.