---
title: "Reminders and escalation — Notifications for Laravel | Roundly"
description: "Reminder::before/after/at, while(), rearm(), acknowledgedOnRead(), the HasNotificationReminders trait, ad-hoc reminders, firing and escalation chains."
url: https://roundly-consulting.com/open-source/docs/notifications-for-laravel/reminders
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Notifications for Laravel](https://roundly-consulting.com/open-source/docs/notifications-for-laravel.md)

# Reminders and escalation

Reminder rules belong to a type with a subject() and are relative to a date attribute of it:

```php
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). |

```php
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 recipients
```

Rules 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

```php
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 runs
```

Ad-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

```php
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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/notifications-for-laravel.md)
