minishlink/web-push-bundle
Symfony bundle integrating the minishlink/web-push PHP library so you can send Web Push notifications to browsers. Configure VAPID keys, autowire the WebPush service, and deliver notifications to subscribed users from your Symfony controllers or services.
1. Install the bundle (and the library version it pins).
composer require minishlink/web-push-bundle
There is no Flex recipe for every Symfony version, so expect to create the config file yourself. v7.0.0 of the bundle is a major bump whose migration note is a redirect to web-push-php v11.0.0 — i.e. the breaking change is the library underneath, not the bundle. Check what you actually got:
composer show minishlink/web-push
The WebPush library major version dictates the API you'll call and, since v9+, also dictates your HTTP stack (see step 3). The bundle README is short; vendor/minishlink/web-push-bundle/DependencyInjection/Configuration.php is the authoritative list of what you can configure — and it's the first place to look after this release, because the new http_client keys only exist there and in the README section "Customizing the HTTP client".
2. Generate VAPID keys once, per environment.
php -r "require 'vendor/autoload.php'; print_r(Minishlink\WebPush\VAPID::createVapidKeys());"
Put them in .env.local / Symfony Secrets, never in git. The keys are permanent for the deployment: rotating them silently invalidates every existing browser subscription.
3. Configure the bundle.
# config/packages/minishlink_web_push.yaml
minishlink_web_push:
VAPID:
subject: '%env(VAPID_SUBJECT)%' # https://your-app.tld OR mailto:[email protected]
publicKey: '%env(VAPID_PUBLIC_KEY)%'
privateKey: '%env(VAPID_PRIVATE_KEY)%'
# http_client: 'my_psr18_client' # see below — key name per Configuration.php
You must have a PSR-18 client installed and bound. Since web-push-php v9 the library no longer ships Guzzle; it takes a PSR-18 ClientInterface plus PSR-17 RequestFactoryInterface. v7.0.0's headline feature is that the bundle now lets you point it at a specific client instead of guessing. If your app has no PSR-18 client (or an ambiguous one) in the container, you get a container/compile-time error before any push is sent — that's the new first-day failure mode.
4. Use the autowired service. The bundle registers Minishlink\WebPush\WebPush as a public/autowirable service — type-hint it and you're done:
use Minishlink\WebPush\WebPush;
use Minishlink\WebPush\Subscription;
final class PushSender
{
public function __construct(private WebPush $webPush) {}
}
Not on Symfony? The bundle is Symfony-only; in Laravel you skip it entirely and bind the library yourself in a service provider (WebPush built with your PSR-18 client + VAPID array). Everything from step 2 onward applies unchanged.
First use case to build: a POST /push/subscribe endpoint that receives the browser's PushSubscription JSON and persists it, plus a console command (app:push:test <endpoint>) that sends one notification and dumps the report. That command is your entire debugging workflow for the rest of the project — build it before anything else, and run it before and after any bundle/library upgrade, because it's the only cheap proof that the VAPID config, the HTTP client and the payload path all still work.
Browser side — you own this, the bundle doesn't ship JS.
// subscribe: needs the VAPID public key as a Uint8Array
const reg = await navigator.serviceWorker.register('/sw.js');
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
await fetch('/push/subscribe', { method: 'POST', body: JSON.stringify(sub) });
// sw.js — payload arrives as JSON you serialized server-side
self.addEventListener('push', (e) => {
const d = e.data ? e.data.json() : {};
e.waitUntil(self.registration.showNotification(d.title ?? 'Update', {
body: d.body, data: { url: d.url },
}));
});
self.addEventListener('notificationclick', (e) => {
e.notification.close();
e.waitUntil(clients.openWindow(e.notification.data.url ?? '/'));
});
Storage. A Subscription entity: endpoint (long, unique), p256dh, auth, contentEncoding, user_id, created_at, last_success_at. The library's Subscription::create() takes exactly that shape:
$sub = Subscription::create([
'endpoint' => $row->getEndpoint(),
'publicKey' => $row->getP256dh(),
'authToken' => $row->getAuth(),
'contentEncoding' => $row->getContentEncoding() ?? 'aes128gcm',
]);
Sending — queue then flush, never one-at-a-time in a loop. flush() is where the parallelism lives (concurrent HTTP requests), and it's the reason to use this library at all:
foreach ($subscriptions as $row) {
$this->webPush->queueNotification(
$this->toSubscription($row),
json_encode(['title' => 'New message', 'body' => $preview, 'url' => $url]),
['TTL' => 3600, 'urgency' => 'high', 'topic' => 'thread-'.$threadId]
);
}
foreach ($this->webPush->flush() as $report) {
if ($report->isSuccess()) {
$this->repo->markSuccess($report->getEndpoint());
continue;
}
if ($report->isSubscriptionExpired()) { // 404 / 410
$this->repo->removeByEndpoint($report->getEndpoint());
continue;
}
$this->logger->warning('push failed', [
'endpoint' => $report->getEndpoint(),
'reason' => $report->getReason(),
'response' => (string) $report->getResponse()?->getBody(),
]);
}
Configure the HTTP client, not the sender, for timeouts. Since the PSR-18 refactor the transport concerns (connect/read timeout, retries, proxies, Retry-After backoff, tracing) live in the HTTP client you inject, not in WebPush's constructor. That's the real payoff of v7.0.0: give the bundle a dedicated, named PSR-18 client for push traffic, with its own short timeout, its own retry policy and its own log channel. Sharing the app's global client means a slow push service can starve unrelated HTTP calls (and vice-versa).
Move the send off the request cycle. Web push is outbound network I/O — never do it inside the HTTP request that triggered it. Dispatch a Messenger message and let a worker handle it; the autowired WebPush service is a singleton and reuses VAPID headers/TLS connections across messages in the same worker, which matters at volume. Tune worker concurrency rather than maxConcurrentRequests if the client already pools connections.
Fan-out pattern for "notify all users in a channel":
// command: enqueue one message per chunk of 500 subscriptions
foreach (array_chunk($ids, 500) as $chunk) {
$bus->dispatch(new SendPushBatch($chunk, $payload));
}
// handler: queue all 500, flush once, prune expired from the reports
Chunk size is a memory/latency trade-off: queueNotification() keeps every encrypted payload in memory until flush().
Expose the public key via a tiny endpoint (/push/vapid-public-key) reading %env(VAPID_PUBLIC_KEY)% — the frontend must use the same keypair the backend signs with, and hardcoding it in JS guarantees drift.
Wire format tips: TTL: 0 means "deliver now or drop" (good for presence/typing indicators), TTL: 86400 for anything durable. topic (max 32 chars, base64url chars only) collapses queued messages on the push service — perfect for "3 new messages" style notifications, disastrous if you need every one delivered.
Laravel equivalent (skip the bundle): in a PushServiceProvider, bind a singleton WebPush built from your configured PSR-18 client and the VAPID array, then inject that interface-free singleton into a queued SendPushBatch job. Same queue-then-flush loop, same pruning from isSubscriptionExpired().
Upgrading 6.3.x → 7.0.0 is a library migration, not a bundle tweak. The release note only points at web-push-php's v11 changelog, so treat these as the actual checklist: (1) bump on a branch, (2) composer show minishlink/web-push and read the v11 upgrade notes, (3) make sure a PSR-18 client (and PSR-17 request factory) is available, (4) run your app:push:test command against a real device before merging, (5) diff Configuration.php for renamed/new keys (the http_client option is new in this release), (6) confirm your PHP version still satisfies the library. Because queueNotification() / flush() / Report are the stable surface but constructor and configuration shape have churned across majors, keep the library behind your own interface (see testing below) so the next major is a one-file edit.
The subject is validated by push services. Must be a https:// URL or mailto: — a bare domain gets you 401/403 from FCM/Mozilla and a very confusing isSuccess() === false. This is the #1 first-day bug.
Payload cap is ~4 KB encrypted (4078 bytes of plaintext). Long HTML snippets will 413. Truncate the body, send a URL to open instead.
Doctrine column sizing. Endpoints from FCM routinely exceed 255 chars and are UTF-8. Use TEXT (or VARCHAR(500) with utf8mb4 and a hash column for the unique index) — a plain VARCHAR(255) UNIQUE will truncate or blow the MySQL index limit.
contentEncoding matters. Old rows stored as aesgcm still decrypt; new browsers negotiate aes128gcm. Persist what the browser gave you rather than defaulting blindly, and don't "migrate" the column when you upgrade the library. Re-check the library's default for new subscriptions after the v11 bump rather than assuming it's unchanged.
410/404 is not an error, it's garbage collection. Every flush should prune. If you don't, your table grows with dead subscriptions and your flush times creep up forever.
Testing: don't mock WebPush directly in functional tests. Wrap it behind your own PushNotifier interface (one method: send(Subscription, array $payload)), bind the real implementation in services.yaml, and swap a recording fake in config/services_test.yaml. You get assertions on payloads without touching the network — and the interface absorbs library major-version churn, which is exactly what v7.0.0 just demonstrated.
Consider whether you need the bundle at all. It's a small, low-traffic project that does one thing: register WebPush from config. That said, v7.0.0 makes it slightly less trivial to replace, because it also wires the HTTP client and PSR-17 factory. If the bundle lags your Symfony version, three-ish lines in services.yaml still replace it:
Minishlink\WebPush\WebPush:
factory: ['Minishlink\WebPush\WebPush', 'create'] # or a small factory service
arguments: [{ VAPID: { subject: '%env(VAPID_SUBJECT)%', publicKey: '%env(VAPID_PUBLIC_KEY)%', privateKey: '%env(VAPID_PRIVATE_KEY)%' } }, '@push.http_client']
Keeping your own factory service is also the cleanest place to add logging/retry logic and to pin the library version you've actually tested.
Extension points are in the library, not the bundle. setAutomaticPadding(), setMaxConcurrentRequests() (version-dependent — check v11's surface), the notification options array, and now the injected HTTP client. Because WebPush is a shared service, any set*() call you make in one request/worker message leaks into the next — configure it once (compiler pass, decorator, or factory), never per-send.
Debugging checklist when nothing arrives: (0) does the container boot / is a PSR-18 client bound? (1) does app:push:test succeed? (2) isSuccess() but no notification → service worker not registered / push handler throws / notification permission not granted; (3) 401 → VAPID key mismatch between frontend applicationServerKey and backend private key; (4) 403 → bad subject; (5) 413 → payload too big; (6) 429 → back off, respect Retry-After (put this in the dedicated HTTP client's retry policy now that you can).
Requires PHP's OpenSSL with EC support (and ext-json, ext-mbstring). On minimal Docker images this is the silent failure — VAPID::createVapidKeys() returns garbage or throws before you get anywhere near the bundle.
How can I help you explore Laravel packages today?