Web Push
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):
| Variable | Required | Description |
|---|---|---|
VAPID_SUBJECT | No | Contact URL or mailto: for push service identification. Falls back to empty string if not set. |
VAPID_PUBLIC_KEY | Yes | The public key from the generator |
VAPID_PRIVATE_KEY | Yes | The private key from the generator |
Frontend (frontend/.env):
| Variable | Required | Description |
|---|---|---|
NUXT_PUBLIC_VAPID_PUBLIC_KEY | Yes | Same public key — exposed to the browser for subscription |
VAPID_PUBLIC_KEY in the root .env.Backend Configuration
The VAPID config is read from environment variables via 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:
// 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:
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:
- Checks for Service Worker support in the browser.
- Reads the VAPID public key from runtime config.
- Registers the
/sw.jsservice worker. - Calls
pushManager.subscribe()with the VAPID application server key. - Sends the subscription endpoint and keys to the backend via
POST /api/v1/me/push-subscriptions.
The unsubscribe flow:
- Gets the existing browser subscription.
- Calls
DELETE /api/v1/me/push-subscriptionsto remove it from the backend. - 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 thenotifications.desktop_enableduser 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.
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:
| Field | Type | Required | Description |
|---|---|---|---|
endpoint | string (URL) | Yes | The push service endpoint URL |
keys.p256dh | string | Yes | P-256 Diffie-Hellman public key (base64) |
keys.auth | string | Yes | Authentication secret (base64) |
content_encoding | string | No | aesgcm (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:
| Field | Type | Required | Description |
|---|---|---|---|
endpoint | string (URL) | Yes | The push service endpoint URL to remove |
Response (200):
{
"message": "Push subscription deleted."
}
Push Subscription Model
Subscriptions are stored in the push_subscriptions table:
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);
}
}
| Column | Type | Description |
|---|---|---|
id | int (auto-increment) | Primary key |
user_id | int (FK) | The user who registered this subscription |
endpoint | string (unique) | Push service endpoint URL |
p256dh_key | string | Public encryption key |
auth_token | string | Auth secret |
content_encoding | string | aesgcm or aes128gcm |
Backend Delivery Pipeline
Domain Contract
The push gateway is abstracted behind a domain contract:
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:
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,
]);
}
}
Minishlink Gateway Implementation
The default implementation uses the minishlink/web-push library:
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:
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:
- The user belongs to the tenant where the event occurred (via
tenant_usertable). - The
notifications.desktop_enableduser setting istrue(viauser_settingstable). - The user has at least one push subscription registered (via
push_subscriptionstable).
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:
- Create a new class implementing
WebPushGatewayInterfaceinbackend/app/Infrastructure/Push/Providers/YourProvider/. - 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:
- Generate VAPID keys:
docker compose exec php php artisan webpush:vapid - Add the keys to
backend/.envandfrontend/.env - Restart containers:
make restart - Enable desktop notifications in the user profile settings
- Trigger a notification event (e.g., create a subscription via the billing flow)
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:
| Browser | Push Support | Service Worker | Notes |
|---|---|---|---|
| Chrome / Edge | Yes | Yes | Full support |
| Firefox | Yes | Yes | Full support |
| Safari (macOS) | Yes (16.4+) | Yes | Requires user gesture for permission |
| Safari (iOS) | Yes (16.4+) | Yes | Requires "Add to Home Screen" |
| Opera | Yes | Yes | Full support |
The NotificationSettings component handles unsupported browsers gracefully by hiding the desktop notification toggle and displaying an explanatory message.
What's Next
- Notifications Overview — Architecture, event types, and the
useNotificationscomposable. - Settings Overview — The
notifications.desktop_enabledandnotifications.email_enableduser settings. - Subscriptions & Lifecycle — Context for billing-related notification events.
Notifications Overview
Real-time notification architecture with WebSocket broadcasting via Laravel Reverb and Web Push delivery, supporting tenant and admin notification channels.
Nuxt Content Setup
Content directory structure, collection definitions, multi-language support, Zod schemas, and MDC syntax for managing editorial content with Nuxt Content v3.