minishlink/web-push
WebPush is a PHP library implementing the Web Push protocol (RFC 8030) for sending push notifications to browser endpoints. It handles Subscription objects, payload encryption, VAPID authentication, and delivery to service workers. Requires PHP 8.2+ with curl, openssl, and mbstring.
The package is a protocol implementation, not a Laravel package — there is no service provider, no config file, no facade. Treat it as a library you wrap yourself. composer require minishlink/web-push is the entire install step.
Where to look first:
Minishlink\WebPush\WebPush — the client you instantiate and flush.Minishlink\WebPush\Subscription — the value object wrapping a browser PushSubscription JSON blob.Minishlink\WebPush\MessageSentReport — what you get back per endpoint, including isSubscriptionExpired().Minishlink\WebPush\VAPID::createVapidKeys() — run this once, offline, to generate your keypair.Minimum viable flow in a Laravel app:
composer require minishlink/web-push
php artisan vendor:publish --tag=... # nothing to publish, just showing this isn't a thing
Generate keys once and put them in .env (never regenerate — changing them invalidates every existing subscription):
php -r "var_dump(Minishlink\WebPush\VAPID::createVapidKeys());"
WEBPUSH_VAPID_SUBJECT=mailto:[email protected]
WEBPUSH_VAPID_PUBLIC_KEY=BNc...88chars
WEBPUSH_VAPID_PRIVATE_KEY=xY...44chars
Then a config/webpush.php:
return [
'subject' => env('WEBPUSH_VAPID_SUBJECT'),
'public_key' => env('WEBPUSH_VAPID_PUBLIC_KEY'),
'private_key' => env('WEBPUSH_VAPID_PRIVATE_KEY'),
'defaults' => [
'TTL' => 60 * 60 * 24, // 1 day
'urgency' => 'normal',
'batchSize' => 500,
],
];
Bind a singleton factory in a service provider so you don't rebuild the client per notification:
$this->app->bind(WebPush::class, function () {
return new WebPush([
'VAPID' => [
'subject' => config('webpush.subject'),
'publicKey' => config('webpush.public_key'),
'privateKey' => config('webpush.private_key'),
],
], config('webpush.defaults'));
});
First real use case: store the JSON from PushSubscription.toJSON() in a push_subscriptions table (user_id, endpoint, p256dh, auth, content_encoding), then send a test notification to one row via sendOneNotification(). If a report comes back successful, you are done with the hard part.
Model + notification job. The idiomatic Laravel shape is a subscription model and a queued job, not a controller action doing the sending.
class PushSubscription extends Model
{
protected $casts = ['content_encoding' => 'string'];
public function toWebPushSubscription(): Subscription
{
return Subscription::create([
'endpoint' => $this->endpoint,
'keys' => ['p256dh' => $this->p256dh, 'auth' => $this->auth],
'contentEncoding' => $this->content_encoding ?? Subscription::defaultContentEncoding,
]);
}
}
Job:
class SendPushNotification implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public Collection $subscriptionIds,
public array $payload,
public array $options = [],
) {}
public function handle(WebPush $webPush): void
{
$subs = PushSubscription::whereIn('id', $this->subscriptionIds)->get();
foreach ($subs as $sub) {
$webPush->queueNotification(
$sub->toWebPushSubscription(),
json_encode($this->payload),
$this->options,
);
}
foreach ($webPush->flush() as $report) {
$endpoint = $report->getEndpoint();
if ($report->isSubscriptionExpired()) {
PushSubscription::where('endpoint', $endpoint)->delete();
} elseif (! $report->isSuccess()) {
Log::warning('push.failed', [
'endpoint' => $endpoint,
'reason' => $report->getReason(),
'status' => $report->getResponse()?->getStatusCode(),
]);
}
}
}
}
Fan-out chunking. The batchSize default of 1000 is Guzzle concurrency, not Laravel chunking. For tens of thousands of subscribers, do both: chunk your query (->chunkById(1000)) and dispatch one job per chunk. That gives you retries and memory bounds at the Laravel level, while batchSize bounds it at the Guzzle level.
Multiple sends to the same push service. $webPush->setReuseVAPIDHeaders(true) caches the JWT per (protocol, host) for the flush session. Worth flipping on for any broadcast — otherwise you re-sign a JWT per notification.
Single-notification paths (a "notify this user now" action) should call sendOneNotification() and return a fresh WebPush instance or refresh() between calls — the client accumulates state and is not designed to live across requests.
Topic as a debounce. For chat messages or feed updates where only the latest matters, set 'topic' => 'conversation-'.$id. The push service itself drops older messages with the same topic. Cheaper than implementing your own coalescing.
TTL by intent. TTL => 0 for "you have a new message right now" (drop if user offline), long TTL only for genuinely important events (a security alert, an expiring booking). The 4-week default is almost always wrong.
Testing. There is no HTTP fake for this library. Bind a fake WebPush in the container, or use Http::fake() — the library uses Guzzle directly, so Http::fake() will not intercept it. The reliable seam is your own thin wrapper class around WebPush with an interface, faked in tests.
The \Generator from flush() is single-pass. Reading it twice throws Cannot rewind a generator that was already run. If you want to log and then process, do it in one loop, or iterator_to_array() it — but that materializes every report, defeating the batching.
VAPID keys must be the raw Base64-URL pair, not PEM. Passing a PEM file path to the publicKey/privateKey array keys silently fails or throws deep in OpenSSL. Use pemFile/pem keys if you have a PEM. The subject is mandatory in practice — Safari and others reject without it, and it must be mailto: or an https URL. An email address without mailto: is a common self-inflicted 401.
Do not regenerate VAPID keys. No fallback, no rotation path — every subscription is bound to the public key. Back them up, treat them like APP_KEY, and don't commit them.
isSubscriptionExpired() is your cleanup signal. 404 and 410 responses mean the endpoint is dead. Nothing else does. Without this check your table fills with garbage and your send jobs get slower every month. Sweep on every flush.
Payload must be a string. Pass json_encode([...]), not an array. Sending an array produces an encryption error that doesn't obviously point at the type. If using declarative push messages, also set 'contentType' => 'application/json' — default is application/octet-stream.
The 3052-byte practical ceiling. The theoretical max is 4078, but Mozilla's archived compatibility note says stay under 3052. Encrypt-and-check: if a payload exceeds it, send a small payload with an ID and let the service worker fetch details.
Padding costs CPU on both ends. Secure by default, but for high-volume non-sensitive notifications (a "new item available" ping), the padding is pure overhead. There's no documented switch to disable it per-notification; accept it or batch harder.
Safari needs urgency. Some users report Safari errors when urgency is omitted. Setting 'urgency' => 'normal' in defaults costs nothing and eliminates a class of environment-specific failures. There is no reason not to.
batchSize is memory, not throughput. If your queue worker has a 128M limit, drop it to 200–500. Set it low in the job, not just the constructor, via $webPush->flush($batchSize).
Queue the job, never send in-request. A broadcast flush blocks for as long as the slowest endpoint takes. Always ShouldQueue, and give it $tries = 1 — retrying a push after partial success re-notifies users. Handle failures via the report loop, not queue retries.
Service worker is not your problem, but it will look like it is. A successful MessageSentReport means the push service accepted the message, not that the browser displayed anything. If notifications silently don't appear, check the service worker's push listener and permission state before suspecting the PHP side.
Frontend subscription must use the same public key. applicationServerKey on pushManager.subscribe() must be the VAPID public key encoded as Uint8Array. A mismatch produces 401s that look like key corruption.
No Laravel-native observability. Wrap the report loop in a structured log with endpoint hash (not the full endpoint — it's a capability URL and logging it is a mild secret leak), status code, and reason. That's your only debugging surface.
How can I help you explore Laravel packages today?