Web push notifications let your app reach users even when they don't have a browser tab open — no native app required. The Laravel Notification Channels project is a community collection of notification drivers for Laravel, and their Web Push Notifications Channel adds push notifications into Laravel's existing notification system, so you write a notification class the same way you would for mail or Slack. Under the hood, it talks directly to the browser's Push API using VAPID keys, with no third-party push service in between. Chrome, Firefox, Edge, and Safari are all supported.
Installation
Pull in the package via Composer, then add the HasPushSubscriptions trait to your User model — this is what gives users their push subscription methods:
composer require laravel-notification-channels/webpush
use NotificationChannels\WebPush\HasPushSubscriptions; class User extends Model{ use HasPushSubscriptions;}
The package needs a database table to store subscriptions, so publish and run its migration:
php artisan vendor:publish --provider="NotificationChannels\WebPush\WebPushServiceProvider" --tag="migrations"php artisan migrate
Publish the config file if you need to customise anything:
php artisan vendor:publish --provider="NotificationChannels\WebPush\WebPushServiceProvider" --tag="config"
Finally, generate a VAPID key pair:
php artisan webpush:vapid
This adds VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY to your .env file. The public key gets shared with the browser when a user subscribes; the private key signs outgoing push messages so browsers can verify they came from you. Treat these like any other secret, and don't rotate them — every existing subscription is tied to the key pair that created it.
If you're targeting Safari or iOS, you'll also need to add a VAPID_SUBJECT — a URL or mailto: address that identifies your app. Apple requires it and will return a BadJwtToken error without it.
Client-Side Setup
Web push requires two things in the browser: a service worker to receive and display notifications, and a call to PushManager.subscribe() to get the subscription object you'll save on the server.
Note: The Push API only works in a secure context — your site must be served over HTTPS.
http://localhostis the one exception and works without TLS for local development.
Service Worker
Create a public/sw.js file. The service worker runs in the background and handles incoming push events:
self.addEventListener('push', function (event) { let data = {}; try { data = event.data?.json() ?? {}; } catch (e) { data = { title: 'Notification', body: event.data?.text() ?? '' }; } event.waitUntil( self.registration.showNotification(data.title, { body: data.body, icon: data.icon, badge: data.badge, data: data.data, actions: data.actions, }) );}); self.addEventListener('notificationclick', function (event) { event.notification.close(); const url = event.notification.data?.url ?? '/'; event.waitUntil( self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (clientList) { for (const client of clientList) { if (client.url === url && 'focus' in client) { return client.focus(); } } return self.clients.openWindow(url); }) );});
The notificationclick handler checks whether a window is already open at the target URL and focuses it rather than opening a duplicate tab.
Subscribing
The VAPID public key needs to be available to your JavaScript. A simple way is a meta tag in your layout:
<meta name="vapid-public-key" content="{{ config('webpush.vapid.public_key') }}">
Wire this up to a button click — browsers block the permission prompt unless it's triggered by a user gesture:
// VAPID keys are Base64URL-encoded; atob() requires standard Base64, so we convert firstfunction urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const rawData = atob(base64); return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));} async function subscribe() { if (!('serviceWorker' in navigator) || !('PushManager' in window)) { return; } const permission = await Notification.requestPermission(); if (permission !== 'granted') { return; } await navigator.serviceWorker.register('/sw.js'); const registration = await navigator.serviceWorker.ready; const contentEncoding = (PushManager.supportedContentEncodings || ['aesgcm'])[0]; const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array( document.querySelector('meta[name="vapid-public-key"]').content ), }); const { endpoint, keys: { p256dh, auth } } = subscription.toJSON(); await fetch('/push/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, }, body: JSON.stringify({ endpoint, key: p256dh, token: auth, encoding: contentEncoding }), });}
navigator.serviceWorker.ready is used rather than the registration returned by register() because register() resolves as soon as the script is parsed, not when the worker is active. ready waits for an active worker before calling pushManager.subscribe().
The /push/subscribe route on your server receives those four values and passes them to updatePushSubscription() on the authenticated user, covered in the next section.
Managing Subscriptions
On the server side, store what the browser just sent by calling updatePushSubscription() on the user:
$user->updatePushSubscription($endpoint, $key, $token, $contentEncoding);
You can also clean up when a user opts out:
$user->deletePushSubscription($endpoint);
One thing you don't have to manage manually is expired subscriptions. When a delivery attempt comes back with an expired endpoint, the package detects it and removes the subscription from the database automatically.
Sending a Notification
Once subscriptions are in place, sending a push notification is the same as any other Laravel notification. Add WebPushChannel to the via() method and implement toWebPush():
use Illuminate\Notifications\Notification;use NotificationChannels\WebPush\WebPushChannel;use NotificationChannels\WebPush\WebPushMessage; class GoalScored extends Notification{ public function __construct( protected string $scorer, protected string $team, protected int $minute, ) {} public function via($notifiable): array { return [WebPushChannel::class]; } public function toWebPush($notifiable, $notification): WebPushMessage { return (new WebPushMessage) ->title('GOAL! ' . $this->team) ->icon('/icons/football.png') ->body("{$this->scorer} scores in the {$this->minute}' ⚽") ->action('View match', 'view_match') ->data(['url' => '/matches/live']) ->options(['TTL' => 60]); }}
A short TTL makes sense for a goal alert — a notification that arrives an hour later isn't useful. WebPushMessage covers the full Push API surface: alongside title, body, icon, and action, you can set badge, image, tag, vibrate, requireInteraction, and more.
To send it, call notify() on the user as you would with any other Laravel notification:
$user->notify(new GoalScored(scorer: 'Eric Barnes', team: 'Laravel News FC', minute: 29));
Declarative Web Push
A newer alternative is DeclarativeWebPushMessage, which targets the Declarative Web Push spec. The main motivation is that traditional web push requires a service worker to be running to handle the push event — declarative push moves that logic to the browser itself, which has privacy and battery-life benefits on mobile. Browser support is still limited, but the API mirrors WebPushMessage closely:
use NotificationChannels\WebPush\DeclarativeWebPushMessage;use NotificationChannels\WebPush\WebPushChannel; public function toWebPush($notifiable, $notification): DeclarativeWebPushMessage{ return (new DeclarativeWebPushMessage) ->title('GOAL! ' . $this->team) ->icon('/icons/football.png') ->body("{$this->scorer} scores in the {$this->minute}' ⚽") ->action('View match', 'view_match', 'https://myapp.com/matches/live') ->navigate('https://myapp.com/matches/live');}
The key difference is navigate(), which tells the browser where to send the user when they tap the notification — no service worker notificationclick handler needed. Actions also accept a URL as a third argument for the same reason.
You can view the source code and learn more about the Web Push Notifications channel on GitHub.