Feature Notification Broadcasts
How to ship user-targeted realtime alerts with FeatureNotification: channels, toBroadcast payload, ephemeral vs retainable UI, and the chat stack as a complete reference implementation.
For domain ShouldBroadcast events, Echo setup, and ops, see Broadcasting Overview. Full class contract: FeatureNotification.
Mental Model
$user->notify(new MyFeatureNotification($model))
│
├─ via() → e.g. database, mail, broadcast
│ (broadcast stripped if BroadcastAvailability is off)
│
└─ broadcast channel
→ Illuminate BroadcastNotificationCreated
→ private users.{notifiableId}
→ Echo channel.notification(...)
→ pushBroadcastToastFromPayload(...)
├─ retainable: true → retainableNotification store (tray)
└─ else → broadcastToast store (top stack)Domain events (public/private channels for live sync) are a separate path — they do not replace this flow for toasts.
Channels (via / config / empty-env fallback)
FeatureNotification::via() delegates to getClassChannels():
- Read
config("modularous.notifications.{FQCN}.channels")(comma-separated string from env merges). - If the value is missing, empty, or whitespace-only, use the subclass
$defaultChannels(blank env must not wipe channels). - Filter to
$validChannels. - If
BroadcastAvailability::isEnabled()is false, removebroadcast.
Package defaults live in config/merges/notifications.php, e.g.:
'Modules\SystemNotification\Notifications\ChatableMessageBroadcastNotification' => [
'channels' => env('MODULAROUS_NOTIFICATIONS_CHATABLE_MESSAGE_BROADCAST_CHANNELS')
?: 'database,broadcast',
],
'Modules\SystemNotification\Notifications\ChatableUnreadNotification' => [
'channels' => env('MODULAROUS_NOTIFICATIONS_CHATABLE_UNREAD_CHANNELS')
?: 'database,mail,broadcast',
],In a concrete notification, set a safe fallback:
class OrderReadyNotification extends FeatureNotification
{
public $defaultChannels = ['database', 'broadcast'];
}Queue connections: viaConnections() maps broadcast → modularous.notifications.broadcast_connection (Horizon must process that connection for BroadcastNotificationCreated).
toBroadcast payload
toBroadcast() builds on toDatabaseFeatureFields() then adjusts the redirector and retainable flags:
| Field | Role |
|---|---|
token | Unique per notification instance (DB / mail redirect lookup) |
subject / message / htmlMessage | Toast / tray copy |
redirectorText / redirector / hasRedirector | CTA |
notification_type | class_basename of the notification |
retainable / retainUntil / retainGroup | Tray opt-in (see below) |
Redirector preference
Same pattern as mail:
- Prefer
getNotificationMailRedirector()→admin.system.system_notification.my_notification.showwhen a database row exists for thistoken(marks read, then redirects). - Fall back to the direct model redirector from
getNotificationRedirector()when the DB row is not available yet (broadcast-only channel, or race ifdatabase_connectionis not sync).
Default database_connection in merges is sync so the show URL is usually available when broadcast runs.
Ephemeral toast vs retainable tray
| Ephemeral | Retainable | |
|---|---|---|
| Opt-in | default (isRetainable() → false) | override isRetainable() → true |
| UI | Top toast stack (BroadcastToasts) | Bottom-right tray (RetainableNotifications) |
| Lifetime | Auto-dismiss timeout | Until Close / Look / TTL / DISMISS_BY_GROUP |
| Payload | retainable: false | retainable, retainUntil (ISO-8601 from getRetainHours(), default 6h), retainGroup |
Example: retainable order alert
use Modules\SystemNotification\Notifications\FeatureNotification;
class OrderReadyNotification extends FeatureNotification
{
public $defaultChannels = ['database', 'broadcast'];
public function isRetainable(): bool
{
return true;
}
/** One tray slot per order — later broadcasts upsert the same item. */
public function getRetainGroup(): ?string
{
return 'order:'.$this->model->getKey();
}
}token ≠ retainGroup
| Field | Role |
|---|---|
token | Unique instance id for DB/mail |
retainGroup | Stable tray key (one slot per logical entity, e.g. order:123, chat:42) |
When retainGroup is set, the frontend upserts by group so repeated broadcasts replace a single tray item.
→ Reference detail: FeatureNotification → Retainable tray
How the UI consumes broadcasts
Wired in vue/src/js/plugins/broadcasting.js (admin shell):
- Resolve
userIdfromSTORE.broadcast.userId(fallback: user profile id). Echo.private('users.' + userId).channel.notification(handler)maps subject/message/retainable fields viapushBroadcastToastFromPayload.listenToAllfallback for structuredtoastpayloads or Laravel notification shapes if Echo’s notification binding misses.
Retainable store API
import useRetainableNotifications from '@/hooks/useRetainableNotifications'
import { RETAINABLE_NOTIFICATION } from '@/store/mutations'
const { dismissByGroup } = useRetainableNotifications()
// Clear one logical slot when the user opens the related UI
dismissByGroup('order:123')
// or
store.commit(RETAINABLE_NOTIFICATION.DISMISS_BY_GROUP, 'chat:42')Tray items hydrate from localStorage per user (modularous.retainableNotifications.{userId}) and prune on TTL.
Ephemeral path commits BROADCAST_TOAST.PUSH instead.
Chat as an end-to-end example
Chat uses all three broadcast roles. Use it as a template when designing a feature with live collaboration + alerts.
Immediate: ChatableMessageBroadcastNotification
- Fired from
ChatController→Chatable::notifyChatableMessageBroadcast()on message create. - Channels default:
database,broadcast(no mail). - Retainable with
retainGroup = 'chat:{chatId}'. - Does not stamp
notified_at— interval unread path stays independent.
Interval unread: ChatableUnreadNotification
- Fired from
Chatable::handleChatableNotification()when the latest message is unread, past the interval, andnotified_atis null. - Scheduler:
modularous:scheduler:chatable(ChatableScheduler). - Channels default:
database,mail,broadcast. - Ephemeral toast (not retainable).
- Also dispatches domain event
UnreadChatMessageon publicunread-chat-message(fan-out / listeners — not the user toast). - Stamps
notified_atso the same message is not re-notified forever.
Live list: ChatableMessageSynced
- Domain event on
private-chats.{chatId},broadcastAs:modularous.chatable.message.synced. - Uses
GatesBroadcastAvailability. - Payload:
{ action: created|updated|deleted, chat_id, message }. - Dispatched from
ChatControlleron create / update / destroy. Chat.vuelistens with a leading.and mutates the open thread — no toast.
Echo.private(`chats.${chatId}`)
.listen('.modularous.chatable.message.synced', (payload) => {
// merge / remove message in local list
})Opening Chat dismisses the retain group
When the user opens a chat whose id is input, Chat.vue commits:
store.commit(RETAINABLE_NOTIFICATION.DISMISS_BY_GROUP, `chat:${chatId}`)So the tray clears for that thread once they are already reading it.
Decision map (chat)
| Concern | Class | Channel | UI |
|---|---|---|---|
| Sync open thread | ChatableMessageSynced | chats.{id} | In-component list |
| “New message” while elsewhere | ChatableMessageBroadcastNotification | users.{id} | Retainable tray |
| Reminder after interval | ChatableUnreadNotification | users.{id} (+ mail/DB) | Ephemeral toast |
| Domain signal | UnreadChatMessage | public unread-chat-message | Optional listeners |
Entity API: Chatable trait.
Checklist for a new feature notification
- Extend
FeatureNotification; set$defaultChannels(includebroadcastonly if you want realtime). - Add a
config/merges/notifications.php(or app override) entry withenv(...) ?: 'database,broadcast'. - Decide ephemeral vs retainable; if retainable, implement
getRetainGroup()and dismiss that group from the relevant Vue screen. - Rely on
toBroadcastdefaults or override subject/message via callbacks / subclass methods. - Confirm Horizon processes the broadcast notification connection; confirm Echo has
STORE.broadcast.userId. - Keep live collaboration on a dedicated
ShouldBroadcastchannel if the open UI needs structured sync — don’t overload the toast payload for that.
See also
- Broadcasting Overview — when to use what, ModelEvent, ops checklist
- FeatureNotification — callbacks, database payload, AfterSendable
- System notifications — built-in notification catalogue
- Testing broadcasts · Troubleshooting