Notifications Overview
SaaS4Builders ships a dual-channel notification system that delivers real-time updates to your users through WebSocket broadcasting (via Laravel Reverb) and Web Push notifications (via the Web Push protocol with VAPID keys). Notifications are persisted in the database for history, broadcast live to connected clients, and optionally pushed to users' devices even when the browser tab is closed.
Architecture at a Glance
The notification system has three layers:
- Event Dispatch — Domain events trigger broadcast notifications scoped to a tenant or the admin channel.
- Delivery Channels — Two parallel channels deliver the notification: WebSocket (in-app, real-time) and Web Push (OS-level, background).
- Persistence — Notifications are stored in the database so users can review their history, mark items as read, and paginate through past events.
Domain Event (e.g. SubscriptionCreated)
→ TenantNotification::dispatch()
├─→ Laravel Reverb (WebSocket)
│ → Frontend Echo listener
│ → In-app toast + notification list
└─→ DispatchPushNotifications (event subscriber)
→ SendTenantPushNotificationsJob (queued)
→ Web Push Gateway
→ Browser push service
→ OS notification
Notification Types
All notification types are defined in a single enum that serves as the central registry:
enum NotificationType: string
{
case SubscriptionCreated = 'subscription_created';
case PaymentFailed = 'payment_failed';
case SubscriptionCanceled = 'subscription_canceled';
case QuotaThresholdReached = 'quota_threshold_reached';
case TeamMemberAdded = 'team_member_added';
case AdminSubscriptionCreated = 'admin_subscription_created';
case AdminSubscriptionCanceled = 'admin_subscription_canceled';
}
Each type provides:
- Translation keys for title and body — fully localized in all four supported locales (EN, FR, ES, IT).
- Default severity —
success,warning,error, orinfo— used for visual styling on the frontend.
| Type | Severity | Channel | Description |
|---|---|---|---|
subscription_created | success | Tenant | A new subscription was activated |
payment_failed | error | Tenant | A payment charge failed |
subscription_canceled | warning | Tenant | A subscription was canceled |
quota_threshold_reached | warning | Tenant | A usage quota nears its limit |
team_member_added | info | Tenant | A new member joined the team |
admin_subscription_created | success | Admin | A tenant created a subscription (admin view) |
admin_subscription_canceled | warning | Admin | A tenant canceled a subscription (admin view) |
Notification Model
Notifications are persisted in the notifications table with UUID primary keys:
final class Notification extends Model
{
use HasFactory;
use HasUuids;
protected $fillable = [
'user_id',
'type',
'data',
'read_at',
];
protected function casts(): array
{
return [
'data' => 'array',
'read_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function scopeUnread(Builder $query): Builder
{
return $query->whereNull('read_at');
}
}
| Column | Type | Description |
|---|---|---|
id | UUID | Ordered UUID primary key |
user_id | int (FK) | The user who received the notification |
type | string | Matches a NotificationType enum value |
data | JSON | Arbitrary payload (plan name, member name, etc.) |
read_at | timestamp | null = unread, set when user marks as read |
created_at | timestamp | When the notification was created |
The table is indexed on [user_id, read_at, created_at] for efficient unread queries and pagination.
Broadcast Events
Every notification originates as a broadcast event that extends the abstract TenantNotification class:
abstract class TenantNotification implements ShouldBroadcast
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
public function __construct(
public string $tenantId,
) {}
public function broadcastOn(): array
{
return [
new PrivateChannel("tenant.{$this->tenantId}"),
];
}
abstract public function broadcastAs(): string;
abstract public function broadcastWith(): array;
}
Each concrete event implements broadcastAs() (the event name) and broadcastWith() (the payload with translated title/body). For example:
final class SubscriptionCreatedNotification extends TenantNotification
{
public function __construct(
string $tenantId,
private readonly string $planName,
) {
parent::__construct($tenantId);
}
public function broadcastAs(): string
{
return 'subscription.created';
}
public function broadcastWith(): array
{
return [
'type' => 'subscription_created',
'title' => __('notifications.subscription_created'),
'body' => __('notifications.subscription_created_body', ['plan' => $this->planName]),
'severity' => 'success',
'timestamp' => now()->toIso8601String(),
];
}
}
All broadcast payloads share the same structure: type, title, body, severity, and timestamp. This consistency allows the frontend to handle every notification type with a single handler.
Backend Delivery Pipeline
When a TenantNotification is dispatched, two things happen simultaneously:
1. WebSocket Broadcast (Synchronous)
Laravel Reverb picks up the ShouldBroadcast event and pushes it to the private-tenant.{tenantId} channel. Connected frontend clients receive it instantly via Laravel Echo.
2. Web Push Dispatch (Asynchronous)
The DispatchPushNotifications event subscriber listens to all five tenant notification types:
public function subscribe(Dispatcher $events): array
{
return [
SubscriptionCreatedNotification::class => 'handleNotification',
PaymentFailedNotification::class => 'handleNotification',
SubscriptionCanceledNotification::class => 'handleNotification',
QuotaThresholdReachedNotification::class => 'handleNotification',
TeamMemberAddedNotification::class => 'handleNotification',
];
}
When triggered, the subscriber:
- Finds the tenant's users who have desktop notifications enabled AND at least one push subscription registered.
- Dispatches a
SendTenantPushNotificationsJobwith the eligible user IDs and the broadcast payload.
The job is queued with 3 retries and progressive backoff (10s, 30s, 60s). It queries push subscriptions for those users, calls the Web Push gateway to send notifications in batch, and automatically cleans up expired subscriptions.
Notification API Endpoints
The notification REST API is scoped to the authenticated user (/api/v1/me/):
| Method | Endpoint | Description |
|---|---|---|
GET | /api/v1/me/docs/notifications | List notifications (paginated) |
GET | /api/v1/me/docs/notifications/unread-count | Get unread notification count |
PATCH | /api/v1/me/docs/notifications/{id}/read | Mark a single notification as read |
POST | /api/v1/me/docs/notifications/mark-all-read | Mark all notifications as read |
All endpoints require auth:sanctum middleware.
Frontend Composable
The useNotifications() composable provides a hybrid REST + WebSocket approach:
- REST API loads persisted notifications on page load (paginated, 20 per page).
- WebSocket (Laravel Echo via Reverb) receives real-time updates and prepends them to the list.
export function useNotifications(mode: NotificationMode = 'tenant'): UseNotificationsReturn {
// Returns:
// - notifications: readonly list of Notification objects
// - unreadCount: reactive unread count
// - hasUnread: computed boolean
// - loadMore(): paginate through older notifications
// - markAsRead(id): optimistic update with rollback on failure
// - markAllAsRead(): batch mark with optimistic update
// - refresh(): reload from API
// - clear(): reset state
}
The composable supports two modes:
tenant— Listens on theprivate-tenant.{tenantId}channel for 5 event types:subscription.created,payment.failed,subscription.canceled,quota.threshold_reached,team.member_added.admin— Listens on theplatform.adminchannel for 2 event types:admin.subscription.created,admin.subscription.canceled.
Each mode maintains its own module-level state that persists across navigation. When a real-time notification arrives, it is validated with a Zod schema, prepended to the list, and displayed as a toast notification.
Architecture Directory Structure
Backend
| Directory | Contents |
|---|---|
backend/app/Domain/Notifications/Enums/ | NotificationType enum |
backend/app/Domain/Push/Contracts/ | WebPushGatewayInterface |
backend/app/Domain/Push/DTO/ | PushPayloadData (immutable DTO) |
backend/app/Application/Push/Actions/ | StorePushSubscription, DeletePushSubscription |
backend/app/Application/Push/DTO/ | StorePushSubscriptionData (input DTO) |
backend/app/Infrastructure/Push/Providers/Minishlink/ | MinishWebPushGateway implementation |
backend/app/Events/Notifications/ | TenantNotification + 5 concrete event classes |
backend/app/Listeners/Push/ | DispatchPushNotifications subscriber |
backend/app/Jobs/Push/ | SendTenantPushNotificationsJob |
backend/app/Http/Controllers/Api/V1/Profile/ | PushSubscriptionController |
backend/app/Models/ | Notification, PushSubscription |
Frontend
| Directory | Contents |
|---|---|
frontend/features/core/docs/notifications/ | Notification composable, schemas, types, API client |
frontend/features/foundation/profile/ | Push subscription composable, API, component |
frontend/public/sw.js | Service worker for push event handling |
Adding a New Notification Type
To add a new notification type:
- Add the enum case to
NotificationTypewith title/body translation keys and default severity. - Create a concrete event extending
TenantNotificationwithbroadcastAs()andbroadcastWith(). - Register the event in the
DispatchPushNotificationssubscriber'ssubscribe()method. - Add translations in
backend/lang/{en,fr,es,it}/docs/notifications.php. - Add the Echo event name to the
TENANT_EVENTSorADMIN_EVENTSarray in the frontenduseNotificationscomposable.
What's Next
- Web Push — VAPID setup, service worker configuration, and the push subscription lifecycle.
- Settings Overview — The
notifications.desktop_enableduser setting that controls push delivery. - Billing Overview — Context for subscription and payment notification events.
Caching Strategy
Settings caching architecture: SettingsCacheManager with scope-specific TTLs, automatic invalidation on write, HTTP-level caching for public settings, and frontend promise deduplication.
Web Push
VAPID key setup, service worker registration, push subscription lifecycle, and the full backend-to-browser delivery pipeline for Web Push notifications.