NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Alerts for Laravel

Scheduling checks

Health::for($owner) scopes every health operation to one owner. Use its fluent builder with preset frequencies and a check class — no magic strings:

use RoundlyConsulting\Alerts\Facades\Health;

$team = Team::first();

Health::for($team)->monitor(DiskUsageCheck::class)
    ->everyFiveMinutes()
    ->throttle(maxAttempts: 2, decayMinutes: 60)
    ->meta(['server_id' => '2d4fcdff-9787-49b1-8b73-3d411f80ae1b'])
    ->save();

// The same builder through the model trait:
$team->monitorCheck(DiskUsageCheck::class)->everyFiveMinutes()->save();

monitor() accepts a Check class name or a registered key — the key of an inline check, or of an instance registered with as(). For an inline check the builder starts from the options declared on define(). A class that exists but does not extend Check throws InvalidHealthCheck. save() persists and returns the HealthCheck row.

Frequencies

MethodStored frequency
everyMinute()* * * * * (the default)
everyFiveMinutes()*/5 * * * *
everyTenMinutes()*/10 * * * *
everyFifteenMinutes()*/15 * * * *
everyThirtyMinutes()*/30 * * * *
hourly()@hourly
daily()@daily
weekly()@weekly
monthly()@monthly
frequency(string)a preset name (hourly, everyFiveMinutes, yearly, …) resolved via Frequency::toCron(), or a cron string
cron(string)the expression, verbatim
$monitors = Health::for($team);

$monitors->monitor(DiskUsageCheck::class)->hourly()->save();
$monitors->monitor(DiskUsageCheck::class)->frequency('hourly')->save();           // preset by name
$monitors->monitor(DiskUsageCheck::class)->frequency('everyFiveMinutes')->save();
$monitors->monitor(DiskUsageCheck::class)->cron('15 3 * * *')->save();            // 03:15 every day
$monitors->monitor(DiskUsageCheck::class)->cron('0 9-17 * * 1-5')->save();        // hourly, office hours
$monitors->monitor(DiskUsageCheck::class)->cron('0 9 * * MON-FRI')->save();       // day and month names work too

Due-ness is evaluated by a small native cron parser: five fields (minute, hour, day of month, month, day of week) with *, lists (1,2,3), ranges (1-5) and steps (*/5, 1-30/2), day and month names (MON-FRI, JAN,JUL — case-insensitive), plus the aliases @yearly, @annually, @monthly, @weekly, @daily, @midnight and @hourly. Both 0 and 7 mean Sunday.

Expressions are validated on save(): one the scheduler could not evaluate throws InvalidCronExpression and nothing is stored, so a user-supplied cron string fails where you save it:

use RoundlyConsulting\Alerts\Exceptions\InvalidCronExpression;

try {
    Health::for($team)->monitor(DiskUsageCheck::class)->cron($expression)->save();
} catch (InvalidCronExpression $e) {
    // The scheduler could not evaluate it — nothing was stored.
}

A row written another way — by a seeder or by hand — is still checked at run time: Health::runDue() skips a row whose cron it cannot evaluate, reports it to your exception handler and keeps dispatching the rows after it.

Every option

The same builder takes flap debounce, recovery confirmation, a per-check timeout, tags, channel routing and an escalation policy:

use RoundlyConsulting\Alerts\Checks\DatabaseCheck;

Health::for($team)->monitor(DatabaseCheck::class)
    ->everyFiveMinutes()
    ->failAfter(3)                                // open only after 3 consecutive failures
    ->recoverAfter(2)                             // close only after 2 consecutive OKs
    ->timeout(5)                                  // mark failed after 5 seconds
    ->tags(['critical', 'db'])                    // group + filter
    ->notifyVia(['database'])                     // channels for the default notification
    ->notifyVia(['mail', 'database'], level: 3)   // override channels at escalation level 3
    ->escalate([1 => 'owner', 3 => 'team', 5 => 'oncall'])
    ->throttle(maxAttempts: 3, decayMinutes: 10)
    ->save();
MethodEffectDefault
throttle(int $maxAttempts, int $decayMinutes)At most maxAttempts notifications per decayMinutes, per recipient, for this row.1 per 1 minute
failAfter(int)Consecutive failures needed before an alert opens (values below 1 clamp to 1).1
recoverAfter(int)Consecutive successes needed before an open alert closes.1
timeout(int $seconds)Time budget for check(); an overrun becomes a failed result.none
tags(array)Tags for grouping, report filtering and muting (de-duplicated).[]
notifyVia(array $channels, ?int $level = null)Channels for the bundled notification — globally, or for one escalation level.mail, database
escalate(array $policy)Escalation policy: consecutive-failure threshold ⇒ notifiable group.config escalation
meta(array)Your own data, readable in the check as $this->healthCheck->meta.[]
save()Persists and returns the HealthCheck row.—

The declarative options are stored in the row’s meta under reserved keys — fail_after, recover_after, timeout, notify_via, notify_via_levels and escalation — alongside your own meta. Avoid those names in meta(). Options left at their defaults are not written.

Scheduling from a DTO

schedule() takes a ScheduleHealthCheckData, and the trait offers the same call as a shortcut:

use RoundlyConsulting\Alerts\DataTransferObjects\ScheduleHealthCheckData;

Health::for($team)->schedule(new ScheduleHealthCheckData(
    check: DiskUsageCheck::class, frequency: 'hourly', maxAttempts: 2, decayMinutes: 60,
));

// Trait shortcuts for the same call:
$team->monitor(new ScheduleHealthCheckData(check: DiskUsageCheck::class, frequency: 'hourly'));
$team->createHealthCheck('disk_usage_check', '*/5 * * * *', maxAttempts: 2, decayMinutes: 60, tags: ['db']);
new ScheduleHealthCheckData(
    string $check,                   // Check class-string or registered key
    string $frequency = '* * * * *', // preset name or cron
    int $maxAttempts = 1,
    int $decayMinutes = 1,
    int $failAfter = 1,
    int $recoverAfter = 1,
    ?int $timeout = null,
    array $tags = [],
    ?array $notifyVia = null,
    array $notifyViaLevels = [],     // [level => channels]
    array $escalation = [],          // [threshold => group]
    array $meta = [],
);

The DTO resolves a class name to its key and a preset name to cron, and validates the result; a valid cron string is stored unchanged. createHealthCheck() is a positional shortcut that builds the same DTO and schedules it through Health::for($this).

Listing and removing schedules

Health::for($team)->monitors();                        // Collection<HealthCheck>, oldest first
Health::for($team)->unmonitor(DiskUsageCheck::class);  // soft-deletes the team's schedules; returns the count
Health::for($team)->unmonitor($healthCheck);           // one row — refused if it belongs to another owner

monitors() lists only scheduled rows — the on-demand rows that run() keeps are left out. unmonitor() accepts a check class name, a registered key, a Check instance or a HealthCheck row. A row scheduled against a different owner throws InvalidHealthCheck — the handle is a security boundary.

The Frequency enum

use RoundlyConsulting\Alerts\Enums\Frequency;

Frequency::toCron('everyFiveMinutes'); // '*/5 * * * *' (names match case-insensitively)
Frequency::toCron('yearly');           // '@yearly'
Frequency::toCron('15 3 * * *');       // anything else is returned unchanged
Frequency::Hourly->value;              // '@hourly'
Frequency::names();                    // Collection: EveryMinute, EveryFiveMinutes, …

Frequency has the same enums-for-laravel helpers as Status. Its values are cron strings, so build pickers from names() or a check’s frequencies() map rather than labels().

Working with the row

$row = $team->healthChecks()->first();

$row->health_check;            // 'disk_usage_check'
$row->frequency;               // '*/5 * * * *'
$row->consecutive_failures;    // flap counter
$row->effectiveTags();         // row tags merged with the check's own tags()
$row->isScheduled();           // false for an on-demand (run-now) row
$row->isDue();                 // is the cron due this minute? (never for an on-demand row)
$row->options()->failAfter();  // declarative options read back from meta
$row->dispatchHealthCheckJob(); // queue a run right now, due or not

$row->delete();                // soft delete — the scheduler skips it
$row->restore();               // and picks it up again

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.