In-app Notifications
Persistent notifications surfaced in the topbar bell dropdown — distinct from toasts (transient feedback). Backed by Laravel's standard
notificationstable.
What it is, what it isn't
- Toasts (
useToast) — transient, auto-dismiss, reactive feedback for the action a user just took ("Record saved", "Failed to send"). They're gone when the page reloads. - Notifications — persistent, per-user, bell + badge. The user opens the dropdown, reads them, marks as read, clicks through to a link, and they survive page reloads / sessions.
Martis shows in-app notifications in a topbar bell, built on Laravel's standard notifications table since v0.8 (before it, only toasts). Anything you already deliver via the database channel surfaces in the bell with zero glue.
How it works
The system is a thin UI layer over Laravel's standard notifications table. Anything that delivers via the database channel automatically surfaces in the Martis bell. This means:
- Existing Laravel
Notificationclasses work without changes. - Queue + broadcast + mail channels keep working alongside.
- The data shape is just the JSON payload your notification's
toArray()returns.
The Martis convention is a small set of recommended keys:
{ "title": "Invoice paid", "message": "INV-2026-001 has been paid.", "level": "success", // info | success | warning | danger "icon": "check-circle", // optional Phosphor icon name "action_url": "/martis/...", // optional click target "action_label": "View invoice" // optional CTA label}title is the only one Martis truly needs — the others either default sensibly or are omitted from the rendering.
Sending a notification — step by step
Goal: deliver a notification to a user and have it show up in the bell dropdown.
Step 1 — make sure the receiving model uses Notifiable
Laravel's standard Notifiable trait powers the whole flow. Apps that started from laravel/laravel already have it on App\Models\User:
use Illuminate\Foundation\Auth\User as Authenticatable;use Illuminate\Notifications\Notifiable; class User extends Authenticatable{ use Notifiable; // ...}If you need to deliver to other models (workspaces, teams, on-call rotations, ...) add the trait there too. The Martis bell only renders for the authenticated user, but the same database table backs everyone — so a future "team inbox" surface is a configuration change, not a schema migration.
Step 2 — pick a sender style
Two styles depending on how often you'll send the same shape.
Style A — one-off, inline (MartisNotification::make)
Ideal when the notification is built ad-hoc inside a controller / job / model event:
use Martis\Enums\NotificationLevel;use Martis\Notifications\MartisNotification; $user->notify(MartisNotification::make( title: 'Invoice paid', message: 'INV-2026-001 has been paid.', level: NotificationLevel::Success, // Info | Success | Warning | Danger icon: 'check-circle', // any Phosphor icon name (kebab-case) actionUrl: '/martis/resources/invoices/42', // click target actionLabel: 'View invoice', // CTA label rendered next to the timestamp));Only title is required. message defaults to '', level takes a Martis\Enums\NotificationLevel case (a string such as 'success' is a TypeError) and defaults to NotificationLevel::Info, the icon falls back to a level-default (info / check-circle / warning / warning-circle), and actionUrl / actionLabel are optional.
Style B — reusable, dedicated class
Pick this when the same notification fires from multiple places, needs to also send mail / Slack, or is queueable:
namespace App\Notifications; use Illuminate\Bus\Queueable;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Notifications\Messages\MailMessage;use Illuminate\Notifications\Notification;use App\Models\Invoice; class InvoicePaid extends Notification implements ShouldQueue{ use Queueable; public function __construct(public Invoice $invoice) {} public function via(mixed $notifiable): array { return ['database', 'mail']; } public function toMail(mixed $notifiable): MailMessage { return (new MailMessage) ->subject("Invoice {$this->invoice->code} paid") ->line("Your invoice {$this->invoice->code} has been paid.") ->action('View invoice', url("/martis/resources/invoices/{$this->invoice->id}")); } public function toArray(mixed $notifiable): array { return [ 'title' => 'Invoice paid', 'message' => "{$this->invoice->code} has been paid.", 'level' => 'success', 'icon' => 'check-circle', 'action_url' => "/martis/resources/invoices/{$this->invoice->id}", 'action_label' => 'View invoice', ]; }}The database channel reads toArray() — the keys above are exactly what the React renderer looks for.
Step 3 — fire it
From any controller, job, model event, command, ...:
use App\Notifications\InvoicePaid; $invoice->payer->notify(new InvoicePaid($invoice));Or send to many recipients at once:
use Illuminate\Support\Facades\Notification; Notification::send($team->members, new InvoicePaid($invoice));Step 4 — verify
Recipients see the notification inside 90 seconds (default poll interval, configurable) — the bell badge updates, then the dropdown shows the new entry on next open. To confirm during local development:
# count pending notifications for a userphp artisan tinker --execute='$u = App\Models\User::where("email", "you@example.com")->first();echo $u->unreadNotifications()->count(), "\n";'To deliver instantly (no polling delay), see "Real-time delivery" below — it requires standing up your own broadcaster and Echo client (Martis ships neither), or wiring the pluggable martisEventBus feed, which needs no broadcaster at all.
Reference: data shape
The React renderer reads these keys from the notification's toArray() / MartisNotification::toArray():
| Key | Required | Default | Effect |
|---|---|---|---|
title | yes | class basename | Bold first line. |
message | no | — | Muted second line. |
level | no | info | Drives the icon bubble colour. One of info / success / warning / danger. |
icon | no | level default | Phosphor icon name (kebab-case). Resolves through iconRegistry so any of the 1500+ icons works. |
action_url | no | — | Click target. A same-origin path (/…) does an in-app navigation; full URLs open in a new tab. Since v2.0.1 a value that starts with // or holds a backslash or a control character is treated as another origin and opens in a new tab too, instead of going through the router. Since v2.4.0 only an http(s), mailto: or tel: URL opens in a new tab: any other scheme (javascript:, data:, ...) is refused with a console error. |
action_label | no | — | CTA text rendered next to the timestamp. Only shown when action_url is set. |
In addition to the keys above, every row carries Laravel's standard notification envelope: id (UUID), type (notification class), read_at (ISO timestamp or null), created_at. The renderer uses id for mark-as-read / delete calls, type for grouping hooks, read_at to grey out read entries, and created_at for the relative timestamp.
Configuration
// config/martis.php'notifications' => [ 'enabled' => env('MARTIS_NOTIFICATIONS_ENABLED', true), 'poll_interval' => env('MARTIS_NOTIFICATIONS_POLL_INTERVAL', 90000), 'max_in_dropdown' => env('MARTIS_NOTIFICATIONS_MAX_DROPDOWN', 10),],| Key | Effect |
|---|---|
enabled | Master switch. When false, the bell never renders and the API returns empty payloads. |
poll_interval | How often the bell badge polls /api/notifications/unread-count (ms). Default 90000 (90 s) — bumped from 60 s in v1.8.8 to halve idle traffic without a UX regression for typical workflows. Set to 0 to disable polling — refresh manually via React Query / broadcast events. |
max_in_dropdown | Maximum entries shown in the dropdown. Older entries live behind a future "View all" link. Capped at 50 server-side. |
Hiding notifications + the bell. enabled is the single switch for "do I want system notifications in this panel?" Set it to false (or MARTIS_NOTIFICATIONS_ENABLED=false) and the bell icon next to the user profile disappears, polling stops, and the REST endpoints return empty payloads. There is no separate user_menu toggle for the bell — this is it.
On a phone (v2.15.0). The panel is 360px wide and right-aligned to the bell, but it never leaves the screen: the bell measures itself when the panel opens (and on resize), moves the panel right until it sits 8px from the left edge, and caps its height to the room under the top bar (the list scrolls). It writes the two values as CSS variables on .martis-notif-panel, --martis-notif-shift (used by right) and --martis-notif-room (used by max-height, with 420px as the upper bound). A theme that restyles the panel keeps those two declarations, or the panel runs off a phone screen again.
Scoping the notification centre
By default the bell shows every database notification of the signed-in user. An app where one user works in several contexts (tenants, workspaces, product areas) can narrow it with Martis::scopeNotificationsUsing() (v2.1.0), in app/Providers/MartisServiceProvider.php:
use Illuminate\Http\Request;use Martis\Facades\Martis; // Illustration: an app that stores the tenant in the notification data.Martis::scopeNotificationsUsing(function ($query, Request $request) { $query->where('data->tenant_id', $request->user()->current_tenant_id);});- The closure receives an Eloquent query builder over the user's notifications and adds its constraints to it (its return value is ignored).
- Its constraints are grouped in parentheses and joined to the user constraint with
AND, so anorWhere(for example->where('data->tenant_id', 1)->orWhere('data->tenant_id', 2)) stays inside the signed-in user's notifications. - Only where constraints of the closure apply: it runs on a nested builder, so
orderBy(),limit()and joins are ignored. - It applies to every endpoint: the list, both unread counts, mark-read, mark-all-read, delete and clear-all.
- A notification outside the scope is absent from the list and the counts. Mark-read and delete answer
404for it, and mark-all-read and clear-all leave it untouched. - The real-time feed forwards whatever your transport emits, and the bell cannot check a pushed notification against a server-side scope. While a scope is registered,
martis:notification-receivedtherefore makes the bell refetch the scoped count and list instead of adding one to the badge, so an out-of-scope push never shows. - There is one scope: a second
scopeNotificationsUsing()replaces the first (unlikeMartis::commandPalette(), which accumulates). An app and a package that both narrow the centre combine their conditions in one closure. - Without a scope, nothing changes.
REST API
All endpoints live under /{martis-path}/api/notifications, scope to the authenticated user, and silently no-op when there's no user (keeps polling cheap on the login screen).
| Method | Path | Description |
|---|---|---|
GET | /notifications | Paginated list. Query: per_page (max 50), unread_only (bool). |
GET | /notifications/unread-count | Cheap COUNT query for the badge. |
POST | /notifications/{id}/read | Mark single as read. |
POST | /notifications/read-all | Mark every unread as read. |
DELETE | /notifications/{id} | Delete single. |
DELETE | /notifications | Clear all. |
Real-time delivery
Polling defaults to 90 s — fine for most apps. Two honest facts up front:
MartisNotification::toBroadcast()only emits Laravel's ownIlluminate\Notifications\Events\BroadcastNotificationCreatedevent on the server side. It does not deliver anything to the browser by itself.- Martis ships no broadcaster, no
routes/channels.php, and no Echo client. The bell has no built-in Echo consumer. Getting an actual instant push to the browser via Laravel broadcasting is entirely on the host app to build — see option 1 below. If you want instant delivery without any of that infrastructure, use the pluggable feed in option 2 instead.
Option 1 — Laravel broadcasting (Reverb/Pusher), wired by you
To get real Slack-like push delivery through Laravel's broadcasting layer, the host app must:
-
Stand up a broadcaster (Reverb, Pusher, or another
BROADCAST_CONNECTIONdriver) and configure it inconfig/broadcasting.php/.env. -
Publish
routes/channels.phpwith aBroadcast::channel('App.Models.User.{id}', function ($user, $id) { return (int) $user->id === (int) $id; })authorization callback, since notifications broadcast on the notifiable's private channel. -
Add the
broadcastchannel to your notification'svia():class MyNotification extends MartisNotification\{public function via(mixed $notifiable): array\{return ['database', 'broadcast'];}}The
MartisNotificationbase provides a defaulttoBroadcast()payload (the same array astoArray()), so you don't need to implement that method yourself — but everything in steps 1-2, plus step 4 below, is still on you. -
Install and bootstrap an Echo client (
laravel-echo+ a driver package such aspusher-js) in your own extension bundle, and subscribe:echo.private('App.Models.User.{id}').notification(n => martisEventBus.emit('martis:notification-received', n)). This is exactly the seam described in option 2 — Echo just becomes one more transport feeding it.
Martis intentionally ships none of this (no bundled Echo/Pusher/websocket client, no channel routes) to stay broadcaster-agnostic and avoid forcing a dependency on apps that don't need real-time delivery.
Option 2 — Pluggable real-time feed (no Reverb required)
Any transport — your own WebSocket gateway, Server-Sent Events, or an Echo listener you write per option 1 — can push a notification into the bell instantly, with zero broadcaster setup, by emitting on the shared Martis event bus (see docs/components.md "Event Bus"):
import { martisEventBus } from '@martis/runtime' // e.g. inside your own WebSocket `onmessage` handler, SSE `onmessage`,// or an Echo `.notification()` callback:martisEventBus.emit('martis:notification-received', { id: payload.id, title: payload.title, message: payload.message,})The notification bell (NotificationBell.tsx) is poll-only by default, but it also subscribes to this event: emitting martis:notification-received bumps the unread badge and, if the dropdown is already open, prepends the item to the visible list — immediately, no waiting for the next poll.
Polling (MARTIS_NOTIFICATIONS_POLL_INTERVAL, default 90 s) keeps running underneath as the reconciliation fallback, so a missed or dropped push self-heals on the next poll tick instead of leaving the badge permanently stale. The real-time feed is additive, not a replacement — Martis ships no transport of its own, only the event contract the bell listens for.
Reconciling reads across sessions
martis:notification-received only moves the badge up. When a notification is marked read (or read-all / deleted) in another session, that session's own count goes down, but a second open session keeps showing the inflated count until its next poll. To converge immediately, emit the payload-less companion event:
import { martisEventBus } from '@martis/runtime' // e.g. when your socket/SSE tells this tab a read happened elsewhere:martisEventBus.emit('martis:notifications-changed', {})The bell responds by re-fetching /api/notifications/unread-count (and the open list) right away, so the badge reconciles in whichever direction the server now reports — the down-direction mirror of martis:notification-received. Bridge it from the same transport that already feeds the received event. As always, the poll remains the fallback if no transport fires it.
Migration
The martis:install --force command publishes a migration that creates the standard Laravel notifications table (idempotent — skipped when the table already exists). Apps that already ran php artisan notifications:table are compatible with no migration needed.
Tests
- 9 feature tests in
tests/Feature/NotificationControllerTest.phpcovering the six endpoints, the data shape, ownership scoping, and the disabled-config case.