Framework
Subsystems

Notifications

Push notifications through Firebase or OneSignal, backed by a per-user queue that doubles as an in-app inbox — with unread counts, read state and localized copy.

Sending

The Notification class pushes to devices directly. sendToAll() broadcasts to every registered device, while sendToSome() targets specific device player ids. Each carries a title, a message, a url to open, a dataType / dataID pair the app can route on. A send to some devices also takes an optional badge, the number the app icon shows, which a broadcast cannot, as no one number is right for everyone. Each answers with a NotificationResult and the id the provider gave the push, which is empty when it did not go out:

use Framework\Notification\Notification;

// Broadcast to every registered device
[ $result, $externalID ] = Notification::sendToAll(
    title:    "New release",
    message:  "Version 2 is out",
    url:      "changelog",
    dataType: "release",
    dataID:   42,
);

// Or target specific devices by their player id
[ $result, $externalID ] = Notification::sendToSome(
    title:    "Order shipped",
    message:  "Your order is on its way",
    url:      "orders/7",
    dataType: "order",
    dataID:   7,
    playerIDs: [ "a1b2…", "c3d4…" ],
    badge:    3,
);

Send results

Delivery records a NotificationResult per user — Sent, or the reason it was skipped:

ResultMeaning
SentHanded off to the provider.
InactiveSendNotifications are turned off (NOTIFICATION_ACTIVE).
NoDevicesThe user has no registered devices.
NoProviderNo NOTIFICATION_PROVIDER is set, so there is nothing to push through.
ProviderErrorThe provider rejected the push.

Configuration

As with email, everything is set through environment values:

KeyControls
NOTIFICATION_ACTIVEThe master switch — off means nothing is sent.
NOTIFICATION_PROVIDERWhich provider to push through (see below).
NOTIFICATION_ICONThe default icon shown on a push.
NOTIFICATION_LIMITMaximum notifications delivered per run.
NOTIFICATION_DELETE_DAYSHow long delivered notifications are kept.

Providers

NOTIFICATION_PROVIDER names a NotificationProvider. When you call a send, the Notification class works out the icon and the full url, hands the push to the matching provider, and answers Sent with the id the provider gave it, or ProviderError with an empty one if it would not take it.

There is no provider to fall back on. A send with NOTIFICATION_PROVIDER unset returns NoProvider, so a setup that was never finished says so.

Each device records the provider that reaches it, and a push only goes to the devices of the provider of the config. So when one replaces another, the devices of the old one are kept rather than pushed to through the new one, and are there should the config go back.

Firebase

Pushes are delivered through Firebase Cloud Messaging, addressed by the registration token that each device registers when a user signs in. A push to everyone goes to the all topic, which every device subscribes to on its own, and a token Firebase reports as no longer registered takes its device off the credential. The keys are the project_id, client_email and private_key of the service account file the Firebase console gives you, pasted as they are:

.env
NOTIFICATION_ACTIVE   = true
NOTIFICATION_PROVIDER = "Firebase"
FIREBASE_PROJECT_ID   = "…"
FIREBASE_CLIENT_EMAIL = "…"
FIREBASE_PRIVATE_KEY  = "-----BEGIN PRIVATE KEY-----\n…\n-----END PRIVATE KEY-----\n"

OneSignal

Pushes are delivered through OneSignal's REST API, addressed by the device player ids that each device registers when a user signs in. Point it at your OneSignal app with its app id and REST key:

.env
NOTIFICATION_ACTIVE   = true
NOTIFICATION_PROVIDER = "OneSignal"
ONESIGNAL_APP_ID      = "…"
ONESIGNAL_REST_KEY    = "…"
ONESIGNAL_USE_ALIAS   = false

ONESIGNAL_USE_ALIAS addresses the devices by their OneSignal alias rather than by subscription id.

Writing your own

The enum is not a list you edit. A provider is a class implementing NotificationSender, and the build finds every one of them — in the framework and in your app — and writes the NotificationProvider enum from their names, the same way email does. The class name is the case, so a Pushy class is NOTIFICATION_PROVIDER = "Pushy", and a Name constant on the class overrides that.

Its keys are yours to name: add them to the .env and the build turns each into a typed Config getter. Both sends answer with the id the provider gave the push, or an empty string when it would not take it:

src/Provider/Pushy.php
namespace MyApp\Provider;

use Framework\Notification\NotificationSender;

class Pushy implements NotificationSender {

    public static function sendToAll(
        string $title,
        string $message,
        string $url,
        string $icon,
        string $dataType,
        int $dataID,
    ): string {
        return "the-id-it-gave-back";
    }

    public static function sendToSome(
        string $title,
        string $message,
        string $url,
        string $icon,
        string $dataType,
        int $dataID,
        array $playerIDs,
        int $badge = 0,
    ): string {
        return "the-id-it-gave-back";
    }
}

Pushing in a test

As with email, write a sender that keeps what it is handed and give it to Notification::setSender(). The send then runs the whole way and stops there:

use Framework\Notification\Notification;

Notification::setSender(TestNotificationSender::class);
[ $result, $externalID ] = Notification::sendToAll(
    "New release", "Version 2 is out", "changelog", "release", 42,
);

Notification::setSender();   // back to the one of the config

The queue & inbox

Most notifications go through the NotificationQueue rather than pushing straight away. It stores one row per user, which makes it two things at once: a delivery buffer that a cron flushes to each user's devices, and an in-app inbox with read state and unread counts. Queue one for a credential:

use Framework\Notification\NotificationQueue;

NotificationQueue::add(
    credentialID: 42,
    currentUser:  1,
    title:        "Order shipped",
    message:      "Your order is on its way",
    url:          "orders/7",
    dataType:     "order",
    dataID:       7,
);

A scheduled sendAll() then looks up each user's devices and pushes the pending rows, each with the user's unread amount as the badge, while the rest of the API drives an inbox UI:

MethodDoes
sendAll()Cron — push every pending notification to its user's devices.
getAllForCredential(…)A user's notifications, for rendering the inbox.
getUnreadAmount(…)The unread badge count.
markAsRead(id) / discard(id)Mark one as read, or dismiss it.
deleteOld()Cron — prune rows past NOTIFICATION_DELETE_DAYS.

Run the scheduled methods — sendAll() and deleteOld() — from your server's cron. First expose each as a console command:

use Framework\Discovery\Attr\ConsoleCommand;
use Framework\Notification\NotificationQueue;

class NotificationCommands {
    #[ConsoleCommand("sendNotifications")]
    public static function send(): void {
        NotificationQueue::sendAll();
    }

    #[ConsoleCommand("cleanNotifications")]
    public static function clean(): void {
        NotificationQueue::deleteOld();
    }
}

Then add the crontab entries:

# crontab
* * * * *   cd /srv/app && ./framework sendNotifications
0 4 * * *   cd /srv/app && ./framework cleanNotifications

Localized content

Repeated notifications keep their copy in the translation files, exactly like email content. Each message lives per language under nls/notifications, keyed by a code, with a title, a message, and a description — a note for whoever edits the copy:

nls/notifications/en.json
{
    "OrderShipped": {
        "description": "Sent when an order ships",
        "title": "Order shipped",
        "message": "Your order {{orderID}} is on its way"
    }
}

Add the matching es.json, with the same keys in the same order — nlsCheck says whether they still match. At build time the NotificationBuilder turns each key into a typed NotificationCode, while a migration loads the copy into the database. Fetch it by language:

use Framework\Notification\NotificationContent;
use Framework\System\NotificationCode;

$content = NotificationContent::get(NotificationCode::OrderShipped, "en");

NotificationContent::render() fills its {{placeholders}} — like {{orderID}} above — with per-send data before the title and message are queued.