Web Push Notifications Implementation
Web Push allows sending push notifications to browser users — like mobile apps, but without App Store. Works via browser Service Worker even when site is closed.
Web Push architecture
Application → Push Service (Google FCM / Mozilla) → Browser → Service Worker → Notification
VAPID (Voluntary Application Server Identification) — authentication standard for notification server.
Generate VAPID keys
npx web-push generate-vapid-keys
# VAPID_PUBLIC_KEY=BEl62...
# VAPID_PRIVATE_KEY=Uf..._...
// .env
VAPID_PUBLIC_KEY=BEl62iUYagI8ghDIpicv9...
VAPID_PRIVATE_KEY=Uf7yFBAs-jWnM...
VAPID_SUBJECT=mailto:[email protected]
Browser subscription
// push-subscription.ts
const VAPID_PUBLIC_KEY = import.meta.env.VITE_VAPID_PUBLIC_KEY;
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)));
}
export async function subscribeToPush(): Promise<boolean> {
if (!('PushManager' in window)) {
console.warn('Push notifications not supported');
return false;
}
const permission = await Notification.requestPermission();
if (permission !== 'granted') return false;
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
// Send subscription to server
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription),
});
return true;
}
export async function unsubscribeFromPush(): Promise<void> {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
await fetch('/api/push/unsubscribe', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
}
}
Service Worker: handle push messages
// sw.js
self.addEventListener('push', event => {
const data = event.data?.json() ?? {};
const options = {
body: data.body ?? 'New notification',
icon: data.icon ?? '/icons/icon-192.png',
badge: '/icons/badge-72.png',
image: data.image,
tag: data.tag ?? 'default',
renotify: data.renotify ?? false,
data: { url: data.url ?? '/' },
actions: data.actions ?? [],
requireInteraction: data.requireInteraction ?? false,
};
event.waitUntil(
self.registration.showNotification(data.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 => {
const existing = windowClients.find(c => c.url === url && 'focus' in c);
if (existing) return existing.focus();
return clients.openWindow(url);
})
);
});
Laravel backend
// Install package
// composer require minishlink/web-push
// Migration
Schema::create('push_subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('endpoint')->unique();
$table->string('public_key');
$table->string('auth_token');
$table->json('user_agent_data')->nullable();
$table->timestamps();
});
// Subscription controller
class PushSubscriptionController extends Controller
{
public function subscribe(Request $request): JsonResponse
{
$data = $request->validate([
'endpoint' => 'required|url',
'keys.p256dh' => 'required|string',
'keys.auth' => 'required|string',
]);
PushSubscription::updateOrCreate(
['endpoint' => $data['endpoint']],
[
'user_id' => auth()->id(),
'public_key' => $data['keys']['p256dh'],
'auth_token' => $data['keys']['auth'],
]
);
return response()->json(['status' => 'ok']);
}
}
// Send notification
use Minishlink\WebPush\WebPush;
use Minishlink\WebPush\Subscription;
class SendPushNotification
{
public function send(PushSubscription $sub, array $payload): void
{
$webPush = new WebPush([
'VAPID' => [
'subject' => config('services.vapid.subject'),
'publicKey' => config('services.vapid.public_key'),
'privateKey' => config('services.vapid.private_key'),
],
]);
$webPush->queueNotification(
Subscription::create([
'endpoint' => $sub->endpoint,
'contentEncoding' => 'aesgcm',
'keys' => [
'p256dh' => $sub->public_key,
'auth' => $sub->auth_token,
],
]),
json_encode($payload)
);
foreach ($webPush->flush() as $report) {
if (!$report->isSuccess()) {
if ($report->isSubscriptionExpired()) {
PushSubscription::where('endpoint', $report->getEndpoint())->delete();
}
}
}
}
}
Notification scenarios
// Order status changed
class OrderStatusChanged
{
public function handle(Order $order): void
{
$user = $order->user;
$subscriptions = PushSubscription::where('user_id', $user->id)->get();
foreach ($subscriptions as $sub) {
$this->sender->send($sub, [
'title' => 'Order status changed',
'body' => "Order #{$order->number}: {$order->status_label}",
'icon' => '/icons/order-icon.png',
'url' => "/account/orders/{$order->id}",
'tag' => "order-{$order->id}",
'renotify' => true,
'actions' => [
['action' => 'view', 'title' => 'View order'],
['action' => 'dismiss', 'title' => 'Close'],
],
]);
}
}
}
Implementation time: 1–2 days for subscription, SW handler and server sending.







