Notifications (in-app & email)
View on GitHubNotifications (in-app & email)
Orbit tells users about activity two ways: an in-app notification (the
bell dropdown) and an email, per App\Enums\Notifications\NotificationType
— per-user, per-channel, independently toggleable in Account settings.
This category documents that system: how a domain event becomes a
notification, how per-user preferences gate delivery, and the one step
that's easy to forget when adding a new kind of notification.
Guides, in the order you'd actually need them
-
Add a new notification typeNotifications (in-app & email)Add a new notification typeWorked example: notifying a member when their project role changes — NotificationType::MemberRoleChanged. Today, ProjectMemberService::updateRole() only wr… — worked example adding
MemberRoleChanged(notifying a member when their project role changes) end to end: the enum case, firing and handling the event, and — the step that's easy to skip — the frontend settings row that makes the new type visible and toggleable at all. -
Send a notification from your codeNotifications (in-app & email)Send a notification from your codeGuide 01 covers wiring a domain event through SendNotificationListener into NotificationService::notify(). You don't always need an event in between — noti… — the full
NotificationService::notify()contract (recipient, type, severity, message, action URL, what each channel actually does), for when you want to send an existing notification type directly, without introducing a new domain event. -
Frontend ↔ backend wiring overviewNotifications (in-app & email)Frontend ↔ backend wiring overviewA map of the whole feature: how notifications reach the browser, how read/unread state round-trips, and how the settings tab persists preferences. Useful t… — how a notification actually reaches the browser (it's a shared Inertia prop, not a fetch), how mark-as-read/mark-all-as-read/delete round-trip, and how the settings tab persists a toggle — useful as a map before diving into either guide above.
-
Add a dedicated transactional emailNotifications (in-app & email)Add a dedicated transactional emailGuide 02 covers reusing the generic NotificationMail template (one shared subject/body/action-button layout for every NotificationType). Sometimes that's t… — worked example (
OwnershipTransferredMail) for the other shape of email: one that must go out unconditionally with its own subject and layout, bypassingNotificationType/NotificationSettingentirely — the same patternProjectInvitationMailalready uses. -
Add @mention support to a free-text fieldNotifications (in-app & email)Add @mention support to a free-text fieldWorked example: the @mention system already built for issue comments — type @ in the comment box, pick a project member from a floating suggestion list, an… — worked example: the comment
@mentionsystem (IssueMentioned), end to end from typing@in a plain<textarea>to the recipient's notification. Covers the reusable range-tracking technique (MentionRange/applyRangeEdit) that tells two mentions apart by character position instead of by name, and the backend check that stops a client from claimingmentioned_user_idsfor someone the comment text never actually names. -
Clean up old notifications and activity historyNotifications (in-app & email)Clean up old notifications and activity historyNotifications and activity log entries pile up forever unless something removes them. Orbit gives every user two ways to do that, both in Settings → Notifi… — the per-user daily/weekly/monthly schedule (
notifications:prune) and the one-off "delete older than N days" actions in Settings → Notifications: what is deleted, the due logic, the cache, and how to add an age preset, a frequency or another kind of data.
Wiring a domain event into the notification pipeline is itself
documented as part of
../integrations/03-add-a-new-event-type.mdIntegrationsAdd a new event typeWorked example: adding IssueCreated — a kind of activity that doesn't exist yet. Today, creating an issue only fires IssueAssigned, and only if it's create…
(using IssueCreated as its worked example) — guide 1 doesn't repeat
that mechanic, it covers everything specific to notification types
on top of it: the settings row, channel defaults, and the
frontend/backend split that guide doesn't touch.
The architecture in one paragraph
Every notification-worthy fact is one NotificationType case (e.g.
IssueAssigned, ProjectInvited). Every notification can go out on
one or both of two NotificationChannels — InApp (on by default)
and Email (off by default, see NotificationChannel::enabledByDefault())
— and a user can override either channel for either type via a
NotificationSetting row (user_id + type + channel + enabled;
no row means "use the channel's default"). NotificationService::notify()
is the single funnel everything goes through: it always asks
NotificationMailService to (maybe) send the email, then checks the
recipient's in-app preference before writing a Notification row at
all — skip the check and you'd persist rows for channels the user
explicitly turned off. Domain events don't call notify() directly;
SendNotificationListener::handle() is the one place that turns "this
happened" (IssueAssigned, CommentAdded, ProjectInvited, …) into
"tell this specific person, with this title/message" — see
../integrations/03-add-a-new-event-type.mdIntegrationsAdd a new event typeWorked example: adding IssueCreated — a kind of activity that doesn't exist yet. Today, creating an issue only fires IssueAssigned, and only if it's create…
for how an event itself gets created and registered. On the frontend,
AccountSettingsNotificationsTab.tsx's defaultNotificationTypes
array is a hand-maintained mirror of the backend's NotificationType
cases (icon, title, description per type) — nothing generates it from
the enum, so a new backend type is invisible in Settings until you add
its row there too.
The bell popup does not render the whole inbox: the shared prop carries
only the newest 20 notifications plus the real unread count, and the
popup pages older ones from GET /notifications (keyset cursor, unread
and severity filters), with selection mode for bulk read/delete — see
guide 3.
