Notifications & Reminders
The notification stack is the single point through which every outbound message leaves the platform — operational reminders, transactional emails (caregiver invite, receipt, order request), and any future SMS / push fan-out.
Architecture overview
┌─────────────────┐ enqueue ┌─────────────────┐ render ┌── ───────────────┐
│ Reminder │───────────────▶│ SendReminderJob │─────────────▶│ TemplateRenderer│
│ Invoice receipt │ │ SendReceipt… │ └─────────────────┘
│ Caregiver invite│ │ SendCaregiver… │ │
│ Order request │ │ SendOrder… │ ▼
└─────────────────┘ └─────────────────┘ ┌─────────────────┐
│ │ OutboundMessage │
▼ └─────────────────┘
┌─────────────────┐ │
│ Driver Manager │ resolves driver │
└─────────────────┘ │
│ ▼
▼ ┌──────────────┐
┌─────────────────┐ │ SendResult │
│ Mailgun / SES / │◀─────────────│ (ok/retry/ │
│ Mail / Log │ │ permanent) │
└─────────────────┘ └──────────────┘
Components
Drivers
App\Notifications\Contracts\NotificationDriver is the single contract every channel implements:
interface NotificationDriver {
public function name(): string;
public function send(OutboundMessage $msg): SendResult;
}
Implementations live in app/Notifications/Drivers/:
LogDriver— local dev fallback; writes to the Laravel log.MailDriver— Laravel mail-stack passthrough (used by tests; works with thearraymail driver).MailgunDriver— Mailgun HTTP API.SesDriver— AWS SES.
The App\Notifications\NotificationDriverManager singleton resolves the active driver per-request from config('notifications.driver') and caches the instance. Per-tenant overrides flow through service_settings (see below).
Templates
reminder_templates carries the message bodies. Lookup hierarchy:
(tenant_id, slug, channel)— tenant override.(NULL, slug, channel)— platform default.
App\Notifications\TemplateRenderer::renderBySlug(tenantId, slug, vars) resolves the template and produces a RenderedTemplate (subject, body text, body HTML, from-name, template-slug). Variable interpolation uses {{key}} syntax with two suffix conventions:
key_html/key_rawsuffix — value is treated as pre-built HTML and not escaped. Used for the receipt cover note, caregiver accept URL, etc.- All other keys are HTML-escaped via
htmlspecialchars.
Seeded slugs (see 2026_04_23_090100_seed_transactional_email_templates.php):
general_reminderappointment_remindervaccination_duepayment_duereceipt_emailcaregiver_inviteorder_request_received
Outbound message DTO
App\Notifications\Dto\OutboundMessage carries the resolved payload:
toAddress,toNamefromAddress,fromNamesubject,bodyText,bodyHtmltenantSlug,reminderId(nullable; for audit cross-link)templateSlug(so deliverability metrics can group by template)attachments[]— currently used by the receipt PDF; structuredAttachment::pdf($filename, $bytes)helper.
Send result DTO
App\Notifications\Dto\SendResult:
ok— boolean.providerMessageId— for cross-referencing with the provider's UI.errorCode,errorMessage— set on failure.retryable— flips between transient (release back to queue) and permanent (stop, audit).
Static helpers: success(...), transientFailure($code, $msg), permanentFailure($code, $msg).
Opt-outs
communication_opt_outs carries (tenant_id, channel, contact) — uniquely indexed. Static helpers on CommunicationOptOut:
isOptedOut(tenant, channel, contact)optOut(tenant, channel, contact, reason?)normalizeContact($value)— lowercase + trim; the lookup key.
The four jobs short-circuit if the recipient is opted out; the row gets stamped with the opted_out error code.
The four transactional jobs
| Job | Triggered by | Template slug |
|---|---|---|
SendReminderJob | Operator-initiated reminder (POST /reminders) | general_reminder, appointment_reminder, vaccination_due, payment_due, … |
SendReceiptEmailJob | POST /invoices/{id}/receipt/email | receipt_email (with attached PDF) |
SendCaregiverInviteEmailJob | POST /me/pets/{pet}/caregivers | caregiver_invite |
SendOrderRequestEmailJob | POST /me/pets/{pet}/orders | order_request_received |
All four follow the same shape:
- Load the row with relations.
- Short-circuit on stale state (revoked, accepted, cancelled, terminal status).
- Resolve recipient + opt-out check.
- Render template via
TemplateRenderer::renderBySlug. - Build
OutboundMessage(with attachments where applicable). - Send through
NotificationDriverManager::driver(). - Persist outcome (status flip /
last_errorstamp / audit event). - On transient failure,
release($backoff)so the queue retries.
Critical gotcha (and the test fix)
Laravel's Bus\Dispatcher::dispatchToQueue interprets a queue() method on a job class as a custom push-hook (queue($queueInstance, $command)) and short-circuits the normal push if one exists. Defining public function queue(): ?string { return config('notifications.queue'); } therefore caused dispatch to no-op silently — the job class was never executed, the recording driver in tests never saw a send() call.
The fix (now applied to all four jobs): set the queue name via $this->onQueue((string) config('notifications.queue', 'default')) in the constructor, and remove the queue() method. The trade-off is documented inline in each job.
Operator-facing surface
Send a reminder
POST /api/v1/tenant/{tenant}/reminders with {client_id, pet_id?, reminder_type, channel, due_at, title, payload?, template_slug?}.
The Remind button in the UI hits this endpoint; SendReminderJob is dispatched immediately (the due_at field is for scheduling but the slab today fires synchronously).
Retry / dismiss
POST /reminders/{reminder}/retry— flips a failed row back to pending and re-dispatches.POST /reminders/{reminder}/dismiss— terminal-cancel (no further retries).
Opt-out CRUD
/api/v1/tenant/{tenant}/reminder-opt-outs — list / store / destroy. Power tenant ops to surface a search box for "this email keeps bouncing — block it."
Driver health
GET /reminders/health returns the active driver's name and a quick "configured?" heuristic. Used by the admin UI's settings page to verify creds are wired.
Per-tenant settings (Filament admin)
The Filament panel exposes a "Notification settings" page backed by service_settings. Operators can:
- Choose the driver (mail, mailgun, ses, log).
- Set the from address + from name.
- Wire driver-specific creds (Mailgun domain + key, SES region + key/secret).
- Pick the SMS provider (scaffolded — see roadmap).
Secret fields are encrypted at rest via the cast on ServiceSetting.value.
Dev tooling
/api/v1/dev/mail-test — local-only invokable controller. Sends a hand-tuned test message through the configured driver. Use it to verify Mailgun / SES creds without going through the full reminder flow.
Audit + roadmap (selected)
- P2 —
attemptscounter inflates on transient retry; bump after save, not before. - P2 — Template variable dot-paths (
{{client.first.name}}) silently fail to resolve. The renderer is flat-bag-only. - P2 — Audit when a tenant override falls back to platform default (silent tone change).
- P2 — Tenant-level kill switch / pause.
- P2 — Template preview / dry-run on the operator UI.
- P3 — SMS driver (Twilio / MSG91 / TextLocal — scaffolded but unimplemented).
- P3 — Push driver (FCM + APNS).
- P3 — In-app notification feed.
- P3 — WhatsApp Business API.
- P3 — Deliverability dashboard (opens / clicks / bounces from the providers).
- P3 — Quiet hours / send window.
- P3 — Template version history + A/B variants.
- P3 —
List-Unsubscribeheader + bounce webhook → auto-opt-out.