Repository navigation
Architecture
The package namespace is Moztopia\Notifications. Public services are NotificationService, NotificationDeviceService, NotificationPreferenceService and NotificationHistoryService. NotificationPayload is the provider-neutral input. Laravel container discovery supplies their dependencies. Names are deliberately specific enough to import into a consuming application without routine aliases.
NotificationDeliveryService, NotificationRecipientService, NotificationTransportService, NotificationDeliveryJob and NotificationPushMessage implement the delivery lifecycle. They are not a second general-purpose notification framework. Existing FCM/APNs Laravel channels perform provider communication; Laravel supplies queueing, encryption, database transactions and native history behavior.
One initial publishable create migration owns all five package tables. All package entity primary keys are UUIDv7. Recipient morph IDs are UUIDs. Installation IDs, provider tokens, HMAC digests, generation counters and category names are values, not replacement entity identities.
NotificationPreference has an application identifier, owner morph type/UUID, global enabled flag, global push flag, application-defined category map and timestamps. A unique application/type/owner constraint provides exactly one preference record per recipient in an application. Defaults are enabled.
This record also supplies the per-recipient coordination row. Reusing it avoids adding a standalone lock table.
NotificationDevice contains application/environment, owner morph, installation identifier, platform, transport, immutable attempt scope, encrypted token, keyed fingerprint, generation, optional name, device push flag, last activity, revocation and normal timestamps.
Unique constraints enforce application/environment/installation uniqueness and active token-fingerprint uniqueness. The fingerprint is HMAC-SHA256 over the configured transport scope and token. Scope includes application, environment, provider, FCM project alias/project identity or APNs bundle. Revocation clears token/fingerprint and increments generation; it retains the registration row.
Ownership changes and token/scope changes increment generation. Registering the same token again does not. The service refuses a conflicting owner and requires explicit authorized reassignment. Tokens and fingerprints are hidden from model serialization.
NotificationMessage is the logical recipient event and retained idempotency record. It contains application/recipient, optional event-key digest, category, encrypted payload, expiration, deduplication deadline, cancellation and timestamps. The UUID is also the visible history identity and client notification_id.
A unique application/type/recipient/key-digest constraint atomically enforces idempotency. A null digest means a new unkeyed event. The same recipient/event key does not create a second message, history record or delivery set during retention. After retention expires, the old digest is detached before a new event is created under the recipient lock.
History-disabled sends still create this small internal lifecycle record. It is required for safe retries, recovery, cancellation and deduplication; it is not user-visible history.
NotificationHistory extends Laravel's DatabaseNotification, with an explicit package table and application scope. Columns retain native type, UUID notifiable morph, JSON-encoded data, nullable read_at and timestamps. The message UUID is reused as the primary key, so each recipient event has at most one history record regardless of device count.
Laravel's normal markAsRead, markAsUnread and unread/read scopes remain available. Host $user->notifications behavior is unchanged; use the package's history service for this table. History content is ordinary database-notification data, not encrypted by the native model: minimize sensitive content and protect the database/backups appropriately.
NotificationDelivery contains its own UUID, logical message UUID, device UUID, captured generation/provider/scope/environment, state, attempt count, safe error code, next availability, provider acceptance time and timestamps. The message UUID is a foreign key. A unique message/device constraint prevents duplicate intents.
The message resolves the intended recipient; worker ownership checks compare that immutable recipient with the current registration. Device rows are retained after revocation so outstanding references remain interpretable.
- Validate recipient identity and ensure its preference/coordination row exists.
- Enter a transaction and acquire recipient coordination.
- Validate ownership for an explicitly targeted device.
- Apply global/category creation preferences and resolve application/recipient/event idempotency.
- Persist the logical message and, when enabled, one native history record.
- Create intents for currently eligible registrations, capturing each generation/scope.
- Commit, then dispatch one Laravel job per intent using after-commit callbacks.
Queue outage after commit leaves durable pending intents for recovery. Transaction rollback creates neither jobs nor partial visible history. A repeated event does not rebuild its original recipient device list.
Each worker resolves the message recipient, acquires the same recipient coordination used by device changes/preferences/deletion, then locks the message and delivery and reloads the device. It verifies owner, generation, provider, application/environment/scope, eligibility, preferences, expiration and attempts before resolving the token and submitting to a single-token existing channel.
The recipient lock is held through provider submission and result recording until transaction commit. Registration changes and history deletion use that same boundary, so they cannot commit in the gap between eligibility checking and provider submission. Reassignment coordinates both recipients in deterministic identity order. Recipient-owned operations never rely on a token or installation identifier as authentication.
On databases with row locking, the preference row is updated/locked for the transaction. SQLite acquires its write lock through the initial preference update before reading eligibility; concurrent writers wait or fail safely rather than modifying state mid-send. This is conservative: submissions to devices of the same recipient are serialized, and SQLite serializes writers more broadly. This tradeoff keeps the boundary explicit with no distributed lock infrastructure. Use a transactional engine and bounded provider timeouts.
Do not mutate package models directly, use an independent recipient connection, or add an alternative sending path: those operations bypass the boundary. Database transaction automatic retries are disabled around provider submission because a deadlock retry could resend an accepted request.
States are pending, processing, provider_accepted, retryable_failure, permanent_failure, skipped and expired. processing is held inside the active transaction. A crashed process rolls back its claim, leaving the intent recoverable; it cannot leave a durable processing lease permanently stuck.
Every intent retries independently. Accepted A/C deliveries stay terminal while B retries. A retry loads the same intent rather than rebuilding device membership. Definitive token invalidation uses a conditional identity/owner/generation update, so an old result cannot revoke a replacement token.
Application idempotency and database claims provide best-effort duplicate prevention, not exactly-once delivery. A process/network failure after remote acceptance but before local commit creates an ambiguous outcome; resubmission may occur. Stable notification_id, grouping/collapse values and client deduplication reduce duplicate presentation. Already submitted messages cannot be recalled by logout, reassignment, preference changes or deletion.
No client receipt, display or interaction state is inferred from provider acceptance. Receipt acknowledgment endpoints and storage are intentionally absent.
Moztopia Notifications
One notification per recipient. Independent delivery to every device.
Documentation · Source · Report an issue · MIT License
© 2026 Moztopia. Distributed under the MIT License.
