Skip to content
SaaS4Builders
Notifications

Notifications Overview

Real-time notification architecture with WebSocket broadcasting via Laravel Reverb and Web Push delivery, supporting tenant and admin notification channels.

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:

  1. Event Dispatch — Domain events trigger broadcast notifications scoped to a tenant or the admin channel.
  2. Delivery Channels — Two parallel channels deliver the notification: WebSocket (in-app, real-time) and Web Push (OS-level, background).
  3. 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:

backend/app/Domain/Notifications/Enums/NotificationType.php
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 severitysuccess, warning, error, or info — used for visual styling on the frontend.
TypeSeverityChannelDescription
subscription_createdsuccessTenantA new subscription was activated
payment_failederrorTenantA payment charge failed
subscription_canceledwarningTenantA subscription was canceled
quota_threshold_reachedwarningTenantA usage quota nears its limit
team_member_addedinfoTenantA new member joined the team
admin_subscription_createdsuccessAdminA tenant created a subscription (admin view)
admin_subscription_canceledwarningAdminA tenant canceled a subscription (admin view)

Notification Model

Notifications are persisted in the notifications table with UUID primary keys:

backend/app/Models/Notification.php
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');
    }
}
ColumnTypeDescription
idUUIDOrdered UUID primary key
user_idint (FK)The user who received the notification
typestringMatches a NotificationType enum value
dataJSONArbitrary payload (plan name, member name, etc.)
read_attimestampnull = unread, set when user marks as read
created_attimestampWhen 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:

backend/app/Events/Notifications/TenantNotification.php
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:

backend/app/Events/Notifications/SubscriptionCreatedNotification.php
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:

backend/app/Listeners/Push/DispatchPushNotifications.php
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:

  1. Finds the tenant's users who have desktop notifications enabled AND at least one push subscription registered.
  2. Dispatches a SendTenantPushNotificationsJob with 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/):

MethodEndpointDescription
GET/api/v1/me/docs/notificationsList notifications (paginated)
GET/api/v1/me/docs/notifications/unread-countGet unread notification count
PATCH/api/v1/me/docs/notifications/{id}/readMark a single notification as read
POST/api/v1/me/docs/notifications/mark-all-readMark 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.
frontend/features/core/docs/notifications/composables/useNotifications.ts
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 the private-tenant.{tenantId} channel for 5 event types: subscription.created, payment.failed, subscription.canceled, quota.threshold_reached, team.member_added.
  • admin — Listens on the platform.admin channel 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.

The composable returns a no-op implementation during SSR. Notifications are client-only — WebSocket connections and the Notifications API are not available on the server.

Architecture Directory Structure

Backend

DirectoryContents
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

DirectoryContents
frontend/features/core/docs/notifications/Notification composable, schemas, types, API client
frontend/features/foundation/profile/Push subscription composable, API, component
frontend/public/sw.jsService worker for push event handling

Adding a New Notification Type

To add a new notification type:

  1. Add the enum case to NotificationType with title/body translation keys and default severity.
  2. Create a concrete event extending TenantNotification with broadcastAs() and broadcastWith().
  3. Register the event in the DispatchPushNotifications subscriber's subscribe() method.
  4. Add translations in backend/lang/{en,fr,es,it}/docs/notifications.php.
  5. Add the Echo event name to the TENANT_EVENTS or ADMIN_EVENTS array in the frontend useNotifications composable.

What's Next

  • Web Push — VAPID setup, service worker configuration, and the push subscription lifecycle.
  • Settings Overview — The notifications.desktop_enabled user setting that controls push delivery.
  • Billing Overview — Context for subscription and payment notification events.