factory/notify (0.11.0)

Published 2026-08-16 13:43:41 +00:00 by bmiadmin

Installation

{
	"repositories": [{
			"type": "composer",
			"url": ""
		}
	]
}
composer require factory/notify:0.11.0

About this package

SaaS Factory notifications: one chokepoint, durable rows, a transport driver interface, and routing as data. Self-hosted — no FCM, no Firebase, no Google project (D28).

factory/notify — v0.4.0

Every notification the fleet sends exists because Notify::send() was called. Products consume this as a versioned dependency (never copied); it depends on factory/kernel ^0.3 for the hash-chained audit and the acting identity, and — since 0.4.0, when the inbox got a route — on factory/infra ^0.1 for the response shape and factory/auth ^0.1 for the gate.

Supports Laravel 10 · 11 · 12 on PHP 8.2+ — both ends proven by the suite.

D28 — the push comes from our own box

No FCM. No Firebase. No Google project, no google-services.json, no Play Services dependency in any of the twelve apps. One origin (Cloudways) and five transports:

# Transport Reaches Cost
1 reverb — websocket app open, foreground free
2 socket — Android foreground service working roles on shift only battery
3 sms anyone with a phone metered
4 webpush — VAPID (RFC 8030/8291/8292), our own keypair browsers, installed PWAs free
5 the inbox anyone who opens the app again free

Transport 5 is not in the driver registry, because it is not a transport — it is the row. That is the whole architecture in one line: a notification is a durable row, and a transport is an attempt against a fact that already exists.

The honest limit, stated rather than papered over: a fully closed Android app cannot be woken by anyone but Play Services. We do not try. The user sees it the moment they open the app, and anything that genuinely cannot wait — a payment, an OTP, a cancelled trip — goes by SMS, which needs no app running at all.

test_no_google_push_dependency_anywhere_in_the_source fails the day any of this drifts. It strips comments before it searches, so writing about Firebase in a docblock is fine; depending on it is not.

The four drivers

All four are off until a .env line turns them on, and each line turns on exactly one. A box that configured nothing still notifies everybody — the row is the notification — and its delivery rows say transport_not_registered rather than nothing at all. That is the difference between unconfigured and silently broken, and it is a test, not a promise.

1 · reverb — the live socket

Rides the consuming app's own broadcast connection on private-notify.{product}.{type}.{id}, the shape Laravel's PrivateChannel already produces, so the app authorises it with an ordinary channel route and its existing auth. The transport holds no credential — the connection is configured once in broadcasting.php, where a deployment's secrets already live. A socket server that is down returns failed, which is retryable and not a bug: the durable row was written before the connection was attempted.

2 · socket — the shift service

The same protocol, aimed at a client holding an Android foreground service open. That service is a permanent tray notification and a visible battery cost — fair for a courier who is paid to receive jobs, unacceptable for someone who opened a shopping app once. So supports() re-reads allowed_roles itself: the doctrine survives someone pointing operational at socket for everyone in the routing table. Its hint is display: ongoing — the service rewrites the one notification it owns instead of stacking dead job offers.

3 · sms — the metered one

The only transport that spends money every time it succeeds, which is why the category gate is repeated in supports() and not trusted to the router: config is edited at 2am, a match in an enum is edited in a pull request.

One HttpSmsGateway covers every aggregator, because they differ in a URL, an auth scheme, three field names and where the message id sits — five pieces of data, not five pieces of behaviour. Adding an aggregator is a .env change on one box, not a package release across the fleet; a country only one provider covers is a config row, exactly as the jurisdiction engine says. The default gateway is log, the one that cannot spend money, because developing against a live metered channel is how a test suite runs up a bill.

Numbers are normalised to E.164 on the way out (655 12 34 56, 00237…, +237 655-123-456 all land on +237655123456) and an unusable one is skipped: phone_number_unusable, never a reason string with the number in it. Redact::text() scrubs anything a gateway hands back before it can reach a row: no number, no credential, no endpoint ever lands in the data.

4 · webpush — VAPID, our own keypair

RFC 8030 (the POST), 8291 (aes128gcm ECDH content encryption), 8292 (the signed identity) implemented against the specs — no SDK, no account, no console. php artisan notify:vapid prints three .env lines and the private half never leaves the box.

What goes out is a POST the push service cannot read: Content-Encoding: aes128gcm, a TTL, an Urgency (high for money and security, low for marketing — a sender that marks promotions urgent is a sender that gets throttled), and a Topic only when the caller asked for one by putting collapse in the notification's data. The topic is a hash, so the caller's key never travels. A payload over one record loses its data bag first — the client refetches that from the inbox by uid — and only then trims the body, because a truncated sentence still tells a person what happened and a missing one does not.

Each status means something to the row: 201 is sent with the service's Location, 410/404 is skipped: subscription_expired — the browser threw the subscription away, nothing to retry and nothing wrong — and the consuming app deletes its row on that reason, which is how a push table stays the size of the userbase rather than of its history.

The honest footnote, kept in the code rather than in a slide: the endpoint belongs to the browser's own push service, and for Chrome that service is Google's. We hold no project there, ship none of their code, and could send identical bytes to Mozilla's service or our own. The test for whether you depend on a vendor is whether you can change endpoints without changing code. Here the endpoint is data the browser hands us.

Verified against an independent implementation. tests/Support/FakeBrowser.php decrypts what we send using its own reading of RFC 8291, so a mistake shared by our encryptor and our assertions shows up as a failure instead of as agreement.

Turning one on

NOTIFY_REVERB_ENABLED=true      # + the app's own BROADCAST_CONNECTION
NOTIFY_SOCKET_ENABLED=true      # working roles on shift only

NOTIFY_SMS_ENABLED=true
NOTIFY_SMS_GATEWAY=http         # 'log' (default) writes a redacted line and spends nothing
NOTIFY_SMS_ENDPOINT=https://…   # + _METHOD / _AUTH / _TOKEN / _SENDER / _FORMAT / _DIAL_CODE
                                #   the aggregator's field names are config, not env

NOTIFY_WEBPUSH_ENABLED=true
NOTIFY_VAPID_PUBLIC_KEY=…       # ← php artisan notify:vapid
NOTIFY_VAPID_PRIVATE_KEY=…      #   server-side only, forever
NOTIFY_VAPID_SUBJECT=mailto:ops@example.com

A malformed VAPID pair costs its transport and nothing else. The tempting place to complain is an exception in boot(), and that would take down every page of the application — checkout included — over a notification channel. Instead the provider logs notify: web push is enabled but not usable — … NOTIFY_VAPID_PRIVATE_KEY is not a 32-byte P-256 scalar (named the way an operator greps: the .env key, never the pasted value), leaves the other drivers registered, and every subsequent send skips with transport_not_registered. Tested, both halves — the log line and the skip.

Chokepoint

Notify::send() is the only door. Five properties, each with tests:

  1. The row comes first. Written and committed before a single transport is touched. An app that was closed all afternoon pulls its inbox on resume and has missed nothing.
  2. Rendered here, in the recipient's language. Title and body leave send() as finished strings, chosen server-side from the recipient's profile (fr / en, falling back rather than shipping a raw key). The client renders nothing, so the SMS and the in-app row cannot disagree. A missing :placeholder is a hard error — a customer receiving "You received :amount" is not a degraded notification, it is a broken one.
  3. Routed by data, with a reason. The transports and why are stored on the row, so "why did this not go out by SMS?" is answered by reading the row rather than by re-running a resolver against config that has since changed.
  4. Audited or it didn't happen. The kernel audit write is inside the same transaction as the rows — one line per send(), not per delivery: the per-product hash chain serialises on a row lock, and a four-transport fan-out would make notifications the busiest writer in the chain for no gain. Delivery outcomes live on the delivery rows.
  5. Replay-safe. A repeated dedupeKey collides on UNIQUE(product_slug, dedupe_key) and gets the original notification back, not a boolean — a retrying job cannot otherwise tell "already sent" from "never sent".

On idempotency. NOTIFY_DOCTRINE.md §3 said "claimed through the kernel's IdempotencyGuard". The authority rule settled in A2 is narrower and is what ships here: when there is a natural row to collide on, collide on it. There are two — UNIQUE(product_slug, dedupe_key) and UNIQUE(notification_uid, transport) — so the guard would be a second, weaker copy of a constraint the database already enforces. The guard stays for the cases with no such row (as in the treasury's float release).

Routing is data

Adding a product adds a row. Adding an event adds a row. Nothing in src/ knows the name of a single business event. Two stages, and they run in one direction only:

Selection — the most specific layer that names this category wins:

event → role → product → default → nothing

A miss at every layer selects nothing, wh

Dependencies

Dependencies

ID Version
ext-openssl *
factory/auth ^0.1 || ^0.2 || ^0.3 || ^0.4 || ^0.5 || ^0.6
factory/infra ^0.1 || ^0.2 || ^0.3
factory/kernel ^0.3 || ^0.4 || ^0.5 || ^0.6 || ^0.7 || ^0.8 || ^0.9 || ^0.10
guzzlehttp/guzzle ^7.5
illuminate/console ^10.0 || ^11.0 || ^12.0 || ^13.0
illuminate/contracts ^10.0 || ^11.0 || ^12.0 || ^13.0
illuminate/database ^10.0 || ^11.0 || ^12.0 || ^13.0
illuminate/http ^10.0 || ^11.0 || ^12.0 || ^13.0
illuminate/routing ^10.0 || ^11.0 || ^12.0 || ^13.0
illuminate/support ^10.0 || ^11.0 || ^12.0 || ^13.0
php ^8.2
psr/log ^1.0 || ^2.0 || ^3.0

Development dependencies

ID Version
orchestra/testbench ^8.0 || ^9.0 || ^10.0 || ^11.0
phpunit/phpunit ^10.1 || ^11.0 || ^12.0
Details
Composer
2026-08-16 13:43:41 +00:00
32
proprietary
176 KiB
Assets (1)
Versions (5) View all
0.11.0 2026-08-16
0.9.1 2026-08-14
0.9.0 2026-08-07
0.8.1 2026-08-06
0.7.0 2026-08-06