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.
| Slug | Provider | API key required |
|---|---|---|
openmeteo_free | Open-Meteo (free tier) | No |
openmeteo_api | Open-Meteo (API tier) | Yes |
brightsky | BrightSky (DWD data) | No |
met_norway | Met 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.
| Property | Type | Description |
|---|---|---|
temperature | float | Temperature in °C |
precipitation | float | Precipitation in mm |
snowDepth | ?float | Snow depth in cm |
windSpeed | float | Wind speed in km/h |
humidity | int | Relative humidity % |
weatherCode | int | WMO 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:
| Slug | Schedule | Description |
|---|---|---|
retention-delete | daily | Deletes expired data per retention policy |
update-check | daily | Checks 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:
| Expression | Meaning |
|---|---|
* * * * * | Every minute |
*/5 * * * * | Every 5 minutes |
0 * * * * | Every hour |
0 6 * * * | Daily at 06:00 |
0 0 * * 1 | Every 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.