Skip to content

Notifications ​

Staff notifications: the bell in the shop header, the emails, SMS and browser pushes behind it, the per-shop preferences screen that governs them, and the digest that replaces them when a user asks not to be interrupted.

Client-context users have none of this — there are no notification routes in routes/webClient.php, and ClientLayout mounts only <toaster /> and RealtimeBanner; there is no bell. Everything below is shop-side.

The one rule ​

A notification never chooses its own channels. Every notification extends App\Notifications\Notification, which declares via() and withDelay() as final and answers both from NotificationPreferences::resolve(). A subclass that overrides them bypasses the user's preferences and their quiet hours with nothing failing visibly.

A subclass therefore supplies only three things: which catalog event it is (event()), how to render itself per channel (toMail(), toSms(), toWebPush(), toArray()), and optionally a uniqueKey() that makes it a one-off — see One-off notifications.

How a notification is delivered ​

  1. Something calls $user->notify(...) or Notification::send($users, ...) inside a request that has a current shop — for example SubmitScheduleRequest::notifyApprovers() (app/Actions/Schedule/SubmitScheduleRequest.php:65), which targets the requester's timeOffApprover or, absent one, every Manager in the shop.
  2. The notification implements ShouldQueue, so it is queued. The tenant travels with it through Laravel's Context (see Queues & realtime); Notification::shop() reads Shop::current() back inside the worker and throws if there is none.
  3. via($notifiable) calls NotificationPreferences::resolve(), which produces a ResolvedDelivery — the channel list plus a per-channel delay.
  4. ResolvedDelivery::notificationChannels() maps the app's channels onto Laravel's: screen becomes both database and broadcast, email becomes mail, sms and push become SmsChannel and WebPushChannel.
  5. withDelay() returns the quiet-hours release time for the interrupting channels only, so the bell row is written immediately while the email waits.

How channels are resolved ​

NotificationPreferences::resolve() applies three rules, in this order:

StepRuleWhere
1mandatory ∪ (chosen ∩ allowed), in catalog orderNotificationEvent::resolveChannels()
2if the user summarizes this event, drop every interrupting channelNotificationPreferences::channels()
3if the moment falls inside quiet hours, delay the interrupting channels to the window's endQuietHours::releaseAfter()

With no stored preference row, step 1 uses the event's default list rather than an empty one.

"Interrupting" is email, sms and push (NotificationChannel::interrupting()). screen is deliberately excluded: it writes a row the user reads when they choose to, so holding it back would delay the bell without sparing anyone an interruption.

The event catalog ​

App\Entities\NotificationEvent::withMeta() is the catalog. Adding an event means adding a case there — nothing else enumerates them.

EventAllowedMandatoryDefaultSummarizable
schedule_changesscreen, email, pushscreenscreen, emailyes
new_assignmentscreen, email, sms, pushscreenscreen, pushyes
request_decisionscreen, email, sms, pushscreen, emailscreen, emailno

request_decision is mandatory-by-mail and never summarized on purpose: a decision on your own time-off request is the answer to a question you asked.

Meta holds raw strings, not enum cases, because it ships to TypeScript and the preferences screen reads the same allowed/mandatory lists to decide which toggles to render. tests/Unit/Entities/NotificationEventTest.php runs the catalog invariants over every case: mandatory and default are subsets of allowed, defaults include every mandatory channel, screen is always mandatory, a summarizable event never forces an interrupting channel, and every icon names a file that exists under resources/icons/.

One-off notifications ​

Notification::uniqueKey() returns null by default, which means "may repeat". A subclass that returns a string becomes a one-off for that recipient and that key: a second send is dropped on every channel, not just the bell.

NewAssignmentNotification returns work_order:{work_order_id}, so a technician is told once that a work order is theirs, however many times its lines are saved afterwards.

The drop happens in via(), which returns [] when alreadySent() is true. Two things answer that question, in order:

  1. The bell row is the ledger. screen is mandatory on every catalog event and those rows are never pruned, so a notifications row matching (notifiable_type, notifiable_id, type, unique_key) means the recipient has already heard about it. DatabaseChannel writes unique_key onto the row; type is the event value, from databaseType().
  2. A cache marker covers the gap. That row is written by the queue worker, well after via() runs, so Cache::add() — atomic, keyed notification-sent:{type}:{morph}:{id}:{md5(key)}, TTL IN_FLIGHT_TTL (3600s) — reserves the slot at dispatch time. Losing the marker is the safe failure: a job that died before writing its row makes the notification sendable again.

Both checks are skipped when the notifiable is not an Eloquent model, since neither a row nor a stable cache key can be derived from an on-demand notifiable.

Data model ​

TablePurpose
notificationsLaravel's own table, plus a nullable shop_id FK, a nullable unique_key, an index on (notifiable_type, notifiable_id, read_at) and one on (notifiable_type, notifiable_id, type, unique_key)
user_notification_preferencesone row per (user_id, shop_id, event) — unique; channels is JSON, plus summary_frequency, summary_day, summary_time, last_summary_at
shop_usernotification_quiet_start / notification_quiet_end, both time, nullable
push_subscriptionsendpoint (text), endpoint_hash (unique, 64), public_key, auth_token, user_agent, indexed user_id

Preferences and quiet hours are both per membership, not per user: the same person can be reachable by SMS at one garage and screen-only at another. 2026_08_26_151312_move_notification_quiet_hours_to_shop_user.php copies the old per-user window onto every membership before dropping the users columns.

App\Models\Notification extends Laravel's DatabaseNotification rather than App\Models\Model — the uuid key, the data cast and markAsRead() all come from the framework; the only addition is shop().

Technical surface ​

KindValueSource
RouteGET {shop}/notifications → shop.notifications.indexroutes/webShop.php:194
RoutePOST {shop}/notifications/read → shop.notifications.readroutes/webShop.php:195
RoutePOST|DELETE {shop}/notifications/pushroutes/webShop.php:196
RoutePUT {shop}/profile/notifications (and {shop}/users/{user}/profile/notifications)routes/webShop.php:380, :405
ServiceNotificationPreferencesapp/Services/Notifications/NotificationPreferences.php
Value objectsResolvedDelivery, QuietHoursapp/ValueObjects/
ChannelsDatabaseChannel, SmsChannel, WebPushChannelapp/Notifications/Channels/
Commandnotifications:send-summaries, scheduled every 15 minutesroutes/console.php
MailNotificationSummaryMail → resources/views/mail/notification-summary.blade.phpapp/Mail/
Configservices.webpush.vapid.{subject,publicKey,privateKey}config/services.php:65
EnvVAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY (both empty in .env.example), VAPID_SUBJECT (falls back to APP_URL).env.example, config/services.php:67
Broadcast channelusers.{id}routes/channels.php:14

User::receivesBroadcastNotificationsOn() returns users.{id} so notification broadcasts ride the channel the app already authorizes, not Laravel's default App.Models.User.{id}.

The bell ​

resources/js/Elements/NotificationBell.vue, mounted in MainHeader.vue. It fetches shop.notifications.index as plain JSON (useHttp), not an Inertia visit, and listens on the user channel through broadcasting.listenToUserNotifications() for the live badge.

ListNotifications is deliberately not scoped to Shop::current(). The rows belong to the user (notifiable_id), not a tenant, and someone working at two garages should not have to switch context to discover the other one needs them; each row carries its shop_id, and the bell badges it with the shop name. Do not add a shop scope here. MarkNotificationsRead still filters by notifiable, so a guessed uuid cannot touch someone else's row.

Each row is an <InertiaLink>. Its href is data.link when the notification put one there — NewAssignmentNotification stores route('shop.workOrders.show', ..., false), a relative path that already carries the owning garage's prefix — and falls back to that garage's root (/{shop_slug}) otherwise. Either way the link stays inside the shop the notification came from: following it under the current shop's prefix would 404 on a record the user can genuinely see. Clicking also marks the row read and closes the dropdown (open_()).

The list is capped at 20 rows (ListNotifications::PER_PAGE) and is not paginated; unread_count is a separate unbounded count.

The badge also drops when a row is read somewhere else: the bell listens for notifications-read on the event bus and takes the count the emitter hands it, refetching only while the dropdown is open. The toast below is the only emitter today.

The screen toast ​

screen maps to broadcast as well as database, so the same notification that writes a bell row arrives live on users.{id}. Toaster.vue — mounted in AppLayout and in ClientLayout, never in GuestLayout — listens on that channel alongside the bell and pushes a NotificationToastCard.vue for each one.

Two things on the payload exist for this card, both on the base App\Notifications\Notification:

MethodValueWhy
broadcastType()the event value (new_assignment, …)The card looks the notification up in NotificationEvent for its title and icon. Laravel's default is the FQCN, which a rename silently breaks.
toBroadcast()toArray() plus shop_nameThe card names the garage the notification came from. The bell reads that off the row's shop_id; the broadcast has no row yet.

NotificationEvent.from() returns a blank EnumValue for a value that is not in the catalog rather than throwing, so a notification whose event was removed still toasts — under layout.notifications.untitled, with the Alarm-Bell fallback icon.

The card runs its own countdown as a CSS animation on the progress bar (notificationDuration, 9 s) instead of a store timer, which is what lets group-hover:[animation-play-state:paused] hold it open under the pointer; @animationend dismisses it. ToasterStore::notification() therefore pushes without scheduling a setTimeout, unlike updateState() for flash toasts.

Following the card's link marks the row read from Toaster.vue, not from the card: the card unmounts on the same click and would take its in-flight request with it. The POST {shop}/notifications/read response carries unread_count, which is emitted as notifications-read for the bell — the two components never share state directly.

Icon names in the catalog are raw strings all the way to VueIcon, which falls back to icon-logo-cone.svg for a name with no file behind it — so a typo ships as the Vroum cone in the bell, the toast and the preferences screen at once, with nothing failing. NotificationEventTest asserts a file exists under resources/icons/ for every event and channel icon.

Web push ​

The browser half is resources/js/Hooks/usePushSubscription.ts; the worker is public/sw.js; the server side is WebPushChannel over minishlink/web-push.

  • public/sw.js is hand-written and not bundled through Vite. A service worker only controls URLs under its own path, so a hashed file under /build could only ever control /build, and its registration would break on every deploy.
  • push_subscriptions is unique on endpoint_hash, the sha256 of the endpoint — not on (user_id, endpoint). Endpoints run past what a Postgres btree entry accepts, and a browser handed to a colleague keeps the same endpoint; a second row would push the first person's notifications to the second person's screen. SavePushSubscription reassigns user_id on the existing row instead of inserting.
  • The browser is the source of truth for whether a device is subscribed — permission can be revoked from the address bar without the app hearing about it — so the hook reads state back from the registration. A subscription the server refused is unsubscribed again client-side rather than left reading as on.
  • WebPushChannel never throws. flush() encrypts the whole batch before sending any of it, so one row with an undecodable key would fail all of them — and on the synchronous path that throw would take the notification's other channels down with it. Failures are logged; missing VAPID keys log a warning and return.
  • Rows are pruned when the push service answers 404/410 (isSubscriptionExpired()).
  • iOS Safari only delivers push to a home-screen-installed app. public/manifest.webmanifest is minimal and this is not a full PWA, so push is effectively desktop + Android today.

Generate a key pair with:

bash
vendor/bin/sail php -r 'require "vendor/autoload.php"; print_r(Minishlink\WebPush\VAPID::createVapidKeys());'

The public key reaches the frontend as the shared Inertia prop vapidPublicKey (HandleInertiaRequests::share()).

Summaries ​

A user who sets summary_frequency on a summarizable event stops receiving its interrupting channels entirely — the bell row is still written, and the email digest catches them up.

notifications:send-summaries runs every fifteen minutes because summary slots are per-shop wall-clock times across many timezones; the command decides what is actually due rather than the scheduler.

  • Due rows are grouped by (user, shop, frequency, time), so a user with three summarized events at 08:00 gets one email, not three.
  • The slot itself is the cutoff, not a fixed interval: a late run, or one the scheduler fires twice, will not send the same summary again.
  • last_summary_at is stamped only after the mail is away, and in a single update across the whole group. A digest that failed to send has to stay due, or its notifications fall behind the next run's cutoff and are never summarized at all.
  • A group that has never been summarized falls back to one period before its slot, never to an unbounded window — otherwise the first digest a user ever receives contains their entire history.
  • The digest goes through the default mailer, not comms. That transport carries customer mail and throws without a current shop, which a scheduled command has none of — see comms-service design §6.4.

Quiet hours ​

App\ValueObjects\QuietHours holds two wall-clock strings and the shop's timezone (Shop::resolvedTimezone()), so a window is read against the local clock of the garage the notification belongs to, not the server's.

A window whose end is before its start runs through midnight: the evening half releases the following morning, the small-hours half releases the same day. A window where start equals end counts as unset.

Because the columns live on the pivot, User::shops() must keep withPivot('notification_quiet_start', 'notification_quiet_end'). Without it the columns are not selected and every window silently reads as unset.

Permissions ​

  • All four bell/push routes sit inside the shop group behind auth, EnsureUserTypeShop and MakeCurrentShop (routes/webShop.php:176).
  • UpdateUserNotificationPreferences::authorize() defers to the update policy on the target user (self, or {user} on the managed-user route).
  • It then additionally aborts 403 unless the target user has a shop_user row for the current shop. Both halves of that screen are per membership, and the update policy only asks that the two users share some shop — without the check updateExistingPivot writes nothing, so the quiet hours would vanish while the preference rows saved and the page reported success.
  • The screen's payload is re-filtered through NotificationEvent::resolveChannels() before storage: the catalog, not the request, is the last word on what an event may deliver.

Gotchas ​

  • via() is called once per recipient on one shared notification instance. Notification::$deliveries is memoized keyed by notifiable for that reason — an unkeyed memo hands the first recipient's channels and quiet hours to everyone behind them.
  • DatabaseChannel is swapped wholesale in AppServiceProvider::boot() (singleton(BaseDatabaseChannel::class, DatabaseChannel::class)) rather than registered under a new channel name, so every database notification carries its shop_id and its unique_key. A second name is one a new notification can forget to use.
  • The bell receives bare enum values, not the hydration envelope, because it is a plain JSON route rather than an Inertia response. Enum.from() (resources/js/types/entities.ts:87) accepts a bare value or the envelope, and yields an empty label_key for a value it does not know.
  • A retired event leaves its rows behind. NotificationData::fromModel() uses tryFrom, and the bell renders an unknown type as a plain entry rather than dropping it, so nobody loses history to a rename.
  • A line assigned on an estimate notifies nobody. AppointmentTaken only backfills service_status once the work order reaches open, so on an estimate the line keeps a null status and WorkOrderLineAssigned bails on service_status?->isAccepted(). Dropping the null-safe operator turns that path into a fatal.
  • The assignment guard is isDirty(), not wasChanged(). syncOriginal() runs after the saved event, so an insert leaves every attribute dirty and a line that arrives already assigned — a duplicated work order, an import — still reaches its technician. Swapping in wasChanged() silently drops that case.
  • SmsChannel requires a phone on the user and a toSms() on the notification; either missing is a silent no-op, not an error. It routes through CreateOutboundSms, i.e. the comms service, like customer SMS.
  • SmsChannel is also a silent no-op once the shop's SMS quota is used up (ShopModules::withinLimit(Module::SMS, $notification->shop())), or when the plan has no sms. Staff alerts share the shop's monthly SMS quota with customer messages; the screen and email channels still deliver. See Plans & modules.
  • Pivot disables activity logging for pivots without an incrementing key. An activity row identifies its subject by primary key, so logging a keyless pivot throws before the log is ever written — which is what writing quiet hours onto shop_user would otherwise do.

Tests ​

FileCovers
tests/Feature/Notifications/NotificationDeliveryTest.phpchannel resolution, quiet-hour delays, per-recipient isolation
tests/Feature/Notifications/NotificationInboxTest.phpthe bell routes and read scoping
tests/Feature/Notifications/NotificationChannelTest.phpthe SMS and web push channels, and the broadcast payload the toast reads
resources/js/Elements/__tests__/Toaster.spec.tsthe toast card: title, countdown, hover pause, read-on-open and the count handed to the bell
tests/Feature/Notifications/PushSubscriptionTest.phpsubscription create/reassign/delete
tests/Feature/Notifications/SendNotificationSummariesTest.phpdue detection, grouping, the re-send guard
tests/Feature/Notifications/UniqueNotificationTest.phpuniqueKey() dedup: once per recipient, per key, and while the first send is still queued
tests/Feature/WorkOrder/WorkOrderLineAssignedTest.phpthe listener that triggers NewAssignmentNotification, and each guard that suppresses it
tests/Feature/QueueTenantContextTest.phpthe tenant carried into (and cleared between) queued jobs
tests/Unit/Services/NotificationPreferencesTest.phpthe resolver in isolation
tests/Unit/Entities/NotificationEventTest.phpcatalog invariants