php elephant sticker
Photo by RealToughCandy.com on Pexels.com

[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 between role="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 (typically User) can send with notify().

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 and aria-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') or diffForHumans() and align locale with Carbon::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" or role="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. → Use polite by default; reserve assertive 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.

By greeden

Leave a Reply

Your email address will not be published. Required fields are marked *

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)