On this page
Developer guide

Notification

The Notification context handles in-app notifications, push notifications (Web Push via VAPID), email notifications, and webhook delivery to external services (Discord, Slack). It supports per-user notification preferences and HMAC-signed webhook delivery for security.

Domain Models

Model Type Purpose
Notification Aggregate Root In-app notification with read/unread state
NotificationPreference Model Per-user notification preferences
NotificationCategory Value Object (enum) Category of a notification event
NotificationChannel Value Object (enum) Delivery channel (InApp, Email, Push, Webhook)

Domain Events

Event Purpose
NotificationEvent Base event for all notification types

Domain Services

Service Purpose
EventCategoryResolver Maps domain events to notification categories
NotificationContentResolver Builds notification content from domain events

Commands and Handlers

Command Handler Purpose
CreateNotificationCommand CreateNotificationHandler Create an in-app notification
SeedDefaultPreferencesCommand SeedDefaultPreferencesHandler Initialize default preferences for a user
SendEmailCommand SendEmailHandler Deliver an email notification in the recipient's email language
SendPushCommand SendPushHandler Deliver a Web Push notification while notifications.push_enabled is on
SendWebhookCommand SendWebhookHandler Deliver a notification to an external webhook
ListWebhooksQuery ListWebhooksHandler List the configured webhooks, oldest first, without secrets (GET /api/webhooks and app:webhook:list)
CreateWebhookCommand CreateWebhookHandler Add a webhook and issue its signing secret (POST /api/webhooks and app:webhook:create)
UpdateWebhookCommand UpdateWebhookHandler Change a webhook's URL, its category filter, or both (PUT /api/webhooks/{id} and app:webhook:update)
RotateWebhookSecretCommand RotateWebhookSecretHandler Replace a webhook's signing secret; the webhook keeps its signing version (POST /api/webhooks/{id}/rotate-secret and app:webhook:rotate-secret)
DeleteWebhookCommand DeleteWebhookHandler Delete a webhook (DELETE /api/webhooks/{id} and app:webhook:delete)

Webhook Administration

WebhookController and the app:webhook:* commands dispatch the same commands and query, so the API and the shell share one use case for each action. The commands carry the input as the client sent it; the handlers validate it with WebhookInput. An ID that is not a UUID, a missing URL, a destination that WebhookDestinationPortInterface does not resolve to an allowed address, or a category filter that is neither null nor a list of supported categories raises InvalidInputException (HTTP 422, console exit INVALID). An unknown webhook raises NotFoundException (HTTP 404). UpdateWebhookCommand changes a field only when its changes* flag is set. A null category filter with the flag set clears the filter; without the flag, the filter stays as it is.

Create and rotate each generate a secret of 64 hex characters from random_bytes(32), store it encrypted through WebhookSecretPortInterface, and return the plain secret once in an IssuedWebhookSecret. WebhookRepositoryInterface takes the encrypted secret and returns WebhookView, which never carries it, so list and update cannot return a secret.

Ports

Port Purpose Implemented By
WebhookRepositoryInterface Store webhook configuration; takes the encrypted secret and returns WebhookView without it WebhookRepository
WebhookSecretPortInterface Encrypt and decrypt webhook signing secrets WebhookSecretCodec
WebhookDestinationPortInterface Resolve a webhook URL to its host and allowed IP addresses, or null WebhookDestinationPolicy
PushSubscriptionRegistrationPortInterface Register a push endpoint for a user or rotate its owner's credentials PushSubscriptionRepository
PushSubscriptionRemovalPortInterface Remove one or all of a user's push subscriptions PushSubscriptionRepository

The context reads the recipient's email language through UserPreference's UserSettingsContractInterface.

Email Language

SendEmailCommand carries translation keys, not text: the title and body keys and their parameters. A parameter is a scalar or a TranslatableParameter, so a fallback value such as an unknown user name is translated too and no English fragment lands in a Danish or Thai email. SendEmailHandler asks UserSettingsContractInterface::resolveLanguage() for the recipient's language when the worker sends the email, then translates the subject, header, title, body and template text in that locale, passing it explicitly; it never changes the shared translator's locale. A language changed after the notification was queued, such as a new server default, therefore applies to the email, and the email can still be written when the user has deleted the notification in the meantime. If the lookup or the send fails, the handler logs the failure and rethrows, so Messenger retries the message.

NotificationMessagePayloadCodec writes this shape and rejects messages queued in the earlier shape, which carried English text.

In-app notifications, push and webhook text stay in English. CreateNotificationHandler translates them with SupportedLanguages::FALLBACK explicitly rather than in whatever locale the shared translator has.

Server Settings

NotificationSettingDefinitions contributes notifications.push_enabled (default true). While it is off, SendPushHandler skips the push and logs the skip; the notification itself is still created and the other channels are unaffected.

notifications.admin_alerts (default true) is defined in Shared's SharedSettingDefinitions, because the health monitor that reads it lives in Shared. While it is off, HealthAlertService logs a health degradation without alerting administrators. Other admin alerts, such as new user registrations, are always sent.

The web server runs the health monitor. HealthMonitorSubscriber starts a Swoole timer in HTTP worker 0 that calls HealthAlertService::checkAndAlert() every HEALTH_MONITOR_INTERVAL_SECONDS (default 60). Each component's alert state (healthy, pending or acknowledged, with the outage window) lives in HealthAlertTable, a Swoole\Table created before the server forks its workers, so worker reloads keep it; HealthAlertTransition holds the rules as pure functions. A component that reads unhealthy owes one alert until it is delivered, also after the component recovers. Delivery reads the setting and saves the notifications through AdminAlertPortInterface inside one error boundary per component: a failure is logged, and the next tick retries. No delivery is tried while PostgreSQL reads unhealthy. The initial migration seeds no health-check schedule. See Health alerts for what operators see.

SendPushHandler reads its setting for every message and HealthAlertService reads its setting for every alert it delivers, so a change applies without a restart. See Settings for the mechanism.

API Endpoints

All endpoints are prefixed with /api.

Notifications

Method Path Controller Purpose
GET /api/notifications NotificationController List user's notifications (paginated)
GET /api/notifications/unread-count NotificationController Get unread notification count
PATCH /api/notifications/{publicId}/read NotificationController Mark notification as read
PATCH /api/notifications/read-all NotificationController Mark all notifications as read
DELETE /api/notifications/{publicId} NotificationController Delete a notification

markRead and delete load the notification by publicId and refuse it unless it belongs to the signed-in user.

Preferences

Method Path Controller Purpose
GET /api/notifications/preferences PreferenceController List notification preferences
PUT /api/notifications/preferences PreferenceController Update notification preferences

Push Subscriptions

Method Path Controller Purpose
POST /api/push/subscribe PushSubscriptionController Subscribe to Web Push
DELETE /api/push/subscribe PushSubscriptionController Unsubscribe from Web Push
DELETE /api/push/subscriptions PushSubscriptionController Remove all push subscriptions

Webhooks

The entire controller is gated with #[IsGranted('ROLE_ADMIN')] — only admins can manage webhooks.

Method Path Controller Purpose
GET /api/webhooks WebhookController List all webhooks (admin)
POST /api/webhooks WebhookController Create a webhook (admin, returns one-time secret)
PUT /api/webhooks/{id} WebhookController Update a webhook (admin)
POST /api/webhooks/{id}/rotate-secret WebhookController Rotate a webhook's secret (admin, returns one-time secret)
DELETE /api/webhooks/{id} WebhookController Delete a webhook (admin)

Cross-Context Dependencies

Direction Context Relationship
Depends on Shared Uses Uuid, PublicId, CursorPaginatedResponse
Depends on Auth User identification and authentication
Depends on UserPreference SendEmailHandler resolves the email language through UserSettingsContractInterface (the UserPreference Settings Contract Deptrac layer)
Depends on Shared SystemSettingsPortInterface for notifications.push_enabled
Depended on by All contexts Any context that emits domain events can trigger notifications

Infrastructure

Doctrine Entities

Entity Purpose
NotificationEntity In-app notification persistence
NotificationPreferenceEntity User notification preferences
PushSubscriptionEntity Web Push subscription data
WebhookEntity Webhook configuration
WebhookDeliveryLogEntity Webhook delivery audit log

WebhookRepository implements WebhookRepositoryInterface over WebhookEntity.

Webhook Delivery

Component Purpose
DiscordAdapter Formats and delivers payloads to Discord
SlackAdapter Formats and delivers payloads to Slack
HmacSigner Signs webhook payloads with HMAC for verification
WebhookDeliveryService Orchestrates webhook delivery and retry logic

Console Commands

Command Purpose
app:webhook:list (WebhookListCommand) List the configured webhooks, without their secrets
app:webhook:create (WebhookCreateCommand) Add a webhook and print its signing secret once
app:webhook:update (WebhookUpdateCommand) Change a webhook's URL or categories; --category replaces the filter and --all-categories clears it
app:webhook:rotate-secret (WebhookRotateSecretCommand) Replace a webhook's signing secret and print the new one once
app:webhook:delete (WebhookDeleteCommand) Delete a webhook
app:generate-vapid-keys (GenerateVapidKeysCommand) Generate VAPID keys for Web Push

Security

Component Purpose
NotificationVoter Authorization voter for notification ownership. No controller calls it; markRead and delete check ownership in NotificationController.

PublicId Type Gotcha

The NotificationRepository.findByPublicId() method must pass a PublicId value object to the custom PublicIdType Doctrine column, not a raw string. Passing a string causes the type converter to throw, resulting in a 500 error. Always wrap: PublicId::fromString($id).