Web Push Guide¶
A notification on someone's phone when your site is not open, their browser is closed, and your server holds no connection to them at all.
class NewSignIn implements \Pramnos\Notification\NotificationInterface
{
public function __construct(private string $city) {}
public function via(mixed $notifiable): array
{
return ['database', 'push'];
}
public function toPush(mixed $notifiable): array
{
return [
'title' => 'Νέα σύνδεση',
'body' => 'Ο λογαριασμός σας χρησιμοποιήθηκε από ' . $this->city . '.',
'url' => sURL . 'account/sessions',
'tag' => 'signin',
];
}
}
$user->notify(new NewSignIn('Θεσσαλονίκη'));
That is the whole application-level API — it is the notification model, of which push is one channel; the Notifications guide covers the dispatcher, the other channels and how to choose between them. The rest of this page is the setup push needs once, and the handful of facts that decide whether it works in a year.
How it actually works¶
Your server holds no connection to anybody. This is the thing most worth understanding, because it is the opposite of what "push" suggests.
The browser keeps one connection open to its vendor's push service — FCM for Chrome, Mozilla autopush for Firefox, APNs for Safari — shared across every site that browser has ever subscribed to. When you send a notification, the framework makes one ordinary HTTPS request to an address that service handed out, and the service delivers it over the connection it already has. Then everything closes.
your server ──HTTPS POST──▶ push service ──existing connection──▶ browser
(one request per (Google / (wakes the service
subscription, then Mozilla / worker, shows the
nothing) Apple) notification)
Consequences worth planning around:
| Web push | SSE / WebSockets | |
|---|---|---|
| Cost while idle | Zero — no connection, no process | One held connection per viewer |
| Works with the tab closed | Yes | No |
| Works with the browser closed | Yes (the OS holds it) | No |
| Latency | ~1 second | Milliseconds |
| Payload | ~4KB, encrypted | Unlimited |
| Needs HTTPS | Yes, always | In practice |
They are not competitors. Use realtime for "this page should update now" and
push for "tell this person even though they are not here". Most applications
want both, and a notification that names both channels in via() gets both.
Server load¶
One HTTPS request per subscription, sent with curl_multi so they go out in
parallel. The cost is dominated by TLS handshakes, not encryption — which is
why the channel builds one batch and flushes it once rather than sending in a
loop.
Rough shape on ordinary hardware: a few thousand subscriptions is seconds, not minutes. Beyond that, send from the queue rather than from a web request:
Idle cost is genuinely zero. A million subscribers who are not being notified cost you a million rows and nothing else.
Setting it up¶
One command¶
./yourapp push:setup # what it would do
./yourapp push:setup --apply
./yourapp push:setup --apply --no-install # no network: report the composer line instead
Push has five parts and four of them are invisible when they are missing: a table, a key pair,
an encryption library, a service worker that listens, and a page that asks. Miss any one and the
other four keep working perfectly — no error, no log line, and no notification. Two more are
checked beside them: the icon a notification shows, and whether the site answers at push/key.
ok Migration
ok VAPID key pair
todo Encryption library — minishlink/web-push is not installed, so nothing can be encrypted
ok Service worker (www/serviceWorker.js)
ok Browser script
ok Icon (www/assets/img/icon-192.png)
todo Routing (https://example.com/push/key) — it answered 301, not JSON, so the browser can never fetch the key — route `push` to the framework's Push controller
Each step names the file or address it looked at, so "no handler" can be checked against the file somebody thinks it is.
- Service worker.
'push' => ['service_worker' => 'serviceWorker.js']in app.php names the worker — under the web root, or absolute — and is looked at first; otherwisesw.js,service-worker.jsandserviceWorker.jsinwww/,public/and the project root. With none,--applywrites the shipped worker there with its placeholders filled asinitfills them, and says that nothing registers it yet: a worker nobody registers receives nothing. - Icon.
'push' => ['icon' => …, 'badge' => …], paths under the web root or addresses, are what every notification carries unless it gives its own; without them the worker usesassets/img/icon-192.pngandassets/img/badge-72.pngunder its own scope. The step checks the icon is there. It cannot draw one, so--applysays where to put it and carries on. - Routing. A GET of
push/keyunder the configured site address must answer as the push controller does — 200 with the key, or 503 before there is one — as JSON. An application whose own routing sends the address elsewhere (a controller allow-list answering 301) has every other step green and no subscriptions. Nothing here can change that routing, so the step stops the run and says why. Without a site address (APP_URL) it is not checked, and says so.
It says what each absence costs rather than naming a file, does only what is missing, and is safe to run again — it is the thing somebody runs when they are not sure. A step that fails stops the run: carrying on would report four done and leave the one that mattered.
On a new project, answer yes to Enable web push notifications? and init does all of this
— including turning the service worker on, because a notification is delivered to one and
offering the two as independent choices offers a combination that cannot work.
The rest of this section is what those steps do, for an installation that wants to do them by hand or understand what happened.
1. The key pair, once¶
Writes app/keys/vapid_private.key and app/keys/vapid_public.key.
Rotating the key invalidates every existing subscription. There is no registration and no shared secret with the push provider — the key pair is your application's identity. A browser that subscribed with the old public key cannot be pushed to with the new private one, and nobody finds out until somebody notices that notifications stopped. Generate once, back the pair up, and treat losing it as losing every subscriber.
The private key must never reach the repository. pramnos init writes a
.gitignore that keeps app/keys/*.key out and re-admits the two public ones;
if your project predates that, check yours before the first commit.
The command warns if no contact subject is configured. RFC 8292 wants one so a
push service has somewhere to write when something is wrong with what you send —
before it starts refusing. Set it explicitly in app/app.php:
Failing that it falls back to the admin_mail setting as a mailto:, and then to
site_url. It is never invented: an unconfigured installation gets an empty
subject and a warning, because a guessed address nobody reads is worse than none
— the provider believes it warned you.
Push can be switched off for an installation — development, tests — where a subscribed browser would otherwise be woken by every run:
'push' => ['send' => false],
// or an application's own setting, asked each time:
'push' => ['send' => static fn (): bool => Settings::getSetting('send_push') === 'yes'],
Nothing is sent, the push log records each one as switched off, and the scheduled tasks' notifications leave push out of their channels. On by default.
2. Run the migration¶
Creates pushsubscriptions — one row per browser, unique on
(userid, endpoint_hash).
3. A service worker¶
Push is delivered to a service worker, so a site without one cannot receive
notifications at all. If you scaffolded with --service-worker=y the handlers
are already there; see the Service Worker Guide
to add one to an existing project. The stub handles push,
notificationclick and pushsubscriptionchange — the last of which is the one
people forget, and it is covered below.
4. The page that asks¶
init writes www/assets/js/push.js beside the worker, and the scaffolded themes carry two
things that use it: a Turn on notifications control on the privacy screen, and a soft
prompt on every signed-in page.
Both halves ship, and that is the point. The worker can receive a notification; nothing in
it can ask to show one — Notification.requestPermission() and PushManager.subscribe() exist
only in a page. An installation with the worker, the key pair, the table and the endpoints and
no page that asks has no subscriptions, for ever, with nothing anywhere saying why.
The soft prompt, and why it is not the real one¶
<div data-push-invite hidden>
… <button data-push-subscribe>Turn on</button> <button data-push-later>Not now</button>
</div>
A settings screen reaches only the people who go looking, and the people who would most want to know their account was signed in from a new device are not the people browsing their privacy preferences. So there is an invitation on every signed-in page.
It is not the browser's permission dialogue. The button inside it opens that, from a click. A real prompt on page load is denied by most people and — in Chrome — suppressed outright for visitors who habitually deny them, so the one chance an application gets is spent before anybody has decided anything. A denial is close to permanent: the browser will not ask again, and the person has to find the site settings themselves.
The invitation appears only when it can lead somewhere: push is supported, permission has never been asked for, this browser is not already subscribed, and there is no "not now" from the last thirty days. Answering it — either way — hides it. A soft prompt that returns on the next page load is a nag, and a nag is answered with the browser's block button, which is the one answer this feature cannot recover from.
5. The subscribing page, by hand¶
// Addresses of this site: put the folder in front where the site lives in one ('/myapp/' + path).
const sitePath = (path) => '/' + path;
async function subscribe() {
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
const registration = await navigator.serviceWorker.ready;
const { publicKey } = await (await fetch(sitePath('push/key'))).json();
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: publicKey,
});
await fetch(sitePath('push/subscribe'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription),
});
}
Ask for permission from a click, not on page load. A prompt that appears before the visitor has done anything is denied by most people and — in Chrome — suppressed entirely for visitors who habitually deny them. A denial is close to permanent: the browser will not ask again, and the person has to find the site settings themselves.
userVisibleOnly: true is not optional in Chrome. A push that shows no
notification is treated as an abuse of the wake-up, and the browser shows its
own "This site has been updated in the background" instead.
6. The library¶
RFC 8291's payload encryption — ECDH, HKDF, AES-128-GCM — is not written here:
It is a suggestion rather than a requirement so that applications sending no
notifications do not carry a push library in vendor/. Without it the channel
sends nothing, and on every send it logs why and writes a NO_LIBRARY refusal to
the push log — a better failure than a message that silently never arrives.
Where the sent notifications are¶
/admin/PushLog, beside /admin/Emails, and the two answer the same question about the two
channels: what was sent, when, to whom, and what came of it.
It is needed because nothing else records a message. pushsubscriptions says when a browser was
last reached successfully and how many failures it has since — a fact about the browser, not
about a message — and massmessagerecipients covers only the mass-send path. The push log covers
every notify() as well.
use Pramnos\Push\Log;
Log::recent(100); // newest first
Log::recent(100, ['userid' => 42]); // one account
Log::recent(100, ['failed' => true]); // everything that did not arrive
Log::stats(7); // delivered / gone / refused / failed
Log::prune(90); // it only grows otherwise
Log::recent(20, ['endpoint_hash' => $hash]); // one browser's pushes
Log::find($pushId); // one attempt, or null
Log::outcome($row); // kind, label, and what it means
One push in full: /admin/PushLog/view/{id}¶
Every row on the list links to its own page, which answers the question the list cannot: why did this person not see it. It shows:
- what the outcome means, and what to check next, from
Log::outcome(); - the message: when, which account, which notification class, the title, the text, the address it opens, and the tag (a newer notification with the same tag replaces this one on the device);
- the device it went to: the browser, when it subscribed, when it last received a push,
and its failures since. The endpoint and keys are not shown, because whoever holds them can
push to that browser.
Subscriptions::describe()returns only the safe fields; - the latest pushes to that same device. These are the evidence. A run of deliveries to a browser the person says shows nothing points at the device; a run of failures points at the subscription.
"Delivered" means the push service accepted it, not that the person saw it. A 201 is
the browser vendor's service saying it will deliver to that browser. Whether a notification
appears is then the device's decision:
- notifications turned off for the site or for the browser;
- battery optimisation or Do Not Disturb holding it back;
- or simply another device than the one the person is looking at.
The detail page says so beside every delivery. The list and the detail page both word
outcomes through Log::outcome(), so they never disagree.
One row per attempt, and one for every refusal¶
The granularity is one attempt against one subscription, because that is where the answers differ: an account with three browsers gets three answers, and «delivered, delivered, 410» is the shape of the real question.
And every reason a push does not go out writes a row too — which is usually the row
somebody is looking for. The row has status 0, an empty endpoint_hash, and the text of one
of these Log constants in error:
Log:: constant |
What happened |
|---|---|
NO_SUBSCRIPTION |
no browser on the account has ever granted permission |
NO_KEYS |
the installation has no VAPID pair |
NO_LIBRARY |
minishlink/web-push is not installed |
NO_PAYLOAD |
the notification produced no title, so nothing could be shown |
Without them, an installation with no key pair and one where every notification is arriving look identical from every table — and the first one is not rare, because the library is a composer suggestion and the key pair is a step somebody can stop before.
What a status means¶
200 / 201 |
delivered to the push service, which will deliver it to the browser |
404 / 410 |
the subscription is gone; the row in pushsubscriptions is deleted |
429, 5xx |
the push service is busy. Kept, and counted as one failure |
0 |
the request never reached a server at all — DNS, a timeout. Emphatically not a 410 |
0 with an empty endpoint_hash is a refusal rather than a network failure; the screen
separates them.
It is a hypertable, compressed and expired¶
Append-only, timestamped, queried by recency, never updated — the shape TimescaleDB exists for,
and the shape that makes a plain table with a DELETE sweep the wrong answer: a delete over a
large table rewrites index pages and leaves bloat only a VACUUM FULL reclaims.
| Partitioned on | sent, in 7-day chunks — a week is how long why did they not get it stays a live question |
| Compressed after | 7 days, segmented by status |
| Dropped after | 90 days |
sent is written in UTC (Database\Instant::utc(): 2026-10-13 09:00:00+00:00 on PostgreSQL,
the bare 2026-10-13 09:00:00 on MySQL and MariaDB, which drops an offset), so a row means the
same moment whatever zone PHP and the database each run in; stats() and prune() count their windows
the same way, and Log::sentAt() reads a row back as a Unix time.
segmentby is status, not userid: a handful of distinct values — 201, 410, 429, 0 — and the
column the useful query filters on, so a batch that cannot match is skipped without being
decompressed. userid is high-cardinality and would produce one segment per account,
compressing almost nothing.
Dropped, which an audit trail deliberately is not. A push is cheap to send so applications send many, and nobody needs to know which notification a browser acknowledged last spring. Ninety days is long enough to investigate a complaint, which is the only reason anybody opens this.
All three declarations are documented no-ops without TimescaleDB, so MySQL and plain PostgreSQL
get the ordinary table and Log::prune() — which is why that method still exists.
What it does not store¶
The endpoint. Whoever holds it can push to that browser, so it is a credential, and a log is the last place to copy one to. The sha256 is enough to join a row to its subscription and to recognise the same browser twice, which is all this table is asked.
On the user's card¶
A Recent pushes panel beside Push devices, and a link to that account's own history. The devices panel says a browser subscribed; this says what was sent — which is the question somebody is holding when they open the screen.
The security alerts that send one¶
Two of the framework's own notifications go out on push as well as mail:
| Push | Why | |
|---|---|---|
| New sign-in from an unfamiliar device | yes | the mail is read when the mailbox is next opened; by then whoever had the password has been inside that long |
| Security change — password, address, factor, passkey | yes | if the address is what changed, the mail goes to the new one, which an attacker who changed it controls |
| Sign-in code | no | a credential |
| New-device auth link | no | a credential |
The line is between an alert and a credential, and it is the point rather than a detail. A code delivered to a subscribed browser is delivered to whoever holds that device — the person the second factor exists to stop. Mail is the deliberate second channel for those and stays the only one.
Never the database channel. An in-app notification is seen by whoever is currently signed in, who in the case worth warning about is the wrong person. Push passes that test where the database channel does not: a browser receives a push only because somebody granted permission in it earlier, so the subscriptions on an account are the owner's devices, not the one the sign-in just happened on.
Not instead of mail. A browser can be closed, unsubscribed, or have its site data cleared; the mail is the copy that survives all three and is still there tomorrow as a record.
The channel is added only when the account has a subscription —
Subscriptions::exist($userId), an indexed existence check. Without it every account that never
subscribed would write a "nothing subscribed" row to the push log on every alert.
An address change sends two mails, to the old address and the new; only the copy to the new address carries the push, because both are the same account and the devices would otherwise get the same warning twice.
What a notification can do¶
public function toPush(mixed $notifiable): array
{
return [
'title' => 'Νέο μήνυμα',
'body' => 'Ο Γιάννης σας έστειλε ένα μήνυμα.',
'url' => sURL . 'messages/42', // where a click goes
'icon' => sURL . 'assets/icon-192.png',
'badge' => sURL . 'assets/badge.png', // monochrome, Android status bar
'tag' => 'message-42', // replaces, instead of stacking
'actions' => [
['action' => 'read', 'title' => 'Διάβασέ το'],
['action' => 'later', 'title' => 'Αργότερα'],
],
'data' => ['messageId' => 42],
];
}
tag is the field worth using deliberately. Two notifications with the same
tag replace one another rather than stacking, which is the difference between
one "new sign-in" and fourteen.
Links. A click opens url — but the shipped worker first looks for a tab
that is already on this origin and focuses it, posting the payload to the page
instead of opening a second copy of your site. That is what people expect and it
is three lines; it is in the stub.
Action buttons. At most two are shown, and the worker can handle one
without opening a window at all — the most useful thing a notification can do,
because the reader acts and the browser never comes to the foreground. Put the
target in data.actions, keyed by the same action string:
'actions' => [['action' => 'notme', 'title' => 'Δεν ήμουν εγώ']],
'data' => ['actions' => ['notme' => ['post' => sURL . 'account/lock']]],
post fires a credentialed POST and dismisses; a url opens that address
instead. Anything beyond two buttons is dropped by the browser, so the channel
caps them rather than spending payload bytes on buttons nobody sees.
Sounds. Effectively no. silent: true suppresses sound; choosing a custom
one is not supported by Chrome or Firefox, and the sound property is ignored
everywhere that matters. The notification uses the operating system's own
notification sound, and the person controls it. Plan for that rather than
against it.
Payload size is about 4KB after encryption. A service that receives more
rejects the message outright — it does not arrive truncated, it does not arrive.
Title and body are capped for you; put an id in data and let the page fetch the
rest.
What goes wrong, and what the framework does about it¶
A dead subscription must be deleted; a busy service must not be¶
The two answers that must not be confused:
- 404 / 410 — the subscription is gone. The browser was uninstalled, the site data cleared, permission revoked. The row is deleted. Retrying is not a recovery, and a table of dead subscriptions makes every future send slower for ever, because each one is still a full HTTPS round trip.
- 429 / 5xx — the service is busy or briefly broken. The row is kept.
Deleting on a 429 silently unsubscribes live users during exactly the moment the
push service is under load, and nobody reports it, because it looks like nothing
happening. Subscriptions::recordResult() is where that decision lives, and it
is the reason this is a class rather than three lines in the channel.
A failure with no response at all — a DNS failure, a timeout — is not a 410 either. It counts as one bad attempt. After ten consecutive failures with no 410 among them, the subscription is presumed dead and removed; a single success clears the count.
The worker has no push handlers¶
A sw.js without the three handlers is registered,
working, caching assets, and discarding every notification. The send succeeds, the subscription
stays healthy, and the only symptom is that nobody mentions receiving anything.
push:vapid-generate says so, and so does the status MCP tool:
The service worker cannot receive this yet.
Found /var/www/html/www/sw.js, without:
push — receives the notification; without it the browser shows its own
"this site was updated in the background" instead
notificationclick — makes a notification go somewhere when it is tapped
pushsubscriptionchange — survives the browser rotating the subscription
The framework does not rewrite an application's files, so it reads them and reports. The
handlers are in scaffolding/templates/service-worker.js.stub; copy the Web push block to
the end of yours, or let push:setup --apply append it.
A tapped notification and an open tab¶
When a tab of the site is already open, the worker focuses it rather than opening a second one,
and posts {type: 'notification-click', url} to it. The browser script (assets/js/push.js)
takes the tab there. A single-page application that routes for itself listens for the
cancelable pf:notification-click event on window and calls preventDefault():
window.addEventListener('pf:notification-click', (event) => {
event.preventDefault();
router.push(new URL(event.detail.url).pathname);
});
Only an address of the site's own origin is followed, whatever the payload said.
The browser script's words, and its token¶
push.js says its sentences in English and takes their translations from its tag:
\Pramnos\Push\BrowserScript::attributes() writes data-push-words, a JSON object from each
English sentence to the page's language, only for those the language has. The scaffolded themes
print it:
<script src="<?php echo assetUrl('assets/js/push.js'); ?>"<?php echo \Pramnos\Push\BrowserScript::attributes(); ?> defer></script>
push/subscribe and push/unsubscribe accept a request from this site only: the session's token
as X-CSRF-Token, or the browser's own Sec-Fetch-Site: same-origin. The session cookie also
reaches them from another subdomain of the same site, and a page there could otherwise attach its
own endpoint to whoever is signed in. push.js sends the token from <meta name="csrf">, or from
data-push-token on its tag. The worker re-subscribes with no page open and no token, so it
relies on the browser's same-origin header.
The script also works on a page that has declared a global var URL = "…" of its own, which
replaces the browser's constructor. It resolves addresses with an anchor instead.
What an administration page loads¶
The worker leaves the administration area and the API to the browser (?skip=, admin/,api/ by
default). That includes the assets those pages load from the root, such as assets/js/pf-utils.js
or a font: the page that asked decides, by the request's referrer or the client it is for, not
only the asset's own address. A worker that activates also deletes its own asset cache, so what an
earlier version kept from admin pages is gone after the next deploy.
A site in a folder¶
The browser script fetches push/key and push/subscribe relative to where the site is: two
folders above itself (assets/js/push.js), or what its tag says with
<script src="…" data-push-base="/myapp/">. The worker resolves its addresses against its own
scope. Neither uses the host's root, which in a folder install is another site.
A page with a worker of its own¶
Subscriptions belong to a registration, and navigator.serviceWorker.ready is the worker that
controls this page — on a page with a narrower worker of its own, such as the conversations,
that is not the site's. So the browser script checks and subscribes the registration at the
site's address (getRegistration(<site>)), and only falls back to ready when there is none.
Code of your own that reads the subscription should do the same, or it will report notifications
as off on such a page and subscribe a worker that has no push handler.
showNotification() rejects¶
Permission can be revoked after a browser subscribes. The subscription stays perfectly valid as far as the push service is concerned, so the server goes on paying for a delivery that can never be shown — for ever, because nothing tells it otherwise.
The shipped worker catches that and unsubscribes, on NotAllowedError only: a transient failure
must not unsubscribe somebody who had a bad moment. Handed to waitUntil() without a catch it
is an unhandled rejection in somebody else's console, where nobody will ever see it.
pushsubscriptionchange¶
Browsers rotate a subscription's keys without asking, and the page may never be open when it happens. If the worker does not re-subscribe and re-register, every push to the old endpoint returns 410 and the row is deleted — the user simply stops receiving notifications and has no way to know.
The shipped worker handles it. If you wrote your own, this is the handler to copy.
Subscribing on every page load¶
subscribe() resolves instantly when permission is already granted, so a page
that calls it on load calls it on every load. Stored naively that is a row per
page view and a notification delivered to the same laptop forty times — which is
why the table is unique on (userid, endpoint_hash) and a repeat subscribe is an
update, not an insert.
Safari¶
iOS Safari supports web push only for a site added to the home screen, and only over HTTPS. There is no way to subscribe from the browser tab. Neither is a bug you can fix; both are worth saying on the page that asks for permission, because otherwise iOS users see a button that does nothing.
It works locally and not in production¶
Push requires HTTPS. localhost is exempt — every other host is not, including
an IP address on your LAN.
The endpoints¶
GET /push/key |
The VAPID public key. Public on purpose: it is the half of the pair the browser is supposed to hold. Answers 503 if no pair has been generated. |
POST /push/subscribe |
Records PushSubscription.toJSON() for the signed-in account. |
POST /push/unsubscribe |
Forgets one endpoint, scoped to the signed-in account. |
POST /push/ack?token= |
A sign-in approval's receipt, sent by the worker as the push arrives. The token is the credential — the worker may have no page and no session. |
POST /push/respond?token=&decision= |
Yes/No from an approval's notification buttons — Yes only on an ask that needs no number. POST only, signed in, and only from a trusted device — see the Authentication guide's Trusted devices and sign-in approval. |
POST /push/test |
Sends a test notification to the signed-in account's browsers, or only to the one whose {"endpoint": …} is in the body. Answers each test's token. POST only, signed in. See Testing a device. |
POST /push/testack?token= |
A test's receipt, sent by the worker as the push arrives. The token is the credential. |
GET /push/teststatus?token= |
{received, received_at, expired} for one of the signed-in account's own tests. |
subscribe also links the subscription to the browser's trusted device
(pushsubscriptions.trusted_device_id) when it carries the trust cookie — that link is
what makes it one of the phones a sign-in elsewhere can be approved from.
subscribe, unsubscribe, respond, test and teststatus require a session,
because a subscription belongs to an account — without one there is nobody to
notify (401 otherwise). ack and testack authenticate by their token alone,
because the service worker posting them may have no page and no session — the same
reasoning as the one-click mail endpoints.
POST /push/unsubscribe answers success when there was nothing to forget: the
browser has already unsubscribed by the time it calls, and reporting a failure
for something in exactly the state the caller asked for is not useful.
Testing a device¶
"I got nothing" has two possible causes, and a push log 201 cannot tell them apart:
- the notification never reached the device;
- it reached the device and the device did not show it.
A test can. It goes through the same PushChannel as every notification, so it is logged like
any other. Each browser gets its own copy with its own receipt address, and the service worker
posts that receipt the moment the push arrives, before it shows anything. A device that
receipted a test it never displayed points at the phone's settings: notifications for the site
or the browser, battery optimisation, Do Not Disturb.
use Pramnos\Push\TestPush;
$tests = (new TestPush())->send($userId); // every browser on the account
$tests = (new TestPush())->send($userId, $hash); // one: its endpoint_hash
// [['token' => '…', 'endpoint_hash' => '…', 'user_agent' => 'Chrome on Android',
// 'status' => 201, 'outcome' => ['kind' => 'delivered', …]], …]
(new TestPush())->status($tests[0]['token']);
// ['received_at' => 1759474010 or null, 'expired' => false, …]
Where to use it:
- On the administration screen, each push's page (/admin/PushLog/view/{id}) has
Send a test to this device. It sends to the device that push went to and opens the
test's own page. That page says whether the device confirmed it, or that it never did once
the hour for a receipt has passed.
- On an application's own device screen ("This device"), post the browser's own
subscription endpoint to /push/test, then ask /push/teststatus for the returned token
until it says received:
const sub = await (await navigator.serviceWorker.ready).pushManager.getSubscription();
const { tests } = await (await fetch('/push/test', {
method: 'POST', credentials: 'include',
body: JSON.stringify({ endpoint: sub.endpoint }),
})).json();
// then GET /push/teststatus?token=tests[0].token a few times
Receipts are kept in pramnos.pushtests, one row per test per device, and accepted for an
hour (TestPush::RECEIPT_WINDOW). The token is the receipt's credential, so only its SHA-256 is
stored, like every other token at rest. They are stored in the database rather than the cache,
because the receipt is a request from the device and may reach another web server than the
one that sent the test. A test notification's push log row is tagged pramnos-test:<token>;
TestPush::tokenOf($row) reads it back. The tag is unique, so two tests never replace each
other on the lock screen.
The scaffolded service worker already posts data.ack for sign-in approvals, and tests use the
same mechanism, so a project's sw.js needs no change.
Sign-in approvals¶
The phone prompt ("Is it you trying to sign in?") is a push with three differences, all in
Pramnos\Auth\ApprovalPushChannel, a PushChannel subclass:
- it goes only to the subscriptions it is handed — the account's trusted devices — never to every browser the account subscribed in;
TTLis 120 seconds, because an approval arriving after it expired can only confuse;urgency()ishigh, so a phone in power saving does not hold it back.PushChannel::urgency()is the seam (normalby default) for any channel of your own.
The payload asks the worker for two things the scaffolded worker does: data.ack — POST there
the moment the push arrives, which is how the waiting page knows it was delivered — and
data.open — tapping opens url in its own window rather than focusing an open tab.
Storage¶
A subscription is a credential for a delivery address, closer to a session token or a passkey than to a message: whoever holds the endpoint can send a notification to that browser. So it is treated like one — never logged, never returned by an API, deleted the moment the push service says it is gone.
Notification content is not stored here. It goes where every other channel's
content goes: a notification that also names 'database' in via() writes its
toDatabase() array to the notifications table, so the same alert is readable
in the application afterwards. Push is a delivery, not a record — a device that
was off for three days lost the notification, and only the table remembers it
happened.
Related¶
- Service Worker Guide — registering one, and the caching it also does
- Realtime Guide — SSE and WebSockets, for when the page is open
- Email Guide → a message to many accounts — pushing a campaign, and the audience filters
- Queue Guide — sending a large batch off the request
- Email Guide — the other channel that reaches somebody who is not here