Reminders and escalation
Reminder rules belong to a type with a subject() and are relative to a date attribute of it:
use RoundlyConsulting\Notifications\Recipients;
use RoundlyConsulting\Notifications\Reminders\Reminder;
final class ContractExpiring extends NotificationType
{
public function key(): string { return 'contract.expiring'; }
public function category(): string { return 'contracts'; }
public function subject(): ?string { return Contract::class; }
public function reminders(): array
{
return [
Reminder::before('expires_at', 'P60D', 'P30D', 'P7D')
->to(Recipients::owner())
->while(fn (Contract $contract): bool => $contract->terminated_at === null)
->escalate(after: 'P3D', to: fn (Contract $contract) => Recipients::role($contract->company, 'manager')),
];
}
public function title(NotificationPayload $p): string
{
return __('app.contract_expiring', ['offset' => $p->data['reminder'] ?? '']); // '-P30D'
}
}| Builder | Meaning |
|---|---|
Reminder::before($attribute, ...$offsets) / after(...) | Anchor ∓ offset (ISO-8601 or relative), 1–25 positive offsets. |
Reminder::at(CarbonInterface $when) | Absolute — ad-hoc schedules only. |
->key('expiry') | A stable rule key (default {attribute}:{direction}); two rules on one attribute and direction need distinct keys. |
->to($recipients) | Who is reminded; a rule without to() is a template for schedule($type, to: …). |
->while(fn (Model $subject): bool) | Checked at fire time with the fresh subject; false cancels (condition). |
->escalate(after:, to:, type: null) | Repeatable — a chain of levels. An unreadable or non-positive delay throws at once. |
->acknowledgedOnRead() | Reading the reminder’s inbox item acknowledges it. |
->rearm(false) | Do not re-arm sent reminders when the anchor moves later (default: re-arm). |
Reminder::after('signed_at', 'P1Y')
->key('anniversary') // two rules on one attribute and direction need distinct keys
->to(Recipients::owner())
->acknowledgedOnRead() // reading the item acknowledges it
->rearm(false); // don't re-arm sent reminders when the anchor moves later
Reminder::before('deadline', 'PT1H'); // a template rule without to(): schedule() supplies recipientsRules are validated at registration: the type has a subject, the attribute is not empty, 1–25 positive offsets, rule keys unique per type, no at() in a type rule. The anchor is read through your cast and converted to UTC. Time offsets (PT…) are absolute; calendar offsets (P…D/W/M/Y) are applied on the anchor’s wall clock in app.timezone, so “7 days before 09:00” stays 09:00 local across a DST change.
Following the date
With the HasNotificationReminders trait on the subject (see Recipient and subject models), every save syncs. Per rule and offset:
- Anchor cleared → pending rows cancelled (anchor_cleared).
- A pending row whose due time moved → re-timed (ReminderRescheduled), or cancelled (passed) when the new time is already past — moving the anchor earlier never fires the skipped offsets all at once.
- No pending row → scheduled when the due time is in the future, unless a row for that very anchor already finished; a sent row of an older anchor re-arms (a renewal). Past offsets are never back-filled.
- A unique key makes two concurrent syncs schedule one row; a conflict never throws.
- A query-builder update bypasses model events: the fire step re-reads the anchor, so a stale due time reschedules itself instead of firing.
The explicit API and ad-hoc reminders
Notifications::for($contract)->reminders()->sync(); // ?string $type
Notifications::for($contract)->reminders()->schedule('task.due', to: $assignee); // template rules, stored recipients
Notifications::for($contract)->reminders()->cancel(); // ?string $type
Notifications::for($contract)->reminders()->pending(); // Collection<NotificationReminder>
Notifications::for($contract)->reminders()->all();
// Ad-hoc reminders
Notifications::for($task)->remind('task.due', Reminder::before('deadline', 'PT1H'), to: $task->assignee);
Notifications::for($report)->remind('report.ready', Reminder::at(now()->addDay()), to: $user, data: ['id' => 7]);
Notifications::reminders()->backfill('contract.expiring'); // every subject of the type's class
Notifications::reminders()->fireDue(); // FiredReminders — what the sweep runsAd-hoc reminders store their recipients and data; an anchored one follows its anchor like a rule reminder. A second remind() with the same key (->key(), else the attribute + offset, or the instant) replaces the pending row. Ad-hoc reminders take no condition, escalation or acknowledgedOnRead() — a rule carrying one throws InvalidReminderRuleException; declare a rule on the type for those. Recipients must be storable (no closures). For subjects that existed before a type was added, run notifications:reminders:sync.
Firing
The sweep fires due rows oldest first (notifications.reminders.batch per sweep), each claimed by compare-and-set. In order: type or rule gone → cancelled; subject gone → subject_deleted; anchor changed → re-timed or passed; condition false → condition; more than reminders.max_lateness (P1D) overdue and the anchor already passed → skipped (late). Then the send runs with the reminder’s idempotency key (access per recipient), the row is sent, the escalation chain is armed and ReminderFired fires. A row still firing after reminders.lease goes back to pending, and its re-fire sends nothing twice; a send that throws is retried by the next sweeps and cancelled after three attempts. The payload’s data holds the signed offset (['reminder' => '-P30D']) and its reminder holds the ReminderReference.
Escalations
Each level is a row due after the previous send. At fire: acknowledged → cancelled; subject gone or condition false → cancelled; otherwise the level’s recipients get the type (or the level’s type:) flagged as escalated, in their own lane — never folded into the original item or mail — and the next level is armed. EscalationFired fires.
Acknowledgement
Notifications::for($contract)->acknowledge('contract.expiring', by: $user); // or acknowledge() for every type
Notifications::reminders()->acknowledge($contract, 'contract.expiring', by: $user);It stamps the open reminders and escalations of the subject (and type) and cancels their pending escalations. Later reminder offsets still fire — an acknowledged 30-day reminder does not silence the 7-day one — and a re-armed reminder starts unacknowledged. Rules with acknowledgedOnRead() also acknowledge when any recipient marks the reminder’s inbox item read, but only while that reader can still see the subject. POST /inbox/{item}/acknowledge does the same after the same access check. Notifications::for($subject)->acknowledge(by:) is trusted host code: it checks nothing, so call it only for a user you have authorized.
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.