Skip to main content

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 the array mail 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:

  1. (tenant_id, slug, channel) — tenant override.
  2. (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_raw suffix — 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_reminder
  • appointment_reminder
  • vaccination_due
  • payment_due
  • receipt_email
  • caregiver_invite
  • order_request_received

Outbound message DTO

App\Notifications\Dto\OutboundMessage carries the resolved payload:

  • toAddress, toName
  • fromAddress, fromName
  • subject, bodyText, bodyHtml
  • tenantSlug, reminderId (nullable; for audit cross-link)
  • templateSlug (so deliverability metrics can group by template)
  • attachments[] — currently used by the receipt PDF; structured Attachment::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

JobTriggered byTemplate slug
SendReminderJobOperator-initiated reminder (POST /reminders)general_reminder, appointment_reminder, vaccination_due, payment_due, …
SendReceiptEmailJobPOST /invoices/{id}/receipt/emailreceipt_email (with attached PDF)
SendCaregiverInviteEmailJobPOST /me/pets/{pet}/caregiverscaregiver_invite
SendOrderRequestEmailJobPOST /me/pets/{pet}/ordersorder_request_received

All four follow the same shape:

  1. Load the row with relations.
  2. Short-circuit on stale state (revoked, accepted, cancelled, terminal status).
  3. Resolve recipient + opt-out check.
  4. Render template via TemplateRenderer::renderBySlug.
  5. Build OutboundMessage (with attachments where applicable).
  6. Send through NotificationDriverManager::driver().
  7. Persist outcome (status flip / last_error stamp / audit event).
  8. 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)

  • P2attempts counter 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.
  • P3List-Unsubscribe header + bounce webhook → auto-opt-out.