xjoc/laravel-notification-center
Composer 安装命令:
composer require xjoc/laravel-notification-center
包简介
A headless notification center for Laravel.
README 文档
README
A headless notification center for Laravel — zero coupling to any UI package. It provides a central gateway, template management, per-channel admin settings, and per-user opt-out preferences for Laravel notifications, leaving presentation entirely up to you.
Notifications still flow through Laravel's native notification system. This package
inserts itself as a gateway on the NotificationSending event: it decides whether
a given notification may go out on a given channel, and injects the rendered template
body (and subject) at send time.
Concept: two tiers
The center deliberately keeps two kinds of notification types side by side:
- Tier 1 — coded types. Declared in
config/notification-center.phpundertypesand synced into the database withphp artisan notification-center:sync. These are the types your application code dispatches (e.g.order.confirmed,otp.sent). Re-syncing updates their structure but never their enabled/disabled state. - Tier 2 — admin types. Created at runtime through the admin API. They are owned by
the admin and are never touched by
notification-center:sync.
Every type belongs to a category (essential, transactional, alerts,
marketing). The category drives gateway behaviour — see below.
Requirements
- PHP
^8.2 - Laravel 12 or 13 (
illuminate/contracts: ^12.0 || ^13.0)
Installation
composer require xjoc/laravel-notification-center
The service provider is auto-discovered via package discovery.
One-command install
php artisan notification-center:install
This publishes the config, runs the package migrations, and runs an initial sync of your coded types. It is equivalent to running, in order:
php artisan vendor:publish --tag="notification-center-config"
php artisan migrate
php artisan notification-center:sync
Manual config publish
php artisan vendor:publish --tag="notification-center-config"
Required host tables (caveats)
This package leans on Laravel's native notification channels. Two things must exist in the host application, not in this package:
-
databasechannel requires the standardnotificationstable. Create it with:php artisan notifications:table php artisan migrate
-
whatsappchannel ships a driver (WhatsappChannel) that renders the template to text and hands a structuredWhatsappMessageto aWhatsappTransportyou supply. The package ships no provider integration: implementXJOC\NotificationCenter\Contracts\WhatsappTransport(one method,send(WhatsappMessage $message): void) to map the message to your provider (Twilio, Meta Cloud API, …) and register it via thenotification-center.whatsapp.transportconfig (FQCN) or by binding the interface in a service provider. Until you do, WhatsApp delivery throws a clearMissingWhatsappTransportException. v1 supports text only (WhatsappMessage::text($to, $body)); richer kinds (file/location/buttons) are reserved and throwUnsupportedWhatsappMessageExceptionuntil a future release.
Database tables this package creates
The package migrations create five tables, all namespaced with the notification_
prefix:
| Table | Purpose |
|---|---|
notification_types |
The notification types (coded + admin-created). |
notification_settings |
Per-type, per-channel enabled state. |
notification_templates |
Per-type, per-channel subject/body templates. |
notification_user_preferences |
Per-notifiable, per-(type, channel) opt-out state. |
notification_event_bindings |
Event-class → type bindings for the event listener. |
These names are not configurable in v1, and the models run on your default
database connection. Before installing, confirm your host app does not already own a
table with one of these names (the most plausible overlaps are notification_settings
and notification_templates). A per-package table prefix is intentionally deferred:
adding it later defaults to today's names, so it is a backward-compatible addition, not
a breaking change held back from v1. The package's own notifications table dependency
(for the database channel) is the host's standard Laravel table, covered above.
Low-touch integration (not zero-touch)
There is no magic. To route one of your existing notifications through the center, do two small things:
- Implement the
NotifiableNotificationcontract. - Use the
HasNotificationCentertrait.
<?php declare(strict_types=1); namespace App\Notifications; use Illuminate\Notifications\Notification; use XJOC\NotificationCenter\Concerns\HasNotificationCenter; use XJOC\NotificationCenter\Contracts\NotifiableNotification; final class OrderConfirmedNotification extends Notification implements NotifiableNotification { use HasNotificationCenter; public function __construct( private string $orderId, private string $total, ) {} public function notificationType(): string { return 'order.confirmed'; } /** @return array<string, mixed> */ public function notificationVariables(object $notifiable): array { return [ 'customer_name' => $notifiable->name, 'order_id' => $this->orderId, 'total' => $this->total, ]; } }
Then dispatch it the normal Laravel way:
$user->notify(new OrderConfirmedNotification($orderId, $total));
The trait provides via() (resolves to the type's supported channels), and
toMail(), toDatabase()/toArray(), and toWhatsapp(), all of which read the
template the gateway injected at send time.
Template injection + override-wins
At send time the gateway renders the type's stored template for the target channel and injects it into the notification via the single unified method:
public function injectTemplate(string $channel, string $rendered, ?string $subject = null): void;
- mail uses the rendered subject + body (
MailMessage). - database stores
['subject' => ?string, 'body' => string]. - whatsapp uses the rendered body string.
Override-wins rule. If you define your own toMail() / toDatabase() /
toWhatsapp() on the notification, your method wins — PHP method resolution puts
your class method ahead of the trait. The center will still gate the send; it just
won't supply the body. This is intentional: the trait is a convenience, not a cage.
If a channel has no injected template and no override, the trait throws
MissingTemplateException. This means: the type exists and is allowed, but no template row exists for that channel. Create one via the admin Template API.
The central gateway (order of decisions)
For every outgoing notification, the package listens on
Illuminate\Notifications\Events\NotificationSending and runs this exact sequence per
channel:
- Not ours? If the notification does not implement
NotifiableNotification, allow it untouched. - Unknown type? If the type key is not registered in the DB, allow it untouched.
- Essential bypass. If the type's category is
essential, inject the template and always allow — ignoring the master switch, the channel setting, and the user's opt-out. (See "Essential protection" under Security.) - Master switch. If the type is disabled (
is_enabled = false), block. - Admin channel setting. If the per-channel
NotificationSettingis disabled, block. - User opt-out. If the user has opted out of this
(type, channel), block. - Allow. Render + inject the template, then allow the send.
Blocking a single channel does not block the others — the decision is made per channel.
Categories and the essential bypass
| Category | Gateway behaviour |
|---|---|
essential |
Bypasses the gateway and is force-locked + force-enabled. Cannot be disabled by admin or opted out by users (e.g. OTP). |
transactional |
Fully gated (master switch, channel setting, user opt-out). |
alerts |
Fully gated. |
marketing |
Fully gated. |
Configuration
config/notification-center.php:
| Key | Default | Purpose |
|---|---|---|
admin_middleware |
['auth:sanctum', 'role:admin'] |
Middleware for the admin routes. |
user_middleware |
['auth:sanctum'] |
Middleware for the user routes. |
route_prefix |
'notification-center' |
URL prefix for both route groups. |
user_model |
'App\Models\User' |
String class name (never autoloaded at config-parse time). |
notifiable_models |
['App\Models\User'] |
Allowlist of models permitted as dispatch recipients. |
channels |
['mail' => MailChannel::class, 'database' => DatabaseChannel::class, 'whatsapp' => WhatsappChannel::class] |
Map of channel key => driver class (implementing NotificationChannel). The registered keys are the authoritative admin-pickable list. Add a custom channel by adding a key => class entry here or calling ChannelRegistry::register() in a provider. |
cache.enabled |
true |
Toggle the lookup cache. When false, every read hits the DB directly. |
cache.store |
null |
Cache store name (null = default store). |
cache.ttl |
3600 |
Cache TTL in seconds. |
cache.prefix |
'notification-center' |
Cache key prefix. |
templates.escape_html |
true |
When enabled, the mail driver escapes variable values in the body via e(). |
templates.on_missing_var |
'empty' |
'empty' (blank) or 'throw' (MissingVariableException) for unknown template variables. |
types |
see below | Tier-1 coded types synced via notification-center:sync. |
Declaring coded types
'types' => [ 'order.confirmed' => [ 'name' => 'Order Confirmed', 'category' => 'transactional', 'channels' => ['mail', 'whatsapp'], 'locked' => false, 'variables' => ['customer_name', 'order_id', 'total'], ], 'otp.sent' => [ 'name' => 'OTP Sent', 'category' => 'essential', 'channels' => ['whatsapp'], 'locked' => true, 'variables' => ['otp_code', 'expires_in'], ], ],
Artisan commands
| Command | Description |
|---|---|
notification-center:install |
Publish config, run migrations, then sync. One-shot setup. |
notification-center:sync |
Sync the coded types from config into the DB. |
notification-center:sync guarantees:
- Admin-created rows are never touched (matched by
created_by = 'admin'). - For existing config rows, only structural fields are updated (name, category,
supported channels, variables, lock) — the
is_enabledmaster switch is preserved. - New types are created with
is_enabled = trueandcreated_by = 'config'. - Default per-channel
NotificationSettingrows are created when missing; existing ones are never modified. - Templates are never touched.
- Essential types are force-locked.
- The command is idempotent and flushes the relevant caches afterwards.
Sending notifications
Direct dispatch (your code)
Use Laravel's notification system as usual once your notification implements the contract and uses the trait:
$user->notify(new OrderConfirmedNotification($orderId, $total));
Manual dispatch via the facade
When you don't have a dedicated notification class, dispatch by type key:
use XJOC\NotificationCenter\Facades\NotificationCenter; NotificationCenter::send( typeKey: 'order.confirmed', notifiables: $user, // a single notifiable or an iterable of them variables: ['customer_name' => 'Sam', 'order_id' => '42', 'total' => '$10'], channels: null, // null = all supported channels for the type );
Signature:
public function send( string $typeKey, iterable|object $notifiables, array $variables = [], ?array $channels = null, ): void;
This builds a GenericNotification and dispatches it through the gateway — so admin
settings and user opt-outs still apply.
Event binding (no code changes at the call site)
Bind a domain event to one or more notification types and the center will dispatch
automatically when that event fires. Your event must implement
ProvidesNotificationContext:
<?php declare(strict_types=1); namespace App\Events; use XJOC\NotificationCenter\Contracts\ProvidesNotificationContext; final class OrderWasConfirmed implements ProvidesNotificationContext { public function __construct(private object $customer, private string $orderId) {} /** @return iterable<int, object> */ public function notificationRecipients(): iterable { return [$this->customer]; } /** @return array<string, mixed> */ public function notificationVariables(): array { return ['customer_name' => $this->customer->name, 'order_id' => $this->orderId]; } }
Then bind the event to a type through the admin Event Bindings API. When the event
fires, the center sends the bound type(s) to the event's recipients with the event's
variables. The service provider registers listeners for every active binding at boot
(guarded against the DB not being ready, e.g. during migrate).
Because bindings are read at boot, a binding created at runtime takes effect on the next boot. Flush the binding cache after creating one (the admin API does this) and, in a long-running worker, plan for the listener registration to occur on the next process start.
API endpoints
All routes are JSON. The prefix is route_prefix from config (default
notification-center). Route-model binding resolves {type} to a NotificationType
and {binding} to a NotificationEventBinding.
Admin API
Group prefix: {route_prefix}/admin · middleware: admin_middleware · route-name
prefix: notification-center.admin.
| Method | URI | Name | Action |
|---|---|---|---|
GET |
types |
types.index |
List all types (with settings). |
POST |
types |
types.store |
Create an admin type. Essential forces lock + enabled. Creates settings. 201. |
PATCH |
types/{type} |
types.update |
Update name / supported channels / enabled. Disabling essential or locked → 422. |
POST |
types/{type}/dispatch |
types.dispatch |
Dispatch this type to resolved recipients. 202. |
GET |
types/{type}/templates |
types.templates.index |
List the type's templates. |
PUT |
types/{type}/templates/{channel} |
types.templates.update |
Upsert a template for a channel (201 created / 200 updated). |
GET |
types/{type}/event-bindings |
types.event-bindings.index |
List the type's event bindings. |
POST |
types/{type}/event-bindings |
types.event-bindings.store |
Bind an event class to the type. 201. |
DELETE |
event-bindings/{binding} |
event-bindings.destroy |
Remove a binding. 204. |
GET |
settings |
settings.index |
Overview of all types + their per-channel settings. |
GET |
channels |
channels.index |
Read-only list of registered channel keys (admin-pickable options). Never creates/modifies channels. |
Dispatch request body shape:
{
"recipients": { "model": "App\\Models\\User", "ids": [1, 2, 3] },
"variables": { "customer_name": "Sam" },
"channels": ["mail"]
}
recipients.model must be present in the notifiable_models allowlist (validated),
otherwise the request is rejected with 422. channels must be a subset of the type's
supported channels; null/omitted dispatches on all supported channels.
User API
Group prefix: {route_prefix}/user · middleware: user_middleware · route-name
prefix: notification-center.user.
| Method | URI | Name | Action |
|---|---|---|---|
GET |
preferences |
preferences.index |
The authenticated user's per-type, per-channel opt-out state. |
PUT |
preferences/{type}/{channel} |
preferences.update |
Set opted_out for a (type, channel). Essential type → 403. |
preferences returns, for each type and supported channel, an entry of the form
{ type_id, type_key, channel, opted_out, locked } where locked is true for essential
types. Updating an essential type is forbidden (403).
Caching
Type, setting, template, supported-channel, and event-binding lookups are cached to keep the gateway cheap on the hot path. Behaviour:
- Controlled by
cache.enabled,cache.store,cache.ttl, andcache.prefix. - When
cache.enabled = false, every read hits the DB directly and all cache forgets are no-ops. - Mutations through the admin API and
notification-center:syncperform targeted cache forgets (per type, per settings, per templates, and event bindings) so stale decisions never leak. - User opt-out lookups are cached per
(notifiable, type, channel)and forgotten when the user updates that preference.
Security
- Output escaping. The template renderer supports two token forms: escaped
{{ key }}and raw{!! key !!}. Escaping is decided per channel driver. The mail driver passes escaped{{ key }}values in the body through Laravel'se()helper whentemplates.escape_htmlis enabled, and renders the subject raw. The database and whatsapp drivers render raw. Use{!! ... !!}only for values you trust to be safe HTML. Unknown variables resolve to empty (or throw, pertemplates.on_missing_var). - Essential protection. Essential types are force-locked and force-enabled, bypass
the gateway entirely, and cannot be disabled by an admin (
422) or opted out of by a user (403). This guarantees critical messages such as OTP codes are always delivered. - Recipient allowlist. Manual/admin dispatch can only target models listed in
notifiable_models. Anything else throws/422s before a notification is built.
v1 caveats (honest limitations)
- Mail is simplified. The mail channel renders subject + body as a
MailMessagewith a single body line. There are no action buttons in v1; the rendered template body is the message body. - WhatsApp delivery is developer-supplied. The package renders the WhatsApp text and
builds a structured
WhatsappMessage, but ships no provider integration — bind your ownWhatsappTransport(via config or a provider). v1 is text-only; file/location/button kinds are reserved and throw until a future release. - Database channel needs the host's
notificationstable. Runphp artisan notifications:tableand migrate. - Event bindings register at boot. Bindings created at runtime take effect on the next process boot.
Development
This package uses spatie/laravel-package-tools and is tested against Laravel 12 and 13 on PHP 8.2–8.4.
composer install # install dependencies composer pint # format code (Laravel preset) composer phpstan # static analysis (larastan, max level) composer test # run the Pest test suite
Package structure
src/
├── Commands/ Artisan commands (install, sync)
├── Concerns/ HasNotificationCenter trait
├── Contracts/ Interfaces (NotifiableNotification, NotificationChannel, ProvidesNotificationContext)
├── Enums/ NotificationCategory, Channel, CreatedBy
├── Exceptions/ MissingTemplateException, MissingVariableException
├── Facades/ NotificationCenter
├── Http/
│ ├── Controllers/ Admin + User controllers
│ ├── Requests/ Form requests
│ └── Resources/ API resources
├── Listeners/ NotificationGatewayListener, EventBindingListener
├── Models/ Eloquent models
├── Notifications/ GenericNotification
├── Support/ Cache, PreferenceResolver, RecipientResolver
└── Templates/ TemplateRenderer
config/ notification-center.php
database/migrations/ Schema migrations
routes/
├── admin.php Administrative routes
└── user.php End-user routes
License
The MIT License (MIT). See LICENSE for details.
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 1
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: MIT
- 更新时间: 2026-06-25