Expiry monitoring
Certificate expiry is a first-class health signal routed through alerts-for-laravel, so it inherits alert dedup and throttling, escalation, silence windows, run history and the /health surface. CertificateExpiryCheck (key certificate_expiry, tag certificates) bands a certificate on two lead-time windows:
- Failed — inside the critical window (alerts.thresholds.critical_days, default 7), or down: past its expiry, Expired, Revoked or Failed (the last issuance or renewal failed).
- Warning — inside the warning window (alerts.thresholds.warning_days, default 30).
- OK — further out. A certificate with no expiry is skipped.
Per-certificate monitors
Certificates::monitorExpiry() returns the alerts PendingScheduledCheck builder, tagged certificates and bound to the certificate, so you chain frequency, flap-debounce, channels and escalation before saving:
use RoundlyConsulting\Certificates\Facades\Certificates;
Certificates::monitorExpiry($certificate, $opsTeam)
->daily() // or hourly(), cron('0 6 * * *'), …
->failAfter(1) // open the alert on the first bad run
->notifyVia(['mail']) // channels for every level without its own list
->escalate([1 => 'owner', 3 => 'oncall']) // consecutive bad runs => alert group
->notifyVia(['mail', 'slack'], level: 3) // channels once the oncall level is reached
->save();
// Own bands for this monitor — meta() replaces the preset, so keep certificate_id
Certificates::monitorExpiry($certificate, $opsTeam)
->meta(['certificate_id' => $certificate->id, 'warning_days' => 45, 'critical_days' => 14])
->daily()
->save();The notifiable is resolved with the precedence explicit argument → alerts.notifiable → the certificate’s certifiable owner; when none resolves, monitorExpiry() throws CertificateException. The notifiable model adopts the alerts UsesHealthChecks trait and the HasNotifiablesForAlerts interface:
use App\Models\User;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\Notifiable;
use RoundlyConsulting\Alerts\Interfaces\HasNotifiablesForAlerts;
use RoundlyConsulting\Alerts\Traits\UsesHealthChecks;
final class OpsTeam extends Model implements HasNotifiablesForAlerts
{
use Notifiable;
use UsesHealthChecks;
// The default group — notified on every bad run when no escalation policy is set.
public function forEachNotifiableForAlerts(Closure $callback): void
{
$callback($this);
}
// Optional: map escalation groups. The trait's default returns the default group for any name.
public function notifiablesForAlertGroup(string $group): iterable
{
return match ($group) {
'oncall' => User::query()->where('on_call', true)->get(),
default => [$this],
};
}
}The interface is required: a monitor whose notifiable doesn’t implement HasNotifiablesForAlerts throws InvalidNotifiableForHealthCheck the first time it has to alert. UsesHealthChecks adds the healthChecks() and alerts() relations and a default notifiablesForAlertGroup().
- escalate() maps a count of consecutive bad runs — warning or failed — to an alert group. Once a policy is set it replaces the default notification: only the groups whose threshold is reached are notified, each once. Give the first run a level too, as with 1 => 'owner' above — escalate([3 => 'oncall']) alone stays silent for two days on a daily monitor.
- Without escalate(), every bad run notifies the default group (forEachNotifiableForAlerts), throttled by alerts; a monitor with no policy of its own inherits alerts.escalation.
- notifyVia() without a level sets the channels for every level; with level: it overrides one threshold.
- monitorExpiry() presets meta(['certificate_id' => …]). Calling meta() yourself replaces it, so pass certificate_id along — plus warning_days and critical_days for bands of its own. Without certificate_id the monitor checks the whole registry.
- Saved monitors run on their own frequency through alerts:perform-health-checks, which alerts registers on the scheduler (alerts.schedule.enabled), so the usual schedule:run cron is all they need.
Scan and alert in one command
certificates:check scans the registry for expiring certificates (renewal.threshold_days, or --threshold), fires CertificateExpiring for each and prints a table. With --alert — or alerts.enabled — it also runs the expiry check through the alerts engine per certificate, opening one throttled, auto-recovering Alert against the resolved notifiable. It never renews:
use Illuminate\Support\Facades\Schedule;
Schedule::command('certificates:check --alert')->daily();The scan window is the renewal threshold, not the warning window — pass --threshold=30 to cover the whole default warning band.
Lifecycle failures and a registry-wide signal
With alerts.enabled, CertificateFailed, CertificateRevoked and CertificateExpired also run the check for that certificate automatically. Set alerts.register_check to register one global CertificateExpiryCheck at boot — it fails when any managed certificate is inside the critical window, already past its expiry, Expired or Failed, and notifies through alerts.channels. Revoked certificates are a deliberate decision (alerted once, via CertificateRevoked) and are not counted:
// config/certificates.php
'alerts' => [
'enabled' => true,
'notifiable' => App\Models\OpsTeam::class, // resolved from the container
'thresholds' => ['warning_days' => 30, 'critical_days' => 7],
'channels' => ['mail', 'slack'],
'register_check' => true,
],Silence expiry alerts during a planned migration with the silences() accessor of the alerts Health facade:
use RoundlyConsulting\Alerts\Facades\Health;
Health::silences()->mute('certificate_expiry', until: now()->addDays(2));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.