Skip to content
SaaS4Builders
Notifications

Web Push

VAPID key setup, service worker registration, push subscription lifecycle, and the full backend-to-browser delivery pipeline for Web Push notifications.

SaaS4Builders implements the Web Push protocol using VAPID authentication, backed by the minishlink/web-push PHP library. Users can opt in to desktop notifications from their profile settings, and the system handles the full lifecycle: service worker registration, browser subscription, backend storage, batch delivery, and automatic cleanup of expired subscriptions.


VAPID Key Setup

Web Push requires a VAPID (Voluntary Application Server Identification) key pair. The public key is shared with browsers during subscription; the private key signs push messages on the server.

Generate Keys

Run the built-in Artisan command:

docker compose exec php php artisan webpush:vapid

This outputs a key pair:

VAPID_PUBLIC_KEY=BPj7KZ...base64...
VAPID_PRIVATE_KEY=dGhpc...base64...

Environment Variables

Add the keys to your environment files:

Backend (backend/.env):

VariableRequiredDescription
VAPID_SUBJECTNoContact URL or mailto: for push service identification. Falls back to empty string if not set.
VAPID_PUBLIC_KEYYesThe public key from the generator
VAPID_PRIVATE_KEYYesThe private key from the generator

Frontend (frontend/.env):

VariableRequiredDescription
NUXT_PUBLIC_VAPID_PUBLIC_KEYYesSame public key — exposed to the browser for subscription
The public key must be identical in both backend and frontend. A mismatch will cause push subscription to fail silently. When using Docker Compose, the frontend variable is automatically mapped from VAPID_PUBLIC_KEY in the root .env.

Backend Configuration

The VAPID config is read from environment variables via backend/config/webpush.php:

backend/config/webpush.php
return [
    'vapid' => [
        'subject' => env('VAPID_SUBJECT', ''),
        'public_key' => env('VAPID_PUBLIC_KEY', ''),
        'private_key' => env('VAPID_PRIVATE_KEY', ''),
    ],
];

The VAPID_SUBJECT should be either a URL (your app's URL) or a mailto: address. If omitted, it falls back to an empty string — push services may reject messages without a valid subject in production.


Service Worker

The service worker handles incoming push events and notification clicks. It lives as a static file at frontend/public/sw.js:

frontend/public/sw.js
// Service Worker for Web Push Notifications
self.addEventListener('push', (event) => {
  if (!event.data) return

  const payload = event.data.json()

  const options = {
    body: payload.body || '',
    icon: '/favicon.ico',
    tag: payload.type || 'notification',
    data: { url: '/', ...payload.data },
  }

  event.waitUntil(
    self.registration.showNotification(payload.title || 'Notification', options)
  )
})

self.addEventListener('notificationclick', (event) => {
  event.notification.close()

  const url = event.notification.data?.url || '/'

  event.waitUntil(
    clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
      // Focus existing tab if available
      for (const client of windowClients) {
        if (client.url.includes(self.location.origin) && 'focus' in client) {
          return client.focus()
        }
      }
      // Open new tab
      return clients.openWindow(url)
    })
  )
})

Key behaviors:

  • Push event: Parses the JSON payload from the push service and displays an OS notification with the title, body, icon, and a tag (deduplication key).
  • Notification click: Closes the notification and either focuses an existing app tab or opens a new one.

The service worker is plain JavaScript (not TypeScript) and is served directly from the public/ directory — no build step required.


Frontend Subscription Flow

The usePushSubscription Composable

The composable manages the browser push subscription lifecycle:

frontend/features/foundation/profile/composables/usePushSubscription.ts
export function usePushSubscription() {
  const config = useRuntimeConfig()
  const pushApi = usePushApi()

  async function subscribe(): Promise<boolean> {
    if (!('serviceWorker' in navigator)) return false

    const vapidKey = config.public.vapidPublicKey as string
    if (!vapidKey) {
      console.error('[PushSubscription] VAPID public key is not configured')
      return false
    }

    const registration = await navigator.serviceWorker.register('/sw.js')
    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(vapidKey),
    })
    await pushApi.storePushSubscription(subscription)
    return true
  }

  async function unsubscribe(): Promise<boolean> {
    const registration = await navigator.serviceWorker.ready
    const subscription = await registration.pushManager.getSubscription()
    if (subscription) {
      await pushApi.deletePushSubscription(subscription.endpoint)
      await subscription.unsubscribe()
    }
    return true
  }

  async function getExistingSubscription(): Promise<PushSubscription | null> {
    const registration = await navigator.serviceWorker.ready
    return await registration.pushManager.getSubscription()
  }

  return {
    subscribe,
    unsubscribe,
    getExistingSubscription,
    isRegistering: readonly(isRegistering),
    isUnregistering: readonly(isUnregistering),
  }
}

The subscribe flow:

  1. Checks for Service Worker support in the browser.
  2. Reads the VAPID public key from runtime config.
  3. Registers the /sw.js service worker.
  4. Calls pushManager.subscribe() with the VAPID application server key.
  5. Sends the subscription endpoint and keys to the backend via POST /api/v1/me/push-subscriptions.

The unsubscribe flow:

  1. Gets the existing browser subscription.
  2. Calls DELETE /api/v1/me/push-subscriptions to remove it from the backend.
  3. Calls subscription.unsubscribe() to remove it from the browser.

The NotificationSettings Component

The NotificationSettings.vue component provides the user-facing toggle for desktop notifications. It is wrapped in <ClientOnly> since the Web Notifications API is browser-only:

  • Turning ON: Requests browser permission via Notification.requestPermission(), subscribes to push, then saves the notifications.desktop_enabled user setting.
  • Turning OFF: Unsubscribes from push, then saves the setting.
  • On mount: Checks if browser permission was revoked externally, verifies push subscription consistency, and silently re-subscribes if the browser subscription was lost (e.g., cleared browser data) but the setting is still enabled.
Browser notification permission (granted/denied) persists independently from the user setting. If a user denies permission at the browser level, the component displays a help message explaining how to re-enable it in browser settings.

Push Subscription API

Push subscription endpoints are scoped to the authenticated user, not to a specific tenant — a single subscription works across all tenant contexts.

Register a Subscription

POST /api/v1/me/push-subscriptions

Auth: Bearer token (Sanctum) Rate limit: 10 requests per minute

Request body:

FieldTypeRequiredDescription
endpointstring (URL)YesThe push service endpoint URL
keys.p256dhstringYesP-256 Diffie-Hellman public key (base64)
keys.authstringYesAuthentication secret (base64)
content_encodingstringNoaesgcm (default) or aes128gcm

Response (201):

{
  "message": "Push subscription stored."
}

The endpoint performs an upsert by endpoint URL — if the same browser re-subscribes with the same endpoint, the existing record is updated rather than duplicated.

Remove a Subscription

DELETE /api/v1/me/push-subscriptions

Auth: Bearer token (Sanctum) Rate limit: 10 requests per minute

Request body:

FieldTypeRequiredDescription
endpointstring (URL)YesThe push service endpoint URL to remove

Response (200):

{
  "message": "Push subscription deleted."
}

Push Subscription Model

Subscriptions are stored in the push_subscriptions table:

backend/app/Models/PushSubscription.php
final class PushSubscription extends Model
{
    use HasFactory;

    protected $fillable = [
        'user_id',
        'endpoint',
        'p256dh_key',
        'auth_token',
        'content_encoding',
    ];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}
ColumnTypeDescription
idint (auto-increment)Primary key
user_idint (FK)The user who registered this subscription
endpointstring (unique)Push service endpoint URL
p256dh_keystringPublic encryption key
auth_tokenstringAuth secret
content_encodingstringaesgcm or aes128gcm
Push subscriptions are user-scoped, not tenant-scoped. A user's browser subscription works regardless of which tenant they are viewing. This is intentional — push is tied to the browser/device, not the organizational context.

Backend Delivery Pipeline

Domain Contract

The push gateway is abstracted behind a domain contract:

backend/app/Domain/Push/Contracts/WebPushGatewayInterface.php
interface WebPushGatewayInterface
{
    /**
     * Send a push notification to multiple subscriptions.
     * Returns the list of expired/invalid subscription IDs to clean up.
     */
    public function sendBatch(Collection $subscriptions, PushPayloadData $payload): array;
}

Payload DTO

Push payloads are represented as an immutable DTO:

backend/app/Domain/Push/DTO/PushPayloadData.php
final readonly class PushPayloadData
{
    public function __construct(
        public string $title,
        public string $body,
        public string $type,
        public string $severity,
        public array $data = [],
    ) {}

    public function toJson(): string
    {
        return (string) json_encode([
            'title' => $this->title,
            'body' => $this->body,
            'type' => $this->type,
            'severity' => $this->severity,
            'data' => $this->data,
        ]);
    }
}

The default implementation uses the minishlink/web-push library:

backend/app/Infrastructure/Push/Providers/Minishlink/MinishWebPushGateway.php
final class MinishWebPushGateway implements WebPushGatewayInterface
{
    private WebPush $webPush;

    public function __construct()
    {
        $this->webPush = new WebPush([
            'VAPID' => [
                'subject' => (string) (config('webpush.vapid.subject') ?? ''),
                'publicKey' => (string) (config('webpush.vapid.public_key') ?? ''),
                'privateKey' => (string) (config('webpush.vapid.private_key') ?? ''),
            ],
        ]);
    }

    public function sendBatch(Collection $subscriptions, PushPayloadData $payload): array
    {
        $json = $payload->toJson();
        $expiredIds = [];

        foreach ($subscriptions as $sub) {
            $this->webPush->queueNotification(
                Subscription::create([
                    'endpoint' => $sub->endpoint,
                    'publicKey' => $sub->p256dh_key,
                    'authToken' => $sub->auth_token,
                    'contentEncoding' => $sub->content_encoding ?? 'aesgcm',
                ]),
                $json,
            );
        }

        foreach ($this->webPush->flush() as $report) {
            if (is_object($report) && method_exists($report, 'isSubscriptionExpired') && $report->isSubscriptionExpired()) {
                $endpoint = method_exists($report, 'getEndpoint') ? $report->getEndpoint() : null;
                $expired = $subscriptions->first(
                    fn (PushSubscription $s) => $s->endpoint === $endpoint
                );
                if ($expired !== null) {
                    $expiredIds[] = $expired->id;
                }
            }
        }

        return $expiredIds;
    }
}

The gateway queues all subscriptions in a single batch, flushes them to push services, and returns a list of expired subscription IDs. The calling job deletes these expired records automatically.

The Queued Job

The SendTenantPushNotificationsJob ties everything together:

backend/app/Jobs/Push/SendTenantPushNotificationsJob.php
final class SendTenantPushNotificationsJob implements ShouldQueue
{
    public int $tries = 3;

    public function __construct(
        private readonly array $userIds,
        private readonly array $broadcastPayload,
    ) {
        $this->onQueue('default');
    }

    public function backoff(): array
    {
        return [10, 30, 60]; // Progressive backoff in seconds
    }

    public function handle(WebPushGatewayInterface $gateway): void
    {
        $subscriptions = PushSubscription::query()
            ->whereIn('user_id', $this->userIds)
            ->get();

        if ($subscriptions->isEmpty()) {
            return;
        }

        $payload = new PushPayloadData(
            title: (string) ($this->broadcastPayload['title'] ?? ''),
            body: (string) ($this->broadcastPayload['body'] ?? ''),
            type: (string) ($this->broadcastPayload['type'] ?? ''),
            severity: (string) ($this->broadcastPayload['severity'] ?? 'info'),
        );

        $expiredIds = $gateway->sendBatch($subscriptions, $payload);

        if ($expiredIds !== []) {
            PushSubscription::query()->whereIn('id', $expiredIds)->delete();
        }
    }
}

Key characteristics:

  • 3 retries with 10s, 30s, 60s backoff — handles transient push service failures.
  • Auto-cleanup — expired subscriptions (e.g., user cleared browser data) are deleted after each batch.
  • Reuses broadcast payload — the same translated content from broadcastWith() is used for both WebSocket and push delivery, ensuring consistency.

User Eligibility

Not every user receives push notifications. The DispatchPushNotifications subscriber filters eligible users by checking:

  1. The user belongs to the tenant where the event occurred (via tenant_user table).
  2. The notifications.desktop_enabled user setting is true (via user_settings table).
  3. The user has at least one push subscription registered (via push_subscriptions table).

Only users matching all three criteria are included in the dispatched job.


Replacing the Push Provider

The system uses a domain contract pattern, making it straightforward to replace minishlink/web-push with another provider:

  1. Create a new class implementing WebPushGatewayInterface in backend/app/Infrastructure/Push/Providers/YourProvider/.
  2. Update the binding in AppServiceProvider::register():
$this->app->bind(WebPushGatewayInterface::class, YourWebPushGateway::class);

The rest of the system (events, listener, job, frontend) works unchanged.


Testing Locally

The notification system includes comprehensive test coverage:

  • 28 backend tests covering push subscription CRUD, action unit tests, job dispatch, listener eligibility filtering, and expired subscription cleanup.
  • 31 frontend tests covering the push API client, subscription composable, and notification settings component with permission flows.

To test push notifications end-to-end locally:

  1. Generate VAPID keys: docker compose exec php php artisan webpush:vapid
  2. Add the keys to backend/.env and frontend/.env
  3. Restart containers: make restart
  4. Enable desktop notifications in the user profile settings
  5. Trigger a notification event (e.g., create a subscription via the billing flow)
Push notifications require HTTPS in production. In local development, localhost is treated as a secure context by browsers, so push works without SSL. However, some browsers may still block push subscriptions if the service worker is not served over a trusted origin.

Browser Compatibility

Web Push is supported by all modern browsers:

BrowserPush SupportService WorkerNotes
Chrome / EdgeYesYesFull support
FirefoxYesYesFull support
Safari (macOS)Yes (16.4+)YesRequires user gesture for permission
Safari (iOS)Yes (16.4+)YesRequires "Add to Home Screen"
OperaYesYesFull support

The NotificationSettings component handles unsupported browsers gracefully by hiding the desktop notification toggle and displaying an explanatory message.


What's Next