Get expert guidance in a few days with a Laravel code review

Laravel Web Push Notifications

Last updated on by

Laravel Web Push Notifications image

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://localhost is 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 first
function 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.

Yannick Lyn Fatt photo

Staff Writer at Laravel News and Full stack web developer.

Cube

Laravel Newsletter

Join 40k+ other developers and never miss out on new tips, tutorials, and more.

image
SerpApi

The Web Search API for Your LLM and AI Applications

Visit SerpApi
Lucky Media logo

Lucky Media

Get Lucky Now - the ideal choice for Laravel Development, with over a decade of experience!

Lucky Media
Harpoon: Next generation time tracking and invoicing logo

Harpoon: Next generation time tracking and invoicing

The next generation time-tracking and billing software that helps your agency plan and forecast a profitable future.

Harpoon: Next generation time tracking and invoicing
Get expert guidance in a few days with a Laravel code review logo

Get expert guidance in a few days with a Laravel code review

Expert code review! Get clear, practical feedback from two Laravel devs with 10+ years of experience helping teams build better apps.

Get expert guidance in a few days with a Laravel code review
Acquaint Softtech logo

Acquaint Softtech

Acquaint Softtech offers AI-ready Laravel developers who onboard in 48 hours at $3000/Month with no lengthy sales process and a 100 percent money-back guarantee.

Acquaint Softtech
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS Starter Kit

SaaSykit is a Multi-tenant Laravel SaaS Starter Kit that comes with all features required to run a modern SaaS. Payments, Beautiful Checkout, Admin Panel, User dashboard, Auth, Ready Components, Stats, Blog, Docs and more.

SaaSykit: Laravel SaaS Starter Kit
PhpStorm logo

PhpStorm

The go-to PHP IDE with extensive out-of-the-box support for Laravel and its ecosystem.

PhpStorm
Laravel Cloud logo

Laravel Cloud

Easily create and manage your servers and deploy your Laravel applications in seconds.

Laravel Cloud
Kirschbaum logo

Kirschbaum

Providing innovation and stability to ensure your web application succeeds.

Kirschbaum
MongoDB logo

MongoDB

Enhance your PHP applications with the powerful integration of MongoDB and Laravel, empowering developers to build applications with ease and efficiency. Support transactional, search, analytics and mobile use cases while using the familiar Eloquent APIs. Discover how MongoDB's flexible, modern database can transform your Laravel applications.

MongoDB
Shift logo

Shift

Running an old Laravel version? Instant, automated Laravel upgrades and code modernization to keep your applications fresh.

Shift
Tinkerwell logo

Tinkerwell

The must-have code runner for Laravel developers. Tinker with AI, autocompletion and instant feedback on local and production environments.

Tinkerwell

The latest

View all →
RedBerry to Host Georgia's First Laravel Meetup in Tbilisi image

RedBerry to Host Georgia's First Laravel Meetup in Tbilisi

Read article
Interruptible Jobs in Laravel 13.7.0 image

Interruptible Jobs in Laravel 13.7.0

Read article
A Free Shift to Check If Your App is Ready for Laravel Cloud image

A Free Shift to Check If Your App is Ready for Laravel Cloud

Read article
Laravel Idempotency: HTTP Idempotency Middleware for Laravel image

Laravel Idempotency: HTTP Idempotency Middleware for Laravel

Read article
Polyscope for Windows is Now Available image

Polyscope for Windows is Now Available

Read article
Laravel Sluggable image

Laravel Sluggable

Read article