Appearance
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
- Something calls
$user->notify(...)orNotification::send($users, ...)inside a request that has a current shop — for exampleSubmitScheduleRequest::notifyApprovers()(app/Actions/Schedule/SubmitScheduleRequest.php:65), which targets the requester'stimeOffApproveror, absent one, every Manager in the shop. - The notification implements
ShouldQueue, so it is queued. The tenant travels with it through Laravel's Context (see Queues & realtime);Notification::shop()readsShop::current()back inside the worker and throws if there is none. via($notifiable)callsNotificationPreferences::resolve(), which produces aResolvedDelivery— the channel list plus a per-channel delay.ResolvedDelivery::notificationChannels()maps the app's channels onto Laravel's:screenbecomes bothdatabaseandbroadcast,emailbecomesmail,smsandpushbecomeSmsChannelandWebPushChannel.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:
| Step | Rule | Where |
|---|---|---|
| 1 | mandatory ∪ (chosen ∩ allowed), in catalog order | NotificationEvent::resolveChannels() |
| 2 | if the user summarizes this event, drop every interrupting channel | NotificationPreferences::channels() |
| 3 | if the moment falls inside quiet hours, delay the interrupting channels to the window's end | QuietHours::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.
| Event | Allowed | Mandatory | Default | Summarizable |
|---|---|---|---|---|
schedule_changes | screen, email, push | screen | screen, email | yes |
new_assignment | screen, email, sms, push | screen | screen, push | yes |
request_decision | screen, email, sms, push | screen, email | screen, email | no |
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:
- The bell row is the ledger.
screenis mandatory on every catalog event and those rows are never pruned, so anotificationsrow matching(notifiable_type, notifiable_id, type, unique_key)means the recipient has already heard about it.DatabaseChannelwritesunique_keyonto the row;typeis the event value, fromdatabaseType(). - A cache marker covers the gap. That row is written by the queue worker, well after
via()runs, soCache::add()— atomic, keyednotification-sent:{type}:{morph}:{id}:{md5(key)}, TTLIN_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
| Table | Purpose |
|---|---|
notifications | Laravel'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_preferences | one row per (user_id, shop_id, event) — unique; channels is JSON, plus summary_frequency, summary_day, summary_time, last_summary_at |
shop_user | notification_quiet_start / notification_quiet_end, both time, nullable |
push_subscriptions | endpoint (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
| Kind | Value | Source |
|---|---|---|
| Route | GET {shop}/notifications → shop.notifications.index | routes/webShop.php:194 |
| Route | POST {shop}/notifications/read → shop.notifications.read | routes/webShop.php:195 |
| Route | POST|DELETE {shop}/notifications/push | routes/webShop.php:196 |
| Route | PUT {shop}/profile/notifications (and {shop}/users/{user}/profile/notifications) | routes/webShop.php:380, :405 |
| Service | NotificationPreferences | app/Services/Notifications/NotificationPreferences.php |
| Value objects | ResolvedDelivery, QuietHours | app/ValueObjects/ |
| Channels | DatabaseChannel, SmsChannel, WebPushChannel | app/Notifications/Channels/ |
| Command | notifications:send-summaries, scheduled every 15 minutes | routes/console.php |
NotificationSummaryMail → resources/views/mail/notification-summary.blade.php | app/Mail/ | |
| Config | services.webpush.vapid.{subject,publicKey,privateKey} | config/services.php:65 |
| Env | VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY (both empty in .env.example), VAPID_SUBJECT (falls back to APP_URL) | .env.example, config/services.php:67 |
| Broadcast channel | users.{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:
| Method | Value | Why |
|---|---|---|
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_name | The 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.jsis hand-written and not bundled through Vite. A service worker only controls URLs under its own path, so a hashed file under/buildcould only ever control/build, and its registration would break on every deploy.push_subscriptionsis unique onendpoint_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.SavePushSubscriptionreassignsuser_idon 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. WebPushChannelnever 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.webmanifestis 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_atis 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,EnsureUserTypeShopandMakeCurrentShop(routes/webShop.php:176). UpdateUserNotificationPreferences::authorize()defers to theupdatepolicy on the target user (self, or{user}on the managed-user route).- It then additionally aborts 403 unless the target user has a
shop_userrow 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 checkupdateExistingPivotwrites 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::$deliveriesis memoized keyed by notifiable for that reason — an unkeyed memo hands the first recipient's channels and quiet hours to everyone behind them.DatabaseChannelis swapped wholesale inAppServiceProvider::boot()(singleton(BaseDatabaseChannel::class, DatabaseChannel::class)) rather than registered under a new channel name, so everydatabasenotification carries itsshop_idand itsunique_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 emptylabel_keyfor a value it does not know. - A retired event leaves its rows behind.
NotificationData::fromModel()usestryFrom, 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.
AppointmentTakenonly backfillsservice_statusonce the work order reachesopen, so on an estimate the line keeps a null status andWorkOrderLineAssignedbails onservice_status?->isAccepted(). Dropping the null-safe operator turns that path into a fatal. - The assignment guard is
isDirty(), notwasChanged().syncOriginal()runs after thesavedevent, 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 inwasChanged()silently drops that case. SmsChannelrequires aphoneon the user and atoSms()on the notification; either missing is a silent no-op, not an error. It routes throughCreateOutboundSms, i.e. the comms service, like customer SMS.SmsChannelis 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 nosms. Staff alerts share the shop's monthly SMS quota with customer messages; the screen and email channels still deliver. See Plans & modules.Pivotdisables 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 ontoshop_userwould otherwise do.
Tests
| File | Covers |
|---|---|
tests/Feature/Notifications/NotificationDeliveryTest.php | channel resolution, quiet-hour delays, per-recipient isolation |
tests/Feature/Notifications/NotificationInboxTest.php | the bell routes and read scoping |
tests/Feature/Notifications/NotificationChannelTest.php | the SMS and web push channels, and the broadcast payload the toast reads |
resources/js/Elements/__tests__/Toaster.spec.ts | the toast card: title, countdown, hover pause, read-on-open and the count handed to the bell |
tests/Feature/Notifications/PushSubscriptionTest.php | subscription create/reassign/delete |
tests/Feature/Notifications/SendNotificationSummariesTest.php | due detection, grouping, the re-send guard |
tests/Feature/Notifications/UniqueNotificationTest.php | uniqueKey() dedup: once per recipient, per key, and while the first send is still queued |
tests/Feature/WorkOrder/WorkOrderLineAssignedTest.php | the listener that triggers NewAssignmentNotification, and each guard that suppresses it |
tests/Feature/QueueTenantContextTest.php | the tenant carried into (and cleared between) queued jobs |
tests/Unit/Services/NotificationPreferencesTest.php | the resolver in isolation |
tests/Unit/Entities/NotificationEventTest.php | catalog invariants |
Related
- Queues & realtime — how the tenant reaches the worker
- Multi-tenancy —
Shop::current()and the shop prefix - Comms service design — customer vs system mail