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

[Hands-On Field Guide] Real-Time Experiences in Laravel — Broadcasting/WebSockets/SSE, Notifications, Reconnects, Offline Support, and Accessible Live Updates

What you’ll learn (highlights)

  • How to choose between Broadcasting (event → channel → delivery) and WebSockets / SSE
  • Architecture for Laravel Echo / Laravel WebSockets / Pusher, and authenticated channels (Private/Presence)
  • Live UI with minimal JavaScript using Livewire/Alpine/Blade, and fallback design (Polling/SSE)
  • Accessible live updates (aria-live, role="status", focus management, toasts/banners/badges)
  • Practical reconnection, offline handling, rate limiting, authorization, audit logging, and testing (Feature/Dusk)
  • Samples for chat, notification center, progress boards, and dashboard aggregation

Intended readers (who benefits?)

  • Laravel beginners to intermediates: Build dynamic, real-time screens (chat, notifications, dashboards) safely
  • Tech leads/PMs: Compare costs/operations of external services (Pusher) vs. self-hosting (Laravel WebSockets)
  • QA/Accessibility professionals: Standardize screen-reader announcements, keyboard interactions, and color-independent notifications
  • CS/Support: Improve content design of notifications and progress to reduce inquiries

Accessibility level: ★★★★★

We cover implementation for aria-live / role="status" / role="alert", focus control, color-independent status, prefers-reduced-motion, keyboard reachability, explicit offline states and retries, and more.


1. Introduction: Real-time means balancing “convenience” with “unexpected change”

Real-time UI is helpful, but sudden changes can disorient users.

  • When the screen changes on its own, provide concise guidance about what happened.
  • Don’t forcibly move focus to the changed area (risk of interrupting input).
  • Use aria-live="polite" by default for announcements; use role="alert" only for critical/urgent cases.
  • For disconnects, reconnects, and latency, explain quietly and provide next steps (reload / lightweight mode).

Laravel supports a one-stop flow from server events to broadcasting to client subscriptions. This article compiles design, code, and operations for safe and considerate real-time experiences.


2. Architecture overview: event → channel → delivery → subscription

  1. Fire an application event (e.g., OrderCreated).
  2. Broadcast to a channel (public/private/presence) via a broadcastable event.
  3. Clients subscribe via WebSockets (Pusher/Laravel WebSockets) or SSE/Polling.
  4. The client updates UI (counters, list insertion, toast).

Decision guide:

  • Bidirectional, massive connections, ultra-low latency → WebSockets.
  • One-way, broadcast-centric, simple → SSE.
  • Constrained environments or minimal setup → Polling.
  • In mobile/corporate networks with strict proxies, combining SSE/Polling as fallback is safer.

3. Broadcasting basics: events and channels

3.1 Broadcastable event

// app/Events/OrderCreated.php
namespace App\Events;

use App\Models\Order;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\PrivateChannel; // if authentication is required
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;

class OrderCreated implements ShouldBroadcast
{
    public function __construct(public Order $order) {}

    public function broadcastOn(): Channel
    {
        // Example: user-specific notification channel (authenticated)
        return new PrivateChannel('users.'.$this->order->user_id);
    }

    public function broadcastAs(): string
    {
        return 'order.created';
    }

    public function broadcastWith(): array
    {
        return [
            'id' => $this->order->id,
            'number' => $this->order->number,
            'total' => $this->order->total,
        ];
    }
}

3.2 Channel authorization

// routes/channels.php
Broadcast::channel('users.{id}', function ($user, $id) {
    return (int) $user->id === (int) $id; // Only owner can subscribe
});
  • Private/Presence channels are authorized on the server.
  • Deliver broadcast events via queues to handle peak loads.

4. Client: subscribe with Laravel Echo

4.1 Basic wiring

npm i laravel-echo pusher-js
// resources/js/bootstrap.js (example)
import Echo from 'laravel-echo';
window.Pusher = require('pusher-js');

window.echo = new Echo({
  broadcaster: 'pusher',
  key: import.meta.env.VITE_PUSHER_KEY,
  wsHost: import.meta.env.VITE_PUSHER_HOST,
  wsPort: 6001,
  wssPort: 6001,
  forceTLS: false,
  disableStats: true,
  enabledTransports: ['ws', 'wss'], // adjust if you use fallbacks
  authEndpoint: '/broadcasting/auth', // Private/Presence
});

// Subscription example
window.echo.private(`users.${userId}`)
  .listen('.order.created', (e) => {
     // Update UI
  });

4.2 Laravel WebSockets (self-hosted)

  • Pusher-compatible server you run yourself — great for cost control and data residency.
  • Small to medium scale can run on 1 node; large scale uses horizontal sharding + Redis, etc.

5. SSE (Server-Sent Events): simple one-way streaming

// routes/web.php
Route::get('/stream/orders', function () {
    return response()->stream(function () {
        while (true) {
            if ($event = App\Support\SseBuffer::next()) {
                echo "data: ".json_encode($event)."\n\n";
                ob_flush(); flush();
            }
            usleep(300000);
        }
    }, 200, ['Content-Type' => 'text/event-stream', 'Cache-Control' => 'no-cache']);
})->middleware('auth');
const es = new EventSource('/stream/orders');
es.onmessage = (ev) => {
  const data = JSON.parse(ev.data);
  // Update UI
};
es.onerror = () => { /* disconnected UI, etc. */ };
  • Works nicely over HTTP/1.1 and is often more proxy-friendly.
  • If bidirectionality or Presence is unnecessary, SSE can be enough.

6. Accessible live updates: design principles

  • Don’t move headings or focus automatically. Avoid interrupting user actions.
  • Use aria-live="polite" for non-urgent updates; use role="alert" only for emergencies.
  • Display text along with color changes (e.g., badge counts).
  • For toasts, tune screen-reader priority (usually role="status").
  • Keep motion short and subtle; respect prefers-reduced-motion.

6.1 Minimal toast (screen-reader friendly)

<div id="toast" role="status" aria-live="polite" class="sr-only"></div>

<script>
function announce(msg){ 
  const t = document.getElementById('toast');
  t.textContent = ''; // re-announce identical text
  setTimeout(()=> t.textContent = msg, 50);
}
</script>

6.2 New items badge

<button class="relative" aria-describedby="notify-help" id="bell">
  <span aria-hidden="true">🔔</span>
  <span id="badge" class="absolute -top-1 -right-1 rounded-full bg-red-600 text-white text-xs px-1">0</span>
</button>
<p id="notify-help" class="sr-only">Displays the number of new notifications.</p>

<script>
let count = 0;
function onOrderCreated(){ 
  count++;
  document.getElementById('badge').textContent = count;
  announce(`You have ${count} new orders.`);
}
</script>
  • Pair color (red badge) with numbers and announcements.
  • If clicking navigates to a list, design the next focus target.

7. Typical UIs: chat and notification center

7.1 Chat (Presence channel)

// routes/channels.php
Broadcast::channel('rooms.{roomId}', function ($user, $roomId) {
    return $user->can('join', Room::findOrFail($roomId)) ? ['id'=>$user->id,'name'=>$user->name] : false;
});
window.echo.join(`rooms.${roomId}`) // presence
  .here(users => renderUsers(users))
  .joining(user => addUser(user))
  .leaving(user => removeUser(user))
  .listen('.message.posted', (e) => appendMessage(e));

// Sending messages: usual POST → fire event server-side

Accessibility

  • Announce new messages with brief text in a role="status" region, e.g., “Message from Yamada.”
  • Use role="log" for the message list so readers handle append-only behavior better.
  • Prefer text to sounds for joins/leaves; prevent over-notification.

7.2 Notification center

  • Low priority: numeric badge + list; badge decreases on read.
  • High priority: role="alert" toast + keyboard-leading to details.
  • Manage expiry and de-duplication (upsert by key).

8. Reconnects, offline handling, fallbacks

8.1 Status banner

<div id="conn" role="status" aria-live="polite" class="text-sm text-gray-600">
  Connecting...
</div>

<script>
function setConn(text, cls=''){ const el = document.getElementById('conn'); el.textContent=text; el.className=cls; }

window.addEventListener('offline', ()=> setConn('You are offline. Changes will be saved temporarily.','text-red-700'));
window.addEventListener('online', ()=> setConn('Back online. Syncing your changes.','text-green-700'));
</script>

8.2 Echo reconnect handling

window.echo.connector.pusher.connection.bind('state_change', (states) => {
  if (states.current === 'connecting') setConn('Connecting...');
  if (states.current === 'unavailable' || states.current === 'disconnected') setConn('Connection is unstable. Reconnecting automatically.','text-red-700');
  if (states.current === 'connected') setConn('Connected','text-green-700');
});

8.3 Fallback strategy

  • 1st: WebSockets → 2nd: SSE → 3rd: Polling (15–60s).
  • Keep live updates only on critical screens; elsewhere provide a “Refresh latest” button.
  • Prioritize a design where features still work without live updates.

9. Security, authorization, rate limiting, audit

  • Enforce strict server-side authorization for Private/Presence channels.
  • Keep payloads minimal (exclude sensitive data).
  • Apply Rate Limiting on message/notification creation to prevent abuse.
  • Audit log: who/when/which channel/which event type.
  • Use structured logs so connection IDs and user IDs are traceable.

10. Server ops: Horizon/queues, WebSockets, scale

  • Queue broadcasting to smooth peaks.
  • If using Laravel WebSockets, watch the dashboard for connections/message rates.
  • For many connections, use health checks & autoscaling; serve static assets from CDN/edge.
  • In reverse proxies (Nginx), tune timeouts/headers (for SSE: X-Accel-Buffering: no, etc.).

11. Live job/progress display (dashboard)

11.1 Progress event

class ExportProgress implements ShouldBroadcast {
  public function __construct(public int $userId, public int $percent) {}
  public function broadcastOn(){ return new PrivateChannel("users.{$this->userId}"); }
  public function broadcastAs(){ return 'export.progress'; }
}
window.echo.private(`users.${userId}`)
  .listen('.export.progress', e => updateProgress(e.percent));

11.2 Accessible progress UI

<div aria-labelledby="exp-title">
  <h2 id="exp-title">Export progress</h2>
  <div role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" id="bar"
       aria-describedby="exp-help">0%</div>
  <p id="exp-help" class="sr-only">A download link will appear when complete.</p>
</div>

<script>
function updateProgress(p){
  const bar = document.getElementById('bar');
  bar.setAttribute('aria-valuenow', p);
  bar.textContent = `${p}%`;
  if (p===100) announce('Export complete.');
}
</script>
  • Show numbers for progress; don’t rely on color.
  • On completion, notify with a short role="status" announcement.

12. Testing: Feature, Dusk, a11y smoke

12.1 Feature (event firing)

use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\Facades\Event;

test('OrderCreated broadcast payload', function () {
    Event::fake();
    $order = Order::factory()->create();
    event(new \App\Events\OrderCreated($order));
    Event::assertDispatched(\App\Events\OrderCreated::class);
});

12.2 Dusk (UI reflection and announcements)

public function test_toast_announces_on_message()
{
    $this->browse(function (Browser $b) {
        $b->visit('/dashboard')
          ->script("announce('You have a new order')"); // inject a mock
        $b->assertSeeIn('#toast','You have a new order');
    });
}

12.3 a11y smoke (Pa11y/axe)

  • Toast region present with role="status".
  • role="alert" not duplicated/overused on non-critical screens.
  • No UI that relies solely on a badge color for meaning.

13. Implementation sample (minimal notification counter)

13.1 Routes / event

// routes/channels.php
Broadcast::channel('users.{id}', fn($user,$id) => (int)$user->id === (int)$id);

// app/Events/NotifyUser.php
class NotifyUser implements ShouldBroadcast {
  public function __construct(public int $userId, public string $message){}
  public function broadcastOn(){ return new PrivateChannel("users.{$this->userId}"); }
  public function broadcastAs(){ return 'notify.user'; }
  public function broadcastWith(){ return ['message'=>$this->message]; }
}

13.2 Blade

<button id="bell" aria-describedby="notify-help" class="relative">
  <span aria-hidden="true">🔔</span>
  <span id="badge" class="absolute -top-1 -right-1 bg-red-600 text-white text-xs rounded px-1">0</span>
</button>
<p id="notify-help" class="sr-only">Displays the number of new notifications.</p>
<div id="toast" role="status" aria-live="polite" class="sr-only"></div>

<script type="module">
import Echo from 'laravel-echo';
window.Pusher = (await import('pusher-js')).default;

const echo = new Echo({ broadcaster:'pusher', key:import.meta.env.VITE_PUSHER_KEY, wsHost:location.hostname, wsPort:6001, forceTLS:false, disableStats:true });
let count = 0;

echo.private(`users.${@js(auth()->id())}`).listen('.notify.user', (e) => {
  count++;
  document.getElementById('badge').textContent = count;
  announce(e.message);
});
</script>

14. Common pitfalls and how to avoid them

  • Overusing alert for non-critical updates → Prefer status / polite by default.
  • Forcing focus moves on live updates → Don’t; prioritize user actions.
  • Silent failures/disconnects → Announce connection state briefly and offer retry paths.
  • Weak Private authorization → Be strict in routes/channels.php.
  • Oversized event payloads → Keep minimal; re-fetch by ID if needed.
  • No fallback → Provide SSE/Polling, plus a clear manual refresh button.
  • Badge-only meaning → Include numbers/text.
  • Excessive motion → Respect prefers-reduced-motion; keep it brief.

15. Checklist (for team distribution)

Architecture

  • [ ] Events implement ShouldBroadcast; minimal payload
  • [ ] Authorization in Private/Presence channels
  • [ ] Deliver via queues; monitor with Horizon

Client

  • [ ] Echo wiring and reconnection messaging
  • [ ] SSE/Polling fallbacks
  • [ ] Connection status displayed (role="status")

Accessibility

  • [ ] Notifications use role="status" aria-live="polite"; urgent cases only use alert
  • [ ] Badges include numbers/text; no color-only cues
  • [ ] Avoid forced focus moves and input interruptions
  • [ ] Respect prefers-reduced-motion

Security/Ops

  • [ ] Strict routes/channels authorization
  • [ ] Rate limiting & audit logs
  • [ ] Copy for down/up/retry states
  • [ ] Monitoring (connections/latency/error rates)

Testing

  • [ ] Feature: event firing/authorization
  • [ ] Dusk: toast/badge/announcements
  • [ ] a11y: appropriate status/alert usage

16. Summary

  • Build a robust real-time backbone with Laravel Broadcasting and Echo.
  • Protect with Private/Presence authorization and minimal payloads.
  • Design UI for screen-reader-friendly notifications and color-independent states.
  • Provide plain-language guidance for disconnect/reconnect and offer fallbacks.
  • Keep testing and monitoring to maintain an experience that runs quietly, reliably, and kindly.

References

By greeden

Leave a Reply

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

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