Notifications (in-app & email)
View on GitHubFrontend ↔ backend wiring overview
A map of the whole feature: how notifications reach the browser, how read/unread state round-trips, and how the settings tab persists preferences. Useful to read before guide 01 or 02 if you haven't touched this system before.
1. Reading: how the bell popup gets its data
Notifications are not fetched on demand by the popup — they arrive
as a shared Inertia prop on every single page load/visit, the same way
auth.user does:
Every Inertia request
│
▼
HandleInertiaRequests::share()
- 'notifications' => fn () => $request->user()
? $this->notificationService->getAllForUser($request->user()->id)
: []
- 'emailEnabled' => fn () => $this->mailConfigurationService->isEnabled()
│
▼ every page component receives `notifications` as a prop automatically
│
▼
PageHeader.tsx
- owns `showNotificationsPopup` (local state, toggled by the Bell icon button)
│
▼ (only rendered while open)
NotificationsPopup.tsx
- reads `notifications` via usePage<PageProps>().props (not its own fetch)
- owns `onlyUnread` (local filter state)
- computes `unreadCount` = notifications.filter(n => !n.read).length
│
├─▶ NotificationHeader.tsx (unread badge, "Mark all as read" button, "Only show unread" toggle)
└─▶ NotificationsList.tsx (maps to NotificationItem, or NotificationEmptyState if empty)
│
└─▶ NotificationItem.tsx (title, message, "View details" link to action_url, unread dot)
Because the prop is refreshed on every Inertia visit, the badge/list update after any action that causes a redirect back (which is every mutating notification action below) — there's no polling, no WebSocket, no separate "unread count" endpoint. The Bell icon button itself carries no badge/dot of its own; the only unread indicator lives inside the popup once it's opened.
There is a GET /notifications route
(NotificationController::index(), name notifications.index,
returns the collection as JSON) — nothing in the current frontend
calls it. It's unused by the popup (which relies entirely on the
shared prop above); leave it alone unless you have a concrete new
reason to fetch notifications outside of a normal page load.
2. Writing: mark as read, mark all as read, delete
Three mutating actions, all Inertia requests that redirect back (triggering the shared-prop refresh from part 1) — none of them touch local component state directly:
NotificationItem's unread dot clicked
│
▼
NotificationsPopup::handleMarkAsRead(id)
- POST /notifications/{id} body: { ...notification, read: true }
│
▼
NotificationController::update()
- abort_if(notification.user_id !== auth()->id(), 403)
- validates type/title/message/read/action_url
- NotificationService::update()
│
▼ redirect back → shared prop refetches → item re-renders as read
"Mark all as read" clicked
│
▼
NotificationsPopup::handleMarkAllAsRead()
- POST /notifications/mark-all-read
│
▼
NotificationController::markAllAsRead()
- NotificationService::markAllAsReadForUser(auth()->id())
- bulk UPDATE, scoped to the authenticated user's own unread rows only
NotificationController::destroy() (DELETE /notifications/{notification})
follows the same ownership check (abort_if($notification->user_id !== auth()->id(), 403))
but returns plain JSON rather than a redirect — nothing in the current
frontend calls it either; add a delete button to NotificationItem.tsx
following the same router.delete(...) pattern used by
WorkspaceSettingsDeleteRoleModal.tsx (see
../permissions/03-grant-a-custom-role-in-bulk.md
step 3) if you need to wire it up.
3. Settings: how a channel toggle persists
A completely separate read/write path from parts 1–2 — this one is a real page load, not the shared prop:
GET /settings?tab=notifications
│
▼
SettingsController::index()
- 'notificationSettings' => $this->notificationSettingService->getAllSettings($user->id)
(every NotificationType × NotificationChannel pair, defaulting via
NotificationChannel::enabledByDefault() unless a NotificationSetting row overrides it)
│
▼
AccountSettingsNotificationsTab.tsx
- mergeNotificationSettings() folds the prop onto its own
hand-maintained `defaultNotificationTypes` array (see guide 01's
"one rule that matters most" — a type missing from that array
never reaches this merge at all)
│
▼ (toggle a switch)
AccountSettingsNotificationTypeRow.tsx → onInAppChange/onEmailChange
│
▼
AccountSettingsNotificationsTab::updateNotificationType()
- optimistically flips local state, then:
- POST /account/notification-settings body: { settings: { [type]: { in_app, email } } }
│
▼
NotificationSettingController::update()
- UpdateNotificationSettingsRequest (validates shape + that every
key is a real NotificationType::cases() value)
- NotificationSettingService::updateSettings()
→ updateOrCreate()s one NotificationSetting row per type/channel pair
→ logs one ActivityLog entry for the whole batch
│
▼ onSuccess: success alert / onError: reverts the optimistic toggle + error alert
Unlike parts 1–2, this update is a plain axios-style POST
(router.post, not tied to the shared-prop refresh) — the UI trusts
its own optimistic state and only reconciles on onError, it doesn't
wait for a fresh notificationSettings prop to confirm the new value.
Files this feature touches, end to end
app/Models/Notification.php,app/Models/NotificationSetting.phpapp/Enums/Notifications/NotificationType.php,NotificationChannel.phpapp/Repositories/NotificationRepository.php,NotificationSettingRepository.php(interface) +EloquentNotificationSettingRepository.php(implementation)app/Services/NotificationService.php,NotificationSettingService.php,NotificationMailService.phpapp/Listeners/SendNotificationListener.phpapp/Http/Controllers/NotificationController.php,NotificationSettingController.phpapp/Http/Requests/Notifications/UpdateNotificationSettingsRequest.phpapp/Http/Middleware/HandleInertiaRequests.php(the sharednotifications/emailEnabledprops)app/Notifications/NotificationMail.php+resources/views/emails/notification.blade.phpresources/js/types/Notification.tsresources/js/Components/Organisms/PageHeader/PageHeader.tsx,NotificationsPopup/NotificationsPopup.tsx,NotificationsList/NotificationsList.tsx,AccountSettingsContent/AccountSettingsNotificationsTab.tsx,AccountSettingsContent/AccountSettingsNotificationTypeRow.tsxresources/js/Components/Molecules/NotificationHeader/NotificationHeader.tsx,NotificationItem/NotificationItem.tsx,NotificationEmptyState/NotificationEmptyState.tsx
Local testing checklist
- Log in as two different users in the same project (or two browser profiles) so you have someone to trigger an event as, and someone to receive the notification.
- To see an email actually land somewhere during local dev, set
MAIL_MAILER=login.envand tail the log (php artisan pail, already part ofcomposer dev) —log/arraymailers never error, so a silently-missing email is the #1 "why didn't this arrive" cause (see guide 02's channel breakdown). - Toggle a channel off in Account settings → Notifications, then
trigger that type again, and confirm nothing was written
(
Notification::where('user_id', $id)->latest()->first()viaphp artisan tinker) rather than just "not visible" — a UI bug hiding a row and the row never being created look identical from the popup alone.
