amjadiqbal / laravel-webpush-kit
The browser side of Laravel web push notifications for laravel-notification-channels/webpush: a Blade and Livewire subscribe button with a polite two-step permission prompt, a service worker, routes that save and delete push subscriptions, iOS Home Screen guidance, declarative web push, and a push:t
Requires
- php: ^8.3
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/notifications: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- illuminate/view: ^12.0|^13.0
- laravel-notification-channels/webpush: ^11.0|^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Laravel WebPush Kit: browser push notifications for Laravel
laravel-notification-channels/webpush
sends web push notifications from Laravel. Everything that happens in the browser is left to you: the
service worker, the Push API subscribe and unsubscribe calls, the permission prompt, saving the
subscription, and the iPhone rules. This package covers that part:
- a Blade and Livewire subscribe button with a polite two-step permission prompt. The browser's own prompt only appears after the user says yes on your page;
- a service worker that shows normal and declarative web push messages and opens the right page on click;
- routes that save and delete the signed-in user's subscription through the channel's own
updatePushSubscription()anddeletePushSubscription(); - iPhone and iPad guidance: Add to Home Screen when it's needed, plus an optional web app manifest;
- subscriptions that stay alive: the service worker re-subscribes when the browser rotates a subscription, and each page visit re-syncs it;
php artisan push:test, which sends a real push and shows what each push service answered, including dead subscriptions the channel removes on 404 and 410.
Docs and demo: https://amjad.com.pk/laravel/packages/laravel-webpush-kit
<x-webpush-kit::button />
php artisan push:test
Requirements
- PHP 8.3 or newer, Laravel 12 or 13
laravel-notification-channels/webpush11, 12 or 13 (installed with this package)- HTTPS in production. Browsers allow push on
localhostwithout it.
Browser support
Tested in Chrome with Google's push service: subscribing and turning off from the button, normal and
declarative pushes, a real 410 removing a dead subscription, Livewire and wire:navigate, and the
blocked state. Not tested yet: clicking a delivered notification (the click target is set and checked,
but the macOS banner never appeared on the test machine), Firefox, and Safari on macOS and iOS. Reports
from those browsers are welcome in the issues.
Installation
composer require amjadiqbal/laravel-webpush-kit
If you haven't set up the channel yet, do that first. It takes three commands:
php artisan vendor:publish --provider="NotificationChannels\WebPush\WebPushServiceProvider" --tag=migrations
php artisan migrate
php artisan webpush:vapid
Then add the channel's trait to your user model:
use NotificationChannels\WebPush\HasPushSubscriptions; class User extends Authenticatable { use HasPushSubscriptions, Notifiable; }
Set a contact for the push services in .env. Safari rejects pushes when it's missing or malformed:
VAPID_SUBJECT=mailto:you@yourdomain.com
Usage
The subscribe button
Put the button anywhere a signed-in user can see it. Guests see nothing.
<x-webpush-kit::button />
Clicking it doesn't open the browser's permission dialog straight away. It first shows a short card that says what the notifications are for. Only Allow asks the browser. Not now closes the card, and the browser's one-time prompt isn't used up on someone who wasn't ready. That matters because once a user blocks notifications, a site can't ask again.
| Prop | Default | |
|---|---|---|
title |
"Get notified?" | Heading of the card |
body |
"We will send a notification whenโฆ" | What the notifications are for |
prompt |
click |
auto shows the card by itself after delay seconds |
delay |
5 |
Seconds before the auto card appears |
remind-after-days |
7 |
After Not now, how long auto stays quiet |
theme |
page | dark, or auto to follow the system. By default it's dark inside .dark or [data-theme="dark"] |
<x-webpush-kit::button prompt="auto" title="Order updates?" body="We'll tell you when your order ships. You can turn this off at any time." />
The button shows the right thing in every state: the offer, Turn off notifications once subscribed, how to unblock a blocked site, a note on browsers without push, and the Home Screen steps on iPhone and iPad.
Livewire
The button works inside Livewire components and across wire:navigate. It keeps its own state
(wire:ignore), and the script loads once (data-navigate-once). The button fires browser events you can
listen for:
<div x-on:webpush-kit:subscribed.window="$wire.$refresh()"> <x-webpush-kit::button /> </div>
webpush-kit:subscribed and webpush-kit:unsubscribed both carry detail.endpoint.
Sending notifications
Send notifications with the channel as usual. To open a page on click, put its URL in data.url. Each
action can open its own page through data.actions:
use NotificationChannels\WebPush\WebPushChannel; use NotificationChannels\WebPush\WebPushMessage; public function via($notifiable): array { return [WebPushChannel::class]; } public function toWebPush($notifiable, $notification): WebPushMessage { return (new WebPushMessage) ->title('Your order shipped') ->body('Order #1042 is on its way.') ->icon('/icon-192.png') ->action('Track it', 'track') ->data([ 'url' => route('orders.show', 1042), 'actions' => ['track' => route('orders.track', 1042)], ]); }
Declarative web push
Safari 18.4 (iOS, iPadOS) and macOS 15.5 can show a declarative web push message with no service worker running. Other browsers still need one. The kit's service worker reads the same message, so one notification works in both:
use NotificationChannels\WebPush\DeclarativeWebPushMessage; public function toWebPush($notifiable, $notification): DeclarativeWebPushMessage { return (new DeclarativeWebPushMessage) ->title('Your order shipped') ->body('Order #1042 is on its way.') ->navigate(route('orders.show', 1042)) ->action('Track it', 'track', route('orders.track', 1042)); }
iPhone and iPad
iOS and iPadOS 16.4+ only allow web push for a site added to the Home Screen, and the site needs a web app
manifest whose display is standalone. In Safari, the button explains this instead of failing. Opened
from the Home Screen, it works like everywhere else.
If your app has no manifest yet, turn on the kit's and add the head tags:
WEBPUSH_KIT_MANIFEST=true
<head> <x-webpush-kit::head /> </head>
Set the name, colours and icons under manifest in config/webpush-kit.php.
Testing a push
php artisan push:test # the user with the newest subscription php artisan push:test 42 # user 42 php artisan push:test --url=/orders # the page a click opens php artisan push:test --declarative # a declarative web push message
INFO Sending to 2 subscription(s) of User #1.
+-----------------------------------+--------+-------------------------------+
| Push service | Status | Result |
+-----------------------------------+--------+-------------------------------+
| fcm.googleapis.com | 201 | delivered |
| updates.push.services.mozilla.com | 410 | expired: subscription removed |
+-----------------------------------+--------+-------------------------------+
It checks the VAPID keys and VAPID_SUBJECT first, then sends through the channel. It exits non-zero when
nothing was delivered. When a push service answers 404 or 410, the channel deletes that subscription;
the table shows when that happened.
php artisan about shows the kit's version and whether the VAPID keys and subject are set.
Configuration
php artisan vendor:publish --tag=webpush-kit-config
| Key | Default | |
|---|---|---|
routes.prefix |
webpush |
The kit's routes: POST/DELETE /webpush/subscriptions, POST /webpush/subscriptions/rotate, GET /webpush/webpush-kit.js |
routes.middleware |
['web', 'auth'] |
Middleware for the subscription routes |
guard |
null |
The auth guard the button checks |
service_worker.path |
webpush-sw.js |
Where the service worker is served (at the site root, so it can control every page) |
service_worker.scope |
/ |
Its scope |
service_worker.register |
true |
false if your app registers its own service worker (see below) |
manifest.* |
off | The optional web app manifest |
notifiable |
users provider model | The model push:test sends to |
Texts are translatable: php artisan vendor:publish --tag=webpush-kit-translations. The button's colours
are CSS variables (--wpk-accent, --wpk-text, --wpk-surface, --wpk-border). Dark mode follows the
page (.dark or data-theme="dark" on a parent), or the system with theme="auto".
Already have a service worker?
A scope can only have one service worker. If your app (a PWA plugin, Workbox) already registers one at /,
set service_worker.register to false and load the kit's code inside yours:
self.WEBPUSH_KIT = { rotateUrl: '/webpush/subscriptions/rotate', vapidKey: 'your VAPID public key' }; importScripts('/webpush-sw.js');
To change the service worker itself, publish it to public/. The web server then serves that file
instead of the route:
php artisan vendor:publish --tag=webpush-kit-service-worker
How it works
- Subscribe: on Allow, the button calls
Notification.requestPermission()inside the click (browsers ignore it otherwise), thenpushManager.subscribe({ userVisibleOnly: true, applicationServerKey })with your VAPID key. It postsPushSubscription.toJSON()and the browser's content encoding to the subscriptions route, which callsupdatePushSubscription(). If another user had that endpoint, the channel moves it to the user who just subscribed. - Unsubscribe:
DELETEto the server (deletePushSubscription()), thensubscription.unsubscribe(). - Staying in sync: once per browser session, a page with the button saves the current subscription
again, so a row the server lost comes back. When the push service rotates a subscription, the
pushsubscriptionchangehandler subscribes again and replaces the old endpoint through the rotate route. The rotate route has no CSRF token (a service worker can't read one), so it only accepts same-origin requests from a signed-in user, and only removes endpoints that user owns. - Showing: every push is shown. Safari revokes permission for a push that shows nothing.
Testing
composer test scripts/ci-local.sh # PHP 8.3-8.5 x Laravel 12-13, plus the oldest supported channel; Pest, Pint, PHPStan
Need this built into your app, or something custom?
I build Laravel features end to end, including notifications across email, SMS and push, PWAs, and the packages around them. If you want web push set up in your app, or need something this package doesn't do, get in touch.
Support
- ๐ Bug or feature request: open an issue
- ๐ Security issue: please report privately via GitHub's security tab
Need this customised, or something built?
I'm available for custom development, package integration, and technical consulting.
More packages
Part of a family of open-source packages. See all of them.
| laravel-mysql-advisor | MySQL and MariaDB health checks with the fix SQL |
| kiln | Deploy-time OPcache control for Laravel |
| laravel-driver-js | Product tours and onboarding for Laravel |
| laravel-logpulse | Log health monitoring and alerting |
Changelog
See CHANGELOG.md.
License
MIT. See LICENSE.md. This package is independent and not affiliated with the laravel-notification-channels project.
