[Complete Guide] Laravel Notifications × Real-Time Broadcasting — An Accessible Toast/Alert Design & Implementation Guide
What you’ll learn (TL;DR)
- The basics of Laravel Notifications (Mail / Database / Broadcast / Slack, etc.) and stable operation with queues
- Designing Event / Listener / Broadcast (public, private, presence) to achieve real-time updates
- UX patterns for accessible toasts / alerts / notification drawers (
aria-live
/role="status|alert"
/ focus management) - Read management, i18n, time display (Carbon), security (channel authorization, handling sensitive info), and performance strategies
- Feature/E2E testing viewpoints and designing fallbacks (progressive enhancement) for incidents
Intended audience (who benefits?)
- Laravel beginners to intermediates: Want to add email and in-app notifications now and be ready for real-time later
- Tech leads for SaaS / enterprise systems: Want to standardize the team’s notification design / operations / monitoring
- CS / PM / Product owners: Want to craft a notification experience users can notice, not be startled by, and act on quickly
- Accessibility / QA: Want to guarantee toasts and banners are announced and keyboard-operable without stress
Accessibility level: ★★★★★
We present implementation-level guidance for using
aria-live
, choosing betweenrole="status|alert"
, focus movement, close buttons, timing controls, color-independent state expression, and motion reduction (prefers-reduced-motion
).
1. Introduction: Notifications are an interface for “information” and “reassurance”
It’s not enough to just deliver notifications.
- Don’t miss (timing, visibility, screen reader announcement)
- Don’t startle (appropriately quiet by priority, restrained animation)
- Enable quick action (shortcuts to primary actions, focus movement, keyboard operation)
Laravel ships with Notifications and Broadcasting, making incremental adoption easy. Start with email / in-app (Database) notifications, and extend to real-time (Broadcast) as needed. ♡
2. Notification basics: Channels and data model
2.1 Prepare tables (Database channel)
php artisan notifications:table
php artisan migrate
- Notifications are stored as JSON in the
notifications
table, making read tracking (read_at
) easy.
2.2 Create a notification class
php artisan make:notification CommentPosted
// app/Notifications/CommentPosted.php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Messages\BroadcastMessage;
class CommentPosted extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public readonly array $payload) {}
public function via($notifiable): array
{
// Select the channels you need (incremental adoption OK)
return ['database', 'broadcast', 'mail'];
}
public function toArray($notifiable): array
{
return [
'type' => 'comment.posted',
'title' => 'New Comment',
'message' => $this->payload['excerpt'] ?? 'You have a new comment.',
'comment_id' => $this->payload['comment_id'] ?? null,
'url' => $this->payload['url'] ?? url('/'),
];
}
public function toBroadcast($notifiable): BroadcastMessage
{
return new BroadcastMessage($this->toArray($notifiable));
}
public function toMail($notifiable): MailMessage
{
$data = $this->toArray($notifiable);
return (new MailMessage)
->subject($data['title'])
->line($data['message'])
->action('View Comment', $data['url'])
->line('If you did not expect this email, please disregard it.');
}
}
- Implementing
ShouldQueue
automatically enqueues the notification and lightens your HTTP response. - With Broadcast, you can update the UI as soon as it arrives (explained later).
2.3 Sender side (e.g., on new comment)
use App\Models\User;
use App\Notifications\CommentPosted;
$user->notify(new CommentPosted([
'excerpt' => $comment->excerpt(80),
'comment_id' => $comment->id,
'url' => route('comments.show', $comment),
]));
- Any model with the
Notifiable
trait (typicallyUser
) can send withnotify()
.
3. In-app notification UI: Build an accessible “notification drawer”
3.1 Fetch list (unread / read)
// app/Http/Controllers/NotificationController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class NotificationController
{
public function index(Request $request)
{
$user = $request->user();
// Fetch recent notifications for page display
$notifications = $user->notifications()->latest()->paginate(20);
return view('notifications.index', compact('notifications'));
}
public function markAsRead(Request $request, string $id)
{
$notification = $request->user()->notifications()->findOrFail($id);
$notification->markAsRead();
// Accessible feedback (JSON example)
return response()->json(['ok' => true, 'read_at' => now()->toISOString()]);
}
}
3.2 Blade (skeleton of the notification drawer)
{{-- resources/views/notifications/index.blade.php --}}
@extends('layouts.app')
@section('title', 'Notifications')
@section('content')
<h1 class="text-2xl font-semibold mb-4" id="page-title" tabindex="-1">Notifications</h1>
{{-- Live region: announce list updates and read actions --}}
<div id="a11y-live" class="sr-only" aria-live="polite"></div>
<ul class="divide-y border rounded" role="list">
@forelse ($notifications as $n)
@php
$data = $n->data;
$isUnread = is_null($n->read_at);
@endphp
<li class="p-4 flex items-start gap-3 {{ $isUnread ? 'bg-blue-50' : '' }}">
<div aria-hidden="true">
{{-- Don’t rely on color alone: dot icon + text for unread --}}
@if($isUnread)
<span class="inline-block w-2 h-2 rounded-full bg-blue-600"></span>
@else
<span class="inline-block w-2 h-2 rounded-full bg-gray-400"></span>
@endif
</div>
<div class="grow">
<h2 class="font-medium">{{ $data['title'] ?? 'Notice' }}</h2>
<p class="text-sm text-gray-700">{{ $data['message'] ?? '' }}</p>
<p class="text-xs text-gray-500 mt-1">
{{ \Carbon\Carbon::parse($n->created_at)->diffForHumans() }}
</p>
<div class="mt-2 flex gap-3">
@if(!empty($data['url']))
<a class="underline" href="{{ $data['url'] }}">View details</a>
@endif
@if($isUnread)
<button type="button"
class="underline"
data-id="{{ $n->id }}"
data-action="mark-read">
Mark as read
</button>
@endif
</div>
</div>
{{-- Supplemental status for screen readers --}}
<span class="sr-only">
{{ $isUnread ? 'Unread' : 'Read' }}
</span>
</li>
@empty
<li class="p-4">There are currently no notifications to display.</li>
@endforelse
</ul>
<div class="mt-4">
{{ $notifications->links() }}
</div>
<script>
document.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action="mark-read"]');
if (!btn) return;
const id = btn.getAttribute('data-id');
const res = await fetch(`/notifications/${id}/read`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
});
if (res.ok) {
const live = document.getElementById('a11y-live');
live.textContent = 'Marked the notification as read.';
// Update the visuals inline (you could also re-render or do a partial swap)
btn.remove();
}
});
</script>
@endsection
Accessibility essentials
- Emphasize unread with dual expression: color + shape (dot icon).
- Insert text into
aria-live="polite"
to ensure the result is announced. - Implement as links/buttons so all features are reachable via keyboard.
- Use Carbon’s
diffForHumans()
for natural-language time.
4. Toast / alert components: Design and samples
4.1 Roles and responsibilities
- Toast: Low–medium priority completion/info notices (may auto-dismiss). Use
role="status"
+aria-live="polite"
. - Alert: High-priority error/critical notices (don’t auto-dismiss). Use
role="alert"
+aria-live="assertive"
. - Both should provide a close button and keyboard focus, and not depend on sound or motion.
4.2 Blade component example (toast)
{{-- resources/views/components/toast.blade.php --}}
@props([
'type' => 'info', // info|success|warning|error
'message' => '',
'autoHide' => true,
'timeout' => 6000,
])
@php
$role = $type === 'error' ? 'alert' : 'status';
$live = $type === 'error' ? 'assertive' : 'polite';
@endphp
<div x-data="{ open: true }"
x-show="open"
x-init="
$nextTick(() => {
// Move focus to the toast to prompt announcement (optional)
$el.focus();
if (@js($autoHide)) setTimeout(() => { open = false }, @js($timeout));
})
"
x-transition
@keydown.escape.window="open=false"
role="{{ $role }}"
aria-live="{{ $live }}"
tabindex="-1"
class="fixed bottom-4 left-1/2 -translate-x-1/2 max-w-md w=[calc(100%-2rem)] rounded shadow-lg p-4
{{ $type === 'success' ? 'bg-green-50 text-green-900' : '' }}
{{ $type === 'info' ? 'bg-blue-50 text-blue-900' : '' }}
{{ $type === 'warning' ? 'bg-yellow-50 text-yellow-900': '' }}
{{ $type === 'error' ? 'bg-red-50 text-red-900' : '' }}"
style="outline: 2px solid transparent; outline-offset: 2px;"
>
<div class="flex items-start gap-3">
<div aria-hidden="true" class="pt-1">
{{-- Differentiate by shape via icon --}}
@if($type === 'success') ✅ @elseif($type === 'warning') ⚠️ @elseif($type === 'error') ❌ @else ℹ️ @endif
</div>
<div class="grow">
<p>{{ $message }}</p>
</div>
<button type="button" class="underline ml-2" @click="open=false" aria-label="Close notification">
Close
</button>
</div>
</div>
<style>
@media (prefers-reduced-motion: reduce) {
[x-transition] { transition: none !important; }
}
</style>
- Switch
role
andaria-live
according to the priority of content. - Use initial focus to encourage announcement (use contextually, not always).
- Respect
prefers-reduced-motion
to reduce motion burden.
5. Real-time delivery: Event × Broadcast × Private Channel
5.1 Create event and broadcast
php artisan make:event CommentCreated
// app/Events/CommentCreated.php
namespace App\Events;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Queue\SerializesModels;
class CommentCreated implements ShouldBroadcast
{
use SerializesModels;
public function __construct(public readonly int $receiverUserId, public readonly array $payload) {}
public function broadcastOn(): array
{
// Private channel for an individual user
return [new PrivateChannel("users.{$this->receiverUserId}")];
}
public function broadcastAs(): string
{
return 'CommentCreated';
}
public function broadcastWith(): array
{
// Minimal data for the front end (exclude sensitive info)
return [
'type' => 'comment.posted',
'title' => 'New Comment',
'message' => $this->payload['excerpt'] ?? '',
'url' => $this->payload['url'] ?? url('/'),
];
}
}
5.2 Channel authorization
// routes/channels.php
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('users.{id}', function ($user, $id) {
return (int)$user->id === (int)$id; // Only the user themselves may subscribe
});
5.3 Dispatch (on comment creation)
event(new \App\Events\CommentCreated($receiverId, [
'excerpt' => $comment->excerpt(80),
'url' => route('comments.show', $comment),
]));
5.4 Front end: Receive with Echo and show toast (conceptual example)
<div id="rt-live" class="sr-only" aria-live="polite"></div>
<script type="module">
// Connect Echo to the broadcaster of your choice (configure to your environment)
// import Echo from 'laravel-echo'; window.Echo = new Echo({...});
const userId = '{{ auth()->id() }}';
window.Echo.private(`users.${userId}`)
.listen('.CommentCreated', (e) => {
// Accessible notification: toast + live region
const msg = e.message || 'You have a new notification.';
const region = document.getElementById('rt-live');
region.textContent = msg;
// Render using the Blade component (simplified here)
const toast = document.createElement('div');
toast.innerHTML = `{!! str_replace("\n", '', (string) view('components.toast', ['type'=>'info','message'=>'A new comment has arrived'])) !!}`;
document.body.appendChild(toast.firstElementChild);
});
</script>
Design points
- Use Private/Presence channels to isolate per user.
- Send a minimal payload (don’t include PII, internal IDs, or raw HTML).
- Provide a fallback (polling) for environments where push is unavailable (see next).
6. Fallback design: Use polling to ensure delivery
Prepare lightweight polling every few tens of seconds in case the real-time connection fails or is blocked.
// routes/api.php
use Illuminate\Http\Request;
Route::middleware('auth:sanctum')->get('/me/notifications/unread-count', function (Request $request) {
return ['count' => $request->user()->unreadNotifications()->count()];
});
// Check unread count every 30 seconds (consider exponential backoff on failure)
const badge = document.getElementById('notif-badge');
async function poll() {
try {
const res = await fetch('/api/me/notifications/unread-count', { headers: { 'Accept': 'application/json' }});
if (res.ok) {
const { count } = await res.json();
badge.textContent = count > 0 ? String(count) : '';
document.getElementById('a11y-live')?.textContent =
count > 0 ? `You have ${count} unread notifications.` : 'You have no unread notifications.';
}
} finally {
setTimeout(poll, 30000);
}
}
poll();
Points
- Maintain usability even without push (progressive enhancement).
- Announce changes in count via the live region to users who can’t rely on visuals.
7. Read management and “Save for later”: Kinder experiences
- Read: Update
read_at
immediately on click, and provide feedback via UI and live region. - Mark all as read: Place a button at the top as well so focus isn’t sent far away.
- Save for later: Add a flag (
saved_for_later
) to prevent losing important notifications. - Filters: Switch between unread / read / saved tabs with buttons +
aria-controls
(keyboard accessible).
8. Internationalization and time display: Messages that land
- Manage notification titles/bodies via a translation dictionary (array/JSON), and use
trans_choice
for pluralization. - For dates, use Carbon’s
isoFormat('LLL')
ordiffForHumans()
and align locale withCarbon::setLocale(app()->getLocale())
. - For mail, use
->locale($locale)
to switch language, and include alt text (for images) in translations.
9. Security and privacy
- Channel authorization: Implement strict checks in
routes/channels.php
. - Sensitive info: Do not include PII in broadcast payloads. Use signed URLs and authorization checks.
- Rate limiting: Prevent spam via excessive notifications.
- XSS protection: Sanitize/escape notification bodies. Avoid sending raw HTML lightly.
- Logging: Audit notifications for sensitive content (avoid leaving PII in logs).
10. Performance / operations
- Queues: Queue notifications by default (
ShouldQueue
). Standardize retry counts and delayed delivery. - Aggregation: Bundle similar notifications within a timeframe (“X and 3 more”).
- Pagination: Use
paginate()
for the list; keep unread badges via separate lightweight queries. - Deletion policy: Plan archiving/deleting old notifications (prevent table bloat).
- Monitoring: Retry failed notifications, design a DLQ, monitor workers (e.g., Supervisor).
11. Testing: Feature / E2E / Accessibility
11.1 Feature: Storage and read
// tests/Feature/NotificationsTest.php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
use App\Notifications\CommentPosted;
use Illuminate\Foundation\Testing\RefreshDatabase;
class NotificationsTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function it_stores_database_notification_and_marks_as_read()
{
$user = User::factory()->create();
$this->actingAs($user);
$user->notify(new CommentPosted(['excerpt'=>'Hello','url'=>'/comments/1']));
$this->assertCount(1, $user->notifications);
$n = $user->notifications()->first();
$this->assertNull($n->read_at);
$resp = $this->post("/notifications/{$n->id}/read");
$resp->assertOk();
$this->assertNotNull($n->fresh()->read_at);
}
}
11.2 Broadcast (unit-style check)
// tests/Unit/Events/CommentCreatedTest.php
public function test_broadcast_payload_is_minimal_and_safe()
{
$event = new \App\Events\CommentCreated(1, ['excerpt'=>'Hi','url'=>'/u/1']);
$data = $event->broadcastWith();
$this->assertArrayHasKey('message', $data);
$this->assertArrayNotHasKey('email', $data); // No sensitive info included
}
11.3 E2E (Dusk): Announcement and operability
- When a notification arrives, a toast is shown with
role="status"
orrole="alert"
. - You can reach the close button by keyboard and close with
Esc
. - After marking read, the live region announces in appropriate language.
12. Common design mistakes and remedies
- Announcing all alerts with
assertive
: Even non-critical notices interrupt users. → Usepolite
by default; reserveassertive
for fatal errors. - Toasts that only auto-dismiss: They disappear before being read. → Close button required; pause timer on hover/focus.
- Color-only status: Not inclusive of color vision diversity. → Multi-modal expression with icons, text, borders.
- Excessive animation: Drains attention and energy. → Respect
prefers-reduced-motion
and keep it subtle. - Over-notifying: Leads to notification fatigue. → Aggregate, throttle, and offer user-level settings.
13. Final checklist (for distribution)
Accessibility
- [ ] Use
role="status"
(info) /role="alert"
(danger) appropriately - [ ] Set
aria-live="polite|assertive"
correctly - [ ] Provide a Close button /
Esc
support / focus management - [ ] Multi-modal state beyond color: icons, text, borders
- [ ] Respect
prefers-reduced-motion
; keep animations minimal
Real-time
- [ ] Private / Presence channels for authorized users only
- [ ] Minimal payload, no sensitive info
- [ ] Provide a fallback (polling) to ensure delivery
Operations
- [ ] Document policies for queuing, retries, delays
- [ ] Prevent fatigue with aggregation and throttling
- [ ] Solid UX for read / saved / filters
- [ ] Archiving/deletion policy for old notifications
Internationalization
- [ ] Manage copy via dictionaries; use
trans_choice
for counts - [ ] Set Carbon locale and use natural relative time
14. Wrap-up: Deliver quietly, reliably, and kindly
This article showed how to build a gradually scalable notification platform and a clear, inclusive notification experience using Laravel Notifications and Broadcasting.
- Start with Database + Email to ensure delivery.
- Add Broadcast to make notifications visible the moment they arrive.
- Carefully design toast/alert a11y (
role
,aria-live
, focus, beyond-color states, restrained motion). - Support “not missing anything” with read / save / aggregation operations.
- Protect security/privacy, and operate reliably with queues and monitoring.
Notifications are the breath of trust between users and your product.
Quiet, reliable, and kind notifications make everyday experiences a bit more comfortable. Use these samples and the checklist as a base, and grow them into your team’s standard. I’m cheering for you, too. ♡
Who will find this guide especially useful (details)
- Tech leads for SaaS/enterprise systems: Seek standardization across notification design, read ops, and monitoring, and the ability to recover quietly during incidents.
- Customer Success / Product Owners: Want to reduce support tickets with can’t-miss pathways while avoiding over-notification.
- Accessibility / QA: Want to templatize test items for announcement guarantees of toasts/alerts, keyboard-only operation, and reduced motion.
- Indie developers: Start with email + DB notifications, and incrementally expand to real-time later.