[Guía Práctica de Campo] Experiencias en Tiempo Real en Laravel — Broadcasting/WebSockets/SSE, Notificaciones, Reconexiones, Soporte Offline y Actualizaciones en Vivo Accesibles
Qué aprenderás (puntos destacados)
- Cómo elegir entre Broadcasting (evento → canal → entrega) y WebSockets / SSE
- Arquitectura para Laravel Echo / Laravel WebSockets / Pusher, y canales autenticados (Private/Presence)
- UI en vivo con JavaScript mínimo usando Livewire/Alpine/Blade, y diseño de fallback (Polling/SSE)
- Actualizaciones en vivo accesibles (
aria-live,role="status", gestión del foco, toasts/banners/badges) - Reconexión práctica, manejo offline, limitación de tasa, autorización, auditoría y testing (Feature/Dusk)
- Ejemplos para chat, centro de notificaciones, tableros de progreso y agregación de dashboards
Lectores previstos (¿quién se beneficia?)
- Principiantes e intermedios de Laravel: Construye pantallas dinámicas y en tiempo real (chat, notificaciones, dashboards) de forma segura
- Líderes técnicos/PMs: Compara costes/operaciones de servicios externos (Pusher) vs. autoalojado (Laravel WebSockets)
- QA/Accesibilidad: Estandariza anuncios para lectores de pantalla, interacciones con teclado y notificaciones independientes del color
- CS/Soporte: Mejora el diseño de contenido de notificaciones y progresos para reducir consultas
Nivel de accesibilidad: ★★★★★
Cubrimos implementación para
aria-live/role="status"/role="alert", control del foco, estado independiente del color,prefers-reduced-motion, alcance por teclado, estados offline explícitos y reintentos, y más.
1. Introducción: Tiempo real significa equilibrar “conveniencia” con “cambio inesperado”
La UI en tiempo real es útil, pero los cambios repentinos pueden desorientar a las personas.
- Cuando la pantalla cambia por sí sola, ofrece orientación concisa sobre lo que ocurrió.
- No muevas el foco a la fuerza al área cambiada (riesgo de interrumpir la entrada).
- Usa
aria-live="polite"por defecto para anuncios; usarole="alert"solo para casos críticos/urgentes. - Para desconexiones, reconexiones y latencia, explica discretamente y ofrece siguientes pasos (recargar / modo ligero).
Laravel admite un flujo integral desde eventos del servidor a broadcasting y suscripción del cliente. Este artículo compila diseño, código y operaciones para experiencias en tiempo real seguras y consideradas.
2. Vista general de la arquitectura: evento → canal → entrega → suscripción
- Dispara un evento de aplicación (p. ej.,
OrderCreated). - Retransmite (broadcast) a un canal (público/privado/presence) mediante un evento broadcastable.
- Los clientes se suscriben vía WebSockets (Pusher/Laravel WebSockets) o SSE/Polling.
- El cliente actualiza la UI (contadores, inserción en lista, toast).
Guía de decisión:
- Bidireccionalidad, muchas conexiones, latencia ultra-baja → WebSockets.
- Unidireccional, centrado en difusión, simple → SSE.
- Entornos restringidos o configuración mínima → Polling.
- En redes móviles/corporativas con proxies estrictos, combinar SSE/Polling como fallback es más seguro.
3. Fundamentos de Broadcasting: eventos y canales
3.1 Evento broadcastable
// app/Events/OrderCreated.php
namespace App\Events;
use App\Models\Order;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\PrivateChannel; // si se requiere autenticación
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
class OrderCreated implements ShouldBroadcast
{
public function __construct(public Order $order) {}
public function broadcastOn(): Channel
{
// Ejemplo: canal de notificación específico por usuario (autenticado)
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 Autorización de canal
// routes/channels.php
Broadcast::channel('users.{id}', function ($user, $id) {
return (int) $user->id === (int) $id; // Solo el propietario puede suscribirse
});
- Los canales Private/Presence se autorizan en el servidor.
- Entrega eventos de broadcast mediante colas para manejar picos de carga.
4. Cliente: suscribirse con Laravel Echo
4.1 Cableado básico
npm i laravel-echo pusher-js
// resources/js/bootstrap.js (ejemplo)
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'], // ajusta si usas fallbacks
authEndpoint: '/broadcasting/auth', // Private/Presence
});
// Ejemplo de suscripción
window.echo.private(`users.${userId}`)
.listen('.order.created', (e) => {
// Actualiza la UI
});
4.2 Laravel WebSockets (autoalojado)
- Servidor compatible con Pusher que gestionas tú — ideal para control de costes y residencia de datos.
- Escala pequeña a media puede correr en 1 nodo; escala grande usa sharding horizontal + Redis, etc.
5. SSE (Server-Sent Events): streaming unidireccional sencillo
// 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);
// Actualiza la UI
};
es.onerror = () => { /* UI de desconexión, etc. */ };
- Funciona bien sobre HTTP/1.1 y suele ser más amigable con proxies.
- Si no necesitas bidireccionalidad o Presence, SSE puede ser suficiente.
6. Actualizaciones en vivo accesibles: principios de diseño
- No muevas encabezados ni el foco automáticamente. Evita interrumpir acciones del usuario.
- Usa
aria-live="polite"para actualizaciones no urgentes; usarole="alert"solo para emergencias. - Muestra texto junto con cambios de color (p. ej., conteo en badge).
- Para toasts, ajusta la prioridad del lector de pantalla (normalmente
role="status"). - Mantén la animación corta y sutil; respeta
prefers-reduced-motion.
6.1 Toast mínimo (amigable con lectores de pantalla)
<div id="toast" role="status" aria-live="polite" class="sr-only"></div>
<script>
function announce(msg){
const t = document.getElementById('toast');
t.textContent = ''; // reanunciar el mismo texto
setTimeout(()=> t.textContent = msg, 50);
}
</script>
6.2 Insignia de nuevos elementos
<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">Muestra el número de notificaciones nuevas.</p>
<script>
let count = 0;
function onOrderCreated(){
count++;
document.getElementById('badge').textContent = count;
announce(`Tienes ${count} pedidos nuevos.`);
}
</script>
- Combina el color (badge rojo) con números y anuncios.
- Si al hacer clic se navega a una lista, diseña el siguiente objetivo de foco.
7. UIs típicas: chat y centro de notificaciones
7.1 Chat (canal Presence)
// 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));
// Envío de mensajes: POST habitual → dispara evento en el servidor
Accesibilidad
- Anuncia mensajes nuevos con texto breve en una región
role="status", p. ej., “Mensaje de Yamada”. - Usa
role="log"para la lista de mensajes para que los lectores manejen mejor el comportamiento de solo anexado. - Prefiere texto a sonidos para entradas/salidas; evita sobresaturar.
7.2 Centro de notificaciones
- Baja prioridad: badge numérico + lista; el badge disminuye al leer.
- Alta prioridad: toast con
role="alert"+ recorrido por teclado hacia el detalle. - Gestiona caducidad y desduplicación (upsert por clave).
8. Reconexiones, manejo offline, fallbacks
8.1 Banner de estado
<div id="conn" role="status" aria-live="polite" class="text-sm text-gray-600">
Conectando...
</div>
<script>
function setConn(text, cls=''){ const el = document.getElementById('conn'); el.textContent=text; el.className=cls; }
window.addEventListener('offline', ()=> setConn('Estás sin conexión. Los cambios se guardarán temporalmente.','text-red-700'));
window.addEventListener('online', ()=> setConn('De vuelta en línea. Sincronizando tus cambios.','text-green-700'));
</script>
8.2 Manejo de reconexión en Echo
window.echo.connector.pusher.connection.bind('state_change', (states) => {
if (states.current === 'connecting') setConn('Conectando...');
if (states.current === 'unavailable' || states.current === 'disconnected') setConn('La conexión es inestable. Reconectando automáticamente.','text-red-700');
if (states.current === 'connected') setConn('Conectado','text-green-700');
});
8.3 Estrategia de fallback
- 1º: WebSockets → 2º: SSE → 3º: Polling (15–60 s).
- Mantén actualizaciones en vivo solo en pantallas críticas; en el resto ofrece un botón “Actualizar”.
- Prioriza un diseño donde las funciones sigan funcionando sin actualizaciones en vivo.
9. Seguridad, autorización, limitación de tasa, auditoría
- Aplica autorización estricta en el servidor para canales Private/Presence.
- Mantén payloads mínimos (excluye datos sensibles).
- Aplica Rate Limiting en creación de mensajes/notificaciones para prevenir abuso.
- Registro de auditoría: quién/cuándo/qué canal/qué tipo de evento.
- Usa logs estructurados para que IDs de conexión y de usuario sean rastreados.
10. Operaciones del servidor: Horizon/colas, WebSockets, escalado
- Encola el broadcasting para suavizar picos.
- Si usas Laravel WebSockets, vigila el dashboard para conexiones/tasa de mensajes.
- Con muchas conexiones, usa health checks y autoscaling; sirve assets estáticos desde CDN/borde.
- En proxies inversos (Nginx), ajusta timeouts/cabeceras (para SSE:
X-Accel-Buffering: no, etc.).
11. Trabajo/progreso en vivo (dashboard)
11.1 Evento de progreso
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 UI de progreso accesible
<div aria-labelledby="exp-title">
<h2 id="exp-title">Progreso de la exportación</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">Aparecerá un enlace de descarga al completar.</p>
</div>
<script>
function updateProgress(p){
const bar = document.getElementById('bar');
bar.setAttribute('aria-valuenow', p);
bar.textContent = `${p}%`;
if (p===100) announce('Exportación completa.');
}
</script>
- Muestra números para el progreso; no dependas solo del color.
- Al completar, notifica con un breve anuncio
role="status".
12. Testing: Feature, Dusk, fumadas de a11y
12.1 Feature (disparo de evento)
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\Facades\Event;
test('payload de OrderCreated broadcast', function () {
Event::fake();
$order = Order::factory()->create();
event(new \App\Events\OrderCreated($order));
Event::assertDispatched(\App\Events\OrderCreated::class);
});
12.2 Dusk (reflejo en UI y anuncios)
public function test_toast_announces_on_message()
{
$this->browse(function (Browser $b) {
$b->visit('/dashboard')
->script("announce('Tienes un nuevo pedido')"); // inyecta un mock
$b->assertSeeIn('#toast','Tienes un nuevo pedido');
});
}
12.3 Fumadas de a11y (Pa11y/axe)
- Región de toast presente con
role="status". role="alert"no duplicado/sobreutilizado en pantallas no críticas.- No hay UI que dependa solo de un color en el badge para el significado.
13. Ejemplo de implementación (contador mínimo de notificaciones)
13.1 Rutas / evento
// 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">Muestra el número de notificaciones nuevas.</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. Errores comunes y cómo evitarlos
- Usar en exceso
alertpara actualizaciones no críticas → Prefierestatus/politepor defecto. - Forzar movimientos de foco en actualizaciones en vivo → No lo hagas; prioriza acciones del usuario.
- Fallos/silencios de desconexión → Anuncia el estado de conexión brevemente y ofrece rutas de reintento.
- Autorización débil en Private → Sé estricto en
routes/channels.php. - Payloads de evento demasiado grandes → Mantenlos mínimos; vuelve a obtener por ID si es necesario.
- Sin fallback → Proporciona SSE/Polling, además de un claro botón de actualización manual.
- Significado solo por badge → Incluye números/texto.
- Demasiada animación → Respeta
prefers-reduced-motion; mantenla breve.
15. Checklist (para distribuir al equipo)
Arquitectura
- [ ] Los eventos implementan
ShouldBroadcast; payload mínimo - [ ] Autorización en canales Private/Presence
- [ ] Entrega vía colas; monitoriza con Horizon
Cliente
- [ ] Cableado de Echo y mensajes de reconexión
- [ ] Fallbacks SSE/Polling
- [ ] Estado de conexión visible (
role="status")
Accesibilidad
- [ ] Notificaciones usan
role="status" aria-live="polite"; casos urgentes solo usanalert - [ ] Badges con números/texto; sin claves solo por color
- [ ] Evita mover el foco a la fuerza e interrumpir entradas
- [ ] Respeta
prefers-reduced-motion
Seguridad/Ops
- [ ] Autorización estricta en
routes/channels - [ ] Rate limiting y logs de auditoría
- [ ] Copys para estados abajo/arriba/reintento
- [ ] Monitorización (conexiones/latencia/tasas de error)
Testing
- [ ] Feature: disparo de eventos/autorización
- [ ] Dusk: toast/badge/anuncios
- [ ] a11y: uso adecuado de
status/alert
16. Resumen
- Construye una columna vertebral en tiempo real robusta con Laravel Broadcasting y Echo.
- Protege con autorización Private/Presence y payloads mínimos.
- Diseña la UI para notificaciones amigables con lectores de pantalla y estados independientes del color.
- Proporciona orientación en lenguaje llano para desconexión/reconexión y ofrece fallbacks.
- Mantén pruebas y monitorización para sostener una experiencia que funcione silenciosa, confiable y amablemente.
Referencias
- Laravel (oficial)
- Ecosistema
- Plataforma web
- Práctica de accesibilidad
