Scheduling identity & reconfigure
By default every schedule() — and monitor()->save() and the UsesHealthChecks shortcuts — inserts a new row, so a seeder or a deploy script that runs twice stacks duplicate schedules. A check declares what identifies its schedule with uniqueBy(); an inline check declares it on define():
use RoundlyConsulting\Alerts\Check;
use RoundlyConsulting\Alerts\Facades\Health;
class ErrorRateCheck extends Check
{
// One live schedule per owner, check and meta.service
public function uniqueBy(): ?array
{
return ['service'];
}
// check(), notification() …
}
// An inline check declares it on define() — [] keeps one schedule per owner:
Health::define('queue-drained', fn () => Queue::size() < 100)->uniqueBy([]);| uniqueBy() | What schedule() does |
|---|---|
null | Inserts a new row every time — the default, and the behaviour before 1.2. |
[] | Keeps one live schedule per owner and check. |
['service'] | Keeps one live schedule per owner, check and meta.service value. |
Discriminator values must be scalars or null — an absent key counts as null, and values compare as text, so 7 and '7' are one identity. Anything else throws InvalidHealthCheck::invalidIdentity(), whose message names the meta key, never the value.
Upsert on schedule()
For such a check, scheduling is an upsert on the identity:
use RoundlyConsulting\Alerts\DataTransferObjects\ScheduleHealthCheckData;
use RoundlyConsulting\Alerts\Facades\Health;
$row = Health::for($team)->schedule(new ScheduleHealthCheckData(
check: ErrorRateCheck::class, // uniqueBy(): ['service']
frequency: 'everyFiveMinutes',
failAfter: 3,
meta: ['service' => 'api'],
)); // created, or updated in place
$row->wasRecentlyCreated; // true only when the schedule was inserted
$row->wasChanged(); // false after an identical re-schedule — nothing was written
$row->getChanges(); // the settings that were written
$row->getPrevious(); // what they replaced- Replace semantics: the settings — frequency, max_attempts, decay_minutes, tags and meta — are rebuilt from the DTO or the builder.
- Only the settings that differ are written: tags compare as sets and meta regardless of key order. An identical re-schedule writes nothing.
- The flap counters, consecutive_failures and consecutive_successes, reset to 0 only when a setting actually changed.
- Audit the outcome with Eloquent’s wasRecentlyCreated, wasChanged(), getChanges() and getPrevious() on the returned row.
- Two concurrent schedules of one identity never insert twice: the unique identity_hash index guards it, and the last write wins.
Schedules from before 1.2
A schedule written before 1.2 has no identity yet. The first time it is scheduled again, the oldest matching one — same owner, check and discriminator values — is adopted and updated; other copies stacked before 1.2 stay as they are, so unmonitor them. unmonitor() gives the identity up in the same transaction as the soft delete, so the same thing can be scheduled anew. A restored row stays without an identity until the next schedule() adopts it.
Changing a schedule with reconfigure()
Health::for($owner)->reconfigure($row, $changes) changes one of the owner’s schedules in place — a PATCH that merges. Every ReconfigureHealthCheckData setting left null stays as it is:
new ReconfigureHealthCheckData(
?string $frequency = null, // cron or preset; validated on construction
?int $maxAttempts = null,
?int $decayMinutes = null,
?array $tags = null, // [] clears them
?int $failAfter = null, // 1 (the default) removes the setting
?int $recoverAfter = null, // 1 (the default) removes the setting
array $meta = [], // merged key by key; a null value removes the key
);use RoundlyConsulting\Alerts\DataTransferObjects\ReconfigureHealthCheckData;
use RoundlyConsulting\Alerts\Exceptions\ScheduleConflict;
use RoundlyConsulting\Alerts\Facades\Health;
// Change two settings; everything left null stays as it is.
Health::for($team)->reconfigure($row, new ReconfigureHealthCheckData(frequency: 'hourly', failAfter: 2));
try {
Health::for($team)->reconfigure($row, new ReconfigureHealthCheckData(meta: ['service' => 'web']));
} catch (ScheduleConflict) {
abort(409); // another live schedule already watches 'web'
}- The same diff rule as schedule() applies: no change means no write, and the counters are kept.
- The frequency is validated when the DTO is built (InvalidCronExpression), and an escalation policy in meta is validated again (InvalidHealthCheck).
- For a uniqueBy() check the identity follows the new meta. Moving onto the identity of another live schedule throws ScheduleConflict and changes nothing — also when a concurrent schedule takes the identity between the check and the save. Its message names the check, the row and the identity’s meta keys, never their values; map it to 409 Conflict.
- In a process where the check is not registered the identity cannot be recomputed: the row keeps it while its meta stays, and gives it up when the meta changes — the next schedule() of the new identity adopts the row.
- A row that belongs to another owner is refused with InvalidHealthCheck. The check key itself cannot be changed.
Health::fake() applies the same rules in memory and records each call for assertReconfigured() (see Testing).
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 cryptoBy 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.