Skip to content

fix duplicate and misdirected notifications, add mail and webhook channels, events, fake, and a delivery log - #17

Merged
techmahedy merged 2 commits into
doppar:4.xfrom
techmahedy:techmahedy-4.x
Oct 7, 2026
Merged

techmahedy merged 2 commits into
doppar:4.xfrom
techmahedy:techmahedy-4.x

Conversation

@techmahedy

Copy link
Copy Markdown
Member

Why

Two bugs affected anyone using the notifier in production, and the package lacked several things people expect from a notification system (mail, events, a test fake, delivery visibility).

Bug 1: a retry re-sent what had already worked. NotificationDispatcher ran every channel in one job: the database row first, then Slack, Discord and so on. If a later channel threw, the whole job was retried and the database notification was stored again each attempt. With ['database', 'slack'] and Slack down, one notification produced 3 database rows.

Bug 2: notifications() read the signed-in user's notifications. notifications(), unreadNotifications(), readNotifications() and markNotificationsAsRead() filtered by auth()->id(), not by the entity. An admin viewing another user saw their own notifications. In a queue worker or console command there is no signed-in user, and the call threw Auth actor [web] is not defined.

Also fixed: Slack failures were only logged, so the queue never retried them (Discord already threw). ChannelDriver threw a TypeError when constructed without the application.

What changed

Delivery

  • One queued job per channel. A failing channel retries on its own.
  • DatabaseChannel is now a real channel (the dispatcher no longer special-cases it).
  • The dispatcher tries every channel in a legacy multi-channel job and throws the first failure at the end.
  • send() returns the notification id (UUID), shared by that send's jobs.

New channels

  • mail: structured content (greeting, lines, action button, outro, footer) or your own html/text. Supports cc, bcc, reply-to, attachments, tags, priority. All values are escaped and action links must be plain http(s).
  • webhook: JSON body, custom headers, optional HMAC signature in X-Notification-Signature. Addresses must be http(s). Headers containing line breaks are dropped.
  • Channels\Support\Http is a small injectable transport, so channels are testable without the network.

Notification API (all optional, defaults keep current behaviour)

  • contentFor($channel, $notifiable) routes to toMail(), toSlack() and so on, falling back to content().
  • shouldSendVia(), throttle(), priority(), uniqueId(), backoff(), onConnection().
  • On the notifiable: wantsNotification() for per-person channel preferences, plus unreadNotificationsCount(), markNotificationAsRead($id), deleteNotification($id), deleteReadNotifications() and notificationDeliveries().

Observability and testing

  • Delivery log: notification_deliveries, one row per notification and channel, with status, attempts and last error. It is best effort: when the table is missing it is skipped with a warning and sending is unaffected.
  • Events: Notification::sending() (return false to cancel), sent(), failed(). A throwing listener is logged and ignored.
  • Notification::fake() with assertSentTo, assertNotSentTo, assertSentTimes, assertSentVia, assertNothingSent, assertCount.
  • DatabaseNotification::prune() and the notification:prune command.

Behaviour changes to review

  • send() returns a UUID instead of a queue job id. toMany() returns one id per recipient.
  • A notification with N channels is N jobs, not one. Jobs already queued in the old form still run, and the delivery log stops them repeating channels.
  • The entity-scoped notification methods change meaning for any caller that relied on the signed-in-user behaviour. That behaviour was the bug.
  • Slack failures now throw, so a bad webhook now fails and retries.
  • ChannelDriver keeps its Application $app property type. Making it nullable would break subclasses that redeclare it, so a channel built without the app leaves the property unset instead.
  • NotificationDispatcher gains a fourth constructor argument, $notificationId.

Migration

New migration create_notification_deliveries_table. Recommended, not required. Without it the log, "skip an already-delivered channel on retry" and throttle() are off. Everything else works.

Dependencies on doppar/queue

Priority, uniqueId(), backoff() and onConnection() use the newer queue's job methods. On an older queue they are accepted and ignored. Everything else works with any 4.x.

The queue's queue_jobs table also needs its new columns for the notifier to queue anything on a newer queue. That belongs to the queue package's own upgrade path.

Tests

  • New suites cover notifiable scoping, each channel, the mail template and its escaping, the pipeline, the dispatcher (isolation, idempotency, skipping, throttling, missing log table), events, the fake, builders, queue features, a Redis driver suite, the prune command, and the migration on SQLite.
  • Redis tests need predis/predis and a reachable server. They skip otherwise.
  • Tests that need the newer queue skip against older installs.
  • The regressions were checked by breaking each fix on purpose (idempotency, per-channel jobs, entity scoping, Slack throwing, throttling, preferences, event cancellation, webhook signing, mail link validation). Each made at least one test fail.

…ebhook channels, events, fake, and a delivery log
@techmahedy techmahedy added bug Something isn't working enhancement New feature or request labels Oct 7, 2026
@techmahedy
techmahedy merged commit bc4dc9f into doppar:4.x Oct 7, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant