Server-side Web Push (VAPID + RFC 8291 aes128gcm) for PHP 7.2+.
Framework-agnostic. Zero third-party dependencies. Bundles the mds-webpush client JS.
- Requirements
- Installation
- Generating VAPID Keys
- Quick Start
- PHP API Reference
- Client-Side Setup (mds.webpush.js)
- Push Payload Fields
- Laravel Integration
- How It Works
- Browser Support
- Production Checklist
- License
| Requirement | Details |
|---|---|
| PHP | 7.2 or later |
| ext-openssl | Required (signing + AES-128-GCM encryption) |
| ext-curl | Recommended for HTTP transport (stream fallback is used otherwise) |
| ext-gmp | Recommended for ECDH on PHP 7.2 (bcmath is used as fallback) |
| ext-bcmath | Alternative ECDH fallback when gmp is unavailable |
On PHP 7.3+ with a standard openssl build, only
ext-opensslis needed.
composer require mojahed/webpushVAPID keys identify your server to the push service (FCM, Mozilla, etc.). Generate them once and store them securely.
npx web-push generate-vapid-keysOutput:
Public Key: BNcRdreALRFXTkOATJDP...
Private Key: UxLevFltuo37CIQR...
Store in your .env (Laravel) or equivalent:
VAPID_PUBLIC_KEY=BNcRdreALRFXTkOATJDP...
VAPID_PRIVATE_KEY=UxLevFltuo37CIQR...
VAPID_SUBJECT=mailto:you@yourdomain.comThe
subjectmust be amailto:address or anhttps:URL. It lets the push service contact you if there is a problem.
use Mojahed\WebPush;
use Mojahed\Subscription;
// 1. Create the sender
$push = new WebPush([
'vapid' => [
'subject' => 'mailto:you@example.com',
'publicKey' => $_ENV['VAPID_PUBLIC_KEY'],
'privateKey' => $_ENV['VAPID_PRIVATE_KEY'],
],
]);
// 2. Build a subscription from the JSON your browser posted
$subscription = Subscription::fromJson($rawJson);
// — or from an array:
$subscription = Subscription::fromArray([
'endpoint' => 'https://fcm.googleapis.com/fcm/send/...',
'keys' => ['p256dh' => '...', 'auth' => '...'],
]);
// 3. Send a notification
$result = $push->send($subscription, [
'title' => 'Hello',
'body' => 'Your order has shipped.',
'icon' => '/images/icon-192.png',
'url' => '/orders/42',
]);
if ($result['success']) {
// HTTP 201 — delivered to the push service
}
if ($result['expired']) {
// HTTP 404 or 410 — delete this subscription from your database
}new WebPush(array $config)| Config Key | Type | Default | Description |
|---|---|---|---|
vapid.subject |
string |
— | mailto: or https: sender identifier (required) |
vapid.publicKey |
string |
— | VAPID public key, base64url (required) |
vapid.privateKey |
string |
— | VAPID private key, base64url (required) |
ttl |
int |
2419200 |
Message Time-To-Live in seconds (max 28 days) |
urgency |
string |
null |
One of very-low, low, normal, high |
timeout |
int |
30 |
HTTP request timeout in seconds |
$result = $push->send(
$subscription, // Subscription, array, or JSON string
$payload, // array (JSON-encoded), string, or null
$options // optional per-message overrides: ttl, urgency, topic
);Per-message $options:
| Key | Type | Description |
|---|---|---|
ttl |
int |
Overrides the instance default TTL |
urgency |
string |
Overrides the instance default urgency |
topic |
string |
Collapse key — same topic replaces a queued notification (max 32 chars) |
$result = WebPush::quickSend(
['vapid' => [...]], // same config array as the constructor
$subscription,
$payload,
$options
);Accepts the standard browser subscription shape or a flat array.
// From raw JSON (e.g. request body from the browser)
$sub = Subscription::fromJson($request->getContent());
// From an associative array
$sub = Subscription::fromArray([
'endpoint' => 'https://...',
'keys' => ['p256dh' => '...', 'auth' => '...'],
]);
// Flat form (keys at top level, not nested)
$sub = Subscription::fromArray([
'endpoint' => 'https://...',
'p256dh' => '...',
'auth' => '...',
]);
// Construct directly with raw bytes or base64url strings
$sub = new Subscription($endpoint, $p256dh, $auth);Keys may be supplied as base64url strings (as the browser provides) or as raw bytes — both are accepted and normalized to raw bytes internally for both p256dh and auth.
send() returns an associative array:
| Key | Type | Description |
|---|---|---|
success |
bool |
true when the push service returned HTTP 201 |
status |
int |
Raw HTTP status code |
body |
string |
Response body from the push service |
expired |
bool |
true on 404 or 410 — subscription should be deleted |
rateLimited |
bool |
true on 429 — back off and retry |
retryAfter |
int|null |
Seconds to wait before retrying (from Retry-After header) |
endpoint |
string |
The subscription endpoint that was targeted |
$result = $push->send($subscription, $payload);
if ($result['expired']) {
// Remove from DB — this subscription will never work again
$subscription->delete();
}
if ($result['rateLimited']) {
$wait = $result['retryAfter'] ?? 60;
// Re-queue the job to run after $wait seconds
}The webpush-js/ directory contains the browser-side library. No build step, no dependencies — drop it in and use it.
| File | Purpose | Where to place |
|---|---|---|
mds.webpush.js |
Main library — load in your page | /public/js/ or CDN |
mds.webpush.sw.js |
Service Worker | /public/ (must be at domain root) |
Important:
mds.webpush.sw.jsmust be served from your domain root (/mds.webpush.sw.js) so its scope covers the entire site. Never place it in a subdirectory.
Call MdsWebpush.register() from a user gesture (button click). Browsers block permission prompts that fire automatically on page load.
<meta name="csrf-token" content="{{ csrf_token() }}">
<script src="/js/mds.webpush.js"></script>
<button id="notify-btn">Enable Notifications</button>
<script>
document.getElementById('notify-btn').addEventListener('click', function () {
MdsWebpush.register({
vapidPublicKey: 'YOUR_VAPID_PUBLIC_KEY',
subscribeUrl: '/mds-push/subscribe',
csrfToken: document.querySelector('meta[name="csrf-token"]').content,
});
});
</script>What happens internally:
- Validates config and checks HTTPS + browser support.
- If permission is already
granted, registers the Service Worker silently. - If permission is
default, checks whether enough time has passed since the user last dismissed the popup (controlled byretryAfter). - Shows a customizable trust popup. If the user clicks Allow, triggers the browser permission prompt.
- On permission granted, subscribes via
PushManagerand POSTs the subscription tosubscribeUrl— but only if the subscription is new or has changed, to avoid hitting your server on every page load.
MdsWebpush.unsubscribe({
vapidPublicKey: 'YOUR_VAPID_PUBLIC_KEY',
unsubscribeUrl: '/mds-push/unsubscribe', // optional — notifies your server
csrfToken: document.querySelector('meta[name="csrf-token"]').content,
});
// Returns a Promise<boolean>This calls subscription.unsubscribe() in the browser, optionally POSTs the old endpoint to unsubscribeUrl, and clears all local state.
| Key | Type | Description |
|---|---|---|
vapidPublicKey |
string |
Your VAPID public key (base64url) |
| Key | Type | Default | Description |
|---|---|---|---|
swUrl |
string |
/mds.webpush.sw.js |
Path to the Service Worker file |
subscribeUrl |
string |
/mds-push/subscribe |
Server endpoint that saves the subscription |
unsubscribeUrl |
string|null |
null |
Server endpoint called on unsubscribe() — optional |
csrfToken |
string|null |
null |
CSRF token (required for Laravel and similar frameworks) |
| Key | Type | Default |
|---|---|---|
popup.title |
string |
'Stay Informed' |
popup.message |
string |
'Enable notifications to receive important updates instantly.' |
popup.okText |
string |
'Allow Notifications' |
popup.cancelText |
string |
'Not Now' |
| Key | Type | Default | Description |
|---|---|---|---|
retryAfter |
string |
'daily' |
When to re-show the popup after a dismiss: 'daily', 'weekly', 'never' |
onError |
function |
console.error |
Called with (code, message) on any error |
Available as MdsWebpush.ERROR_CODES.*:
| Code | When |
|---|---|
HTTPS_REQUIRED |
Page is not served over HTTPS (localhost is exempt) |
SW_NOT_SUPPORTED |
Browser does not support Service Workers |
PUSH_NOT_SUPPORTED |
Browser does not support the Push API |
PERMISSION_DENIED |
User denied the notification permission |
SW_REGISTRATION_FAILED |
Service Worker failed to register |
SUBSCRIBE_FAILED |
PushManager.subscribe() failed |
UNSUBSCRIBE_FAILED |
unsubscribe() could not complete |
SERVER_FAILED |
Server returned an error when saving the subscription |
INVALID_CONFIG |
vapidPublicKey is missing or invalid |
MdsWebpush.register({
vapidPublicKey: '...',
onError: function (code, message) {
if (code === MdsWebpush.ERROR_CODES.PERMISSION_DENIED) {
// Show UI explaining how to re-enable from browser settings
}
},
});Your server sends this JSON as the notification payload. All fields are optional.
{
"title": "New Message",
"body": "You have a new message from Ahmed.",
"icon": "/images/icon-192.png",
"badge": "/images/badge-72.png",
"image": "/images/preview.jpg",
"url": "/messages",
"tag": "message-thread-1",
"renotify": true
}| Field | Type | Description |
|---|---|---|
title |
string |
Notification title. Defaults to 'Notification' if omitted |
body |
string |
Notification body text |
icon |
string |
Small icon URL (192×192 recommended) |
badge |
string |
Monochrome badge icon for Android status bar (72×72) |
image |
string |
Large image shown inside the notification |
url |
string |
URL opened when the user clicks the notification. Defaults to / |
tag |
string |
Collapse key — a new notification with the same tag replaces the previous one |
renotify |
bool |
Re-alert the user when a same-tagged notification replaces a previous one |
Payload size limit: ~4 079 bytes (RFC 8291 single-record limit).
Schema::create('push_subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->text('endpoint');
$table->string('p256dh');
$table->string('auth');
$table->timestamps();
$table->unique(['user_id', 'endpoint'], 'unique_user_endpoint');
});// routes/web.php
Route::middleware('auth')->group(function () {
Route::post('/mds-push/subscribe', [PushController::class, 'subscribe']);
Route::post('/mds-push/unsubscribe', [PushController::class, 'unsubscribe']);
});// app/Http/Controllers/PushController.php
use App\Models\PushSubscription;
use Illuminate\Http\Request;
class PushController extends Controller
{
public function subscribe(Request $request)
{
PushSubscription::updateOrCreate(
[
'user_id' => auth()->id(),
'endpoint' => $request->input('endpoint'),
],
[
'p256dh' => $request->input('keys.p256dh'),
'auth' => $request->input('keys.auth'),
]
);
return response()->json(['success' => true]);
}
public function unsubscribe(Request $request)
{
PushSubscription::where('user_id', auth()->id())
->where('endpoint', $request->input('endpoint'))
->delete();
return response()->json(['success' => true]);
}
}use Mojahed\WebPush;
use App\Models\PushSubscription;
$push = new WebPush([
'vapid' => [
'subject' => config('services.vapid.subject'),
'publicKey' => config('services.vapid.public_key'),
'privateKey' => config('services.vapid.private_key'),
],
]);
$subscriptions = PushSubscription::where('user_id', $user->id)->get();
foreach ($subscriptions as $row) {
$result = $push->send(
[
'endpoint' => $row->endpoint,
'keys' => ['p256dh' => $row->p256dh, 'auth' => $row->auth],
],
[
'title' => 'Order Shipped',
'body' => 'Your order #' . $order->id . ' is on its way.',
'icon' => '/images/icon-192.png',
'url' => '/orders/' . $order->id,
]
);
if ($result['expired']) {
$row->delete(); // push service says this endpoint is gone
}
}Add these to config/services.php:
'vapid' => [
'subject' => env('VAPID_SUBJECT'),
'public_key' => env('VAPID_PUBLIC_KEY'),
'private_key' => env('VAPID_PRIVATE_KEY'),
],Always delete subscriptions that come back expired — HTTP 404 and 410 both mean the push service has permanently invalidated the endpoint.
if ($result['expired']) {
PushSubscription::where('endpoint', $result['endpoint'])->delete();
}User clicks button
→ MdsWebpush.register(config)
→ validate vapidPublicKey
→ HTTPS + browser support checks
→ If permission granted → register SW silently → subscribe
→ If permission denied → fire onError(PERMISSION_DENIED)
→ If permission default:
→ Check localStorage cancel date vs retryAfter
→ Show trust popup
→ "Not Now" → store cancel date → stop
→ "Allow" → browser permission prompt
→ Denied → store cancel date → fire onError
→ Granted → register SW → subscribe
→ POST to server only if subscription is new/changed
Server sends push
→ POST encrypted payload to push service endpoint (Google / Mozilla)
→ Push service delivers to browser
→ Browser wakes Service Worker
→ SW receives 'push' event
→ SW calls showNotification()
→ User sees notification
→ User clicks notification
→ SW focuses existing tab or opens new one
Push subscriptions are not permanent — the push service can rotate or invalidate them at any time. The Service Worker listens for pushsubscriptionchange, creates a fresh subscription, and POSTs the new endpoint to subscribeUrl. This works even when no browser tab is open. The VAPID key and server URL needed for this are delivered from the page to the SW via postMessage and persisted in IndexedDB.
For each message the server:
- Generates an ephemeral P-256 key pair.
- Derives an ECDH shared secret with the subscriber's
p256dhkey. - Applies RFC 8291 §3.4 two-step HKDF to produce the AES-128 content-encryption key and nonce:
PRK_key = HMAC-SHA-256(auth_secret, ecdh_secret)IKM = HMAC-SHA-256(PRK_key, "WebPush: info" || ua_public || as_public || 0x01)PRK = HMAC-SHA-256(salt, IKM)CEK = HMAC-SHA-256(PRK, "Content-Encoding: aes128gcm" || 0x01)[0..15]NONCE = HMAC-SHA-256(PRK, "Content-Encoding: nonce" || 0x01)[0..11]
- Encrypts the payload with AES-128-GCM.
- Prepends the RFC 8188 header (
salt || record_size || ephemeral_public_key).
The VAPID JWT (Authorization header) is signed with ES256 and proves the server's identity to the push service.
| Browser | Supported |
|---|---|
| Chrome 50+ | ✅ |
| Firefox 44+ | ✅ |
| Edge 17+ | ✅ |
| Safari 16+ (macOS 13+ / iOS 16.4+) | ✅ |
| Opera 42+ | ✅ |
| Internet Explorer | ❌ |
Safari on iOS requires the site to be added to the Home Screen for push to work on iOS 16.4.
- VAPID keys generated and stored in
.env(never commit them) -
mds.webpush.sw.jsplaced at domain root (/public/) -
subscribeUrlroute is protected by authentication middleware - Subscribe endpoint is idempotent (same endpoint can be POSTed multiple times safely)
- Expired subscriptions (404 / 410) are deleted from the database
- HTTPS is enabled on the production domain
- Notification icon is at least 192×192 PNG
-
unsubscribeUrlendpoint wired up for clean opt-out (optional but recommended)
MIT
Made With ❤️ Love By: md-mojahed.github.io