Construcción de un sistema reactivo de notificaciones con Laravel y WebSocket: arquitectura, colas y escalabilidad
Introducción: por qué las notificaciones en tiempo real requieren una arquitectura propia
Las notificaciones en las aplicaciones web modernas se dividen en varios tipos fundamentalmente distintos: in-app (contador de campana), email, push (navegador y móvil) y SMS. Cada uno tiene diferentes requisitos de velocidad de entrega, fiabilidad y canal de transmisión.
La pregunta clave es: ¿se puede prescindir del polling, es decir, de consultar el servidor periódicamente cada N segundos? Técnicamente sí, pero el coste es evidente: la carga sobre la base de datos crece de forma lineal con el número de usuarios, la latencia de entrega es inaceptable para chats y alertas, y el escalado se convierte en un problema. Por eso las notificaciones en tiempo real requieren una rama arquitectónica propia: una conexión persistente (WebSocket o SSE) más un pipeline asíncrono de procesamiento en el servidor.
En este artículo veremos cómo construir dicho sistema sobre Laravel, Redis y Laravel Reverb, desde el modelo de datos hasta el escalado horizontal en producción.
Visión general del stack: cómo las piezas forman el sistema
El stack se compone de varios niveles, cada uno con su responsabilidad:
- Laravel Notifications: API de alto nivel para enviar notificaciones a través de distintos canales (mail, database, broadcast, Slack, etc.).
- Laravel Broadcasting: mecanismo de publicación de eventos en canales WebSocket; funciona sobre los drivers Pusher, Ably o un servidor propio.
- Redis: doble función: cola de tareas (driver
redis) y transporte Pub/Sub entre la aplicación y el servidor WebSocket. - Laravel Reverb: servidor WebSocket nativo de primera parte, presentado en Laravel 11; sustituye a Soketi y otras soluciones de terceros.
- Queue Workers: procesan los canales pesados (email, SMS) de forma asíncrona, sin bloquear la petición HTTP.
El flujo de datos es el siguiente: petición HTTP o evento de dominio → Laravel crea la notificación → la escribe en la BD y la envía a la cola → el worker procesa el canal → Redis Pub/Sub → Reverb → WebSocket → navegador.
Diseño del modelo de notificaciones
La tabla notifications estándar que genera Laravel es minimalista. Para un sistema en producción conviene ampliarla.
-- Migración de la tabla de notificaciones extendida
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(255) NOT NULL, -- clase de notificación
notifiable_type VARCHAR(255) NOT NULL,
notifiable_id BIGINT UNSIGNED NOT NULL,
data JSON NOT NULL,
status ENUM('pending','sent','read','archived') DEFAULT 'pending',
priority TINYINT DEFAULT 0, -- 0=normal, 1=high, 2=critical
group_key VARCHAR(128) NULL, -- para agrupar similares
channel VARCHAR(64) NOT NULL DEFAULT 'database',
read_at TIMESTAMP NULL,
expires_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_notifiable (notifiable_type, notifiable_id, status),
INDEX idx_group (group_key),
INDEX idx_priority (priority, created_at)
);
El campo group_key permite agrupar notificaciones del mismo tipo: en lugar de "10 nuevos comentarios", el usuario ve una sola tarjeta con el recuento. El campo priority controla el orden de procesamiento en la cola: las notificaciones críticas se envían a una cola de alta prioridad independiente.
El modelo en Laravel:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Notifications\DatabaseNotification;
class Notification extends DatabaseNotification
{
use HasUuids;
protected $casts = [
'data' => 'array',
'read_at' => 'datetime',
'expires_at' => 'datetime',
];
public function scopeUnread($query)
{
return $query->whereNull('read_at')->where('status', '!=', 'archived');
}
public function scopeHighPriority($query)
{
return $query->where('priority', '>=', 1)->orderByDesc('priority');
}
public function markAsRead(): void
{
$this->update(['read_at' => now(), 'status' => 'read']);
}
}
Laravel Broadcasting y canales: private, presence, autenticación
El broadcasting en Laravel se basa en el concepto de canales. Existen tres tipos:
- Public: cualquiera puede suscribirse; adecuado para anuncios globales.
- Private: requiere autenticación del usuario; es el estándar para notificaciones personales.
- Presence: extensión de private; el servidor sabe quién está conectado en cada momento; se utiliza para "X usuarios están leyendo".
Ejemplo de notificación con soporte de broadcast:
<?php
namespace App\Notifications;
use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\BroadcastMessage;
use Illuminate\Notifications\Notification;
class OrderStatusChanged extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(private Order $order) {}
public function via(object $notifiable): array
{
return ['database', 'broadcast'];
}
public function toDatabase(object $notifiable): array
{
return [
'order_id' => $this->order->id,
'status' => $this->order->status,
'message' => "El pedido #{$this->order->number} cambió su estado a {$this->order->status}",
];
}
public function toBroadcast(object $notifiable): BroadcastMessage
{
return new BroadcastMessage([
'id' => $this->id,
'type' => 'order.status_changed',
'payload' => $this->toDatabase($notifiable),
]);
}
public function broadcastOn(): array
{
return [new \Illuminate\Broadcasting\PrivateChannel(
'users.' . $this->notifiable->id
)];
}
// Nombre personalizado del evento en el cliente
public function broadcastAs(): string
{
return 'notification.new';
}
}
La autorización de los canales privados se configura en routes/channels.php:
<?php
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('users.{userId}', function ($user, $userId) {
return (int) $user->id === (int) $userId;
});
// Canal presence para el equipo
Broadcast::channel('team.{teamId}', function ($user, $teamId) {
if ($user->teams->contains($teamId)) {
return ['id' => $user->id, 'name' => $user->name];
}
});
Redis como transporte: Pub/Sub entre la aplicación y el servidor WebSocket
Cuando Laravel publica un evento en un canal de broadcast, lo serializa en JSON y lo publica en un canal de Redis mediante PUBLISH. El servidor WebSocket está suscrito a ese canal mediante SUBSCRIBE y reenvía el mensaje a todos los clientes conectados al canal correspondiente.
Este es el patrón clásico de Redis Pub/Sub, y es fundamental para el escalado: varias instancias de la aplicación pueden publicar mensajes de forma independiente, mientras el servidor WebSocket actúa como punto único de entrega.
Configuración en config/broadcasting.php:
<?php
return [
'default' => env('BROADCAST_DRIVER', 'reverb'),
'connections' => [
'reverb' => [
'driver' => 'reverb',
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST', '0.0.0.0'),
'port' => env('REVERB_PORT', 8080),
'scheme' => env('REVERB_SCHEME', 'http'),
],
],
],
];
Para Redis como transporte de colas en config/queue.php:
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => null,
'after_commit' => true, // importante: se envía tras el commit exitoso de la transacción
],
Integración con Laravel Reverb: configuración y puesta en marcha
Laravel Reverb es un servidor WebSocket nativo escrito en PHP sobre ReactPHP. Apareció en Laravel 11 y resuelve el principal problema: la dependencia de Pusher o la necesidad de mantener un servicio Node.js separado.
Instalación:
composer require laravel/reverb
php artisan reverb:install
Variables clave en .env:
BROADCAST_DRIVER=reverb
REVERB_APP_ID=my-app
REVERB_APP_KEY=my-key
REVERB_APP_SECRET=my-secret
REVERB_HOST=0.0.0.0
REVERB_PORT=8080
REVERB_SCHEME=http
# Para escalado horizontal
REVERB_SCALING_ENABLED=true
REVERB_SCALING_CHANNEL=reverb
Inicio en producción mediante Supervisor:
[program:reverb]
command=php /var/www/app/artisan reverb:start --host=0.0.0.0 --port=8080
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/supervisor/reverb.log
Con el escalado horizontal, Reverb utiliza Redis para sincronizar el estado entre varias instancias del servidor. Esto se activa mediante scaling en config/reverb.php:
'scaling' => [
'enabled' => env('REVERB_SCALING_ENABLED', false),
'driver' => 'redis',
'redis' => [
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
],
],
Envío asíncrono: colas, batching y canales pesados
El email y el SMS son operaciones costosas: llamadas a APIs externas, posibles timeouts y rate limits. Enviarlos de forma síncrona dentro de una petición HTTP es un antipatrón. Laravel lo resuelve mediante la interfaz ShouldQueue.
Estrategia de separación de colas por prioridad:
// Inicio de workers para distintas colas
// Alta prioridad (in-app, broadcast) — workers rápidos
php artisan queue:work redis --queue=notifications-critical,notifications-high,default
// Canales lentos (email, SMS) — pool separado
php artisan queue:work redis --queue=notifications-email,notifications-sms --timeout=60
El batching de notificaciones con Bus::batch() permite hacer seguimiento del envío grupal:
<?php
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Notification;
$users = User::whereIn('id', $targetIds)->cursor();
$jobs = collect();
foreach ($users as $user) {
$jobs->push(new SendNotificationJob($user, new CampaignNotification($campaign)));
}
Bus::batch($jobs)
->then(function (Batch $batch) use ($campaign) {
$campaign->markAsDelivered();
})
->catch(function (Batch $batch, \Throwable $e) {
Log::error('Batch notification failed', ['error' => $e->getMessage()]);
})
->onQueue('notifications-email')
->dispatch();
Escalabilidad: workers, múltiples instancias, enfoque stateless
A medida que crece la carga, el sistema debe escalar horizontalmente. Algunos principios clave:
- Aplicación stateless: la aplicación Laravel no almacena el estado de las conexiones WebSocket; esa es la responsabilidad de Reverb. La aplicación solo publica eventos en Redis.
- Múltiples instancias de Reverb: con el scaling activado mediante Redis, todas las instancias sincronizan el estado de los canales. El balanceador (Nginx/HAProxy) distribuye las conexiones WebSocket; aquí no se necesitan sticky sessions, ya que Reverb se sincroniza por sí mismo a través de Redis Pub/Sub.
- Escalado de workers: añade workers horizontalmente. Usa
--max-jobsy--max-timepara prevenir fugas de memoria.
Ejemplo de configuración de Nginx para el proxy WebSocket:
upstream reverb_servers {
# stateless — sin sticky sessions gracias al scaling con Redis
least_conn;
server reverb-1:8080;
server reverb-2:8080;
server reverb-3:8080;
}
server {
listen 443 ssl;
server_name ws.example.com;
location / {
proxy_pass http://reverb_servers;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
En entornos de contenedores (Kubernetes / Docker Swarm), el escalado horizontal con un enfoque de microservicios resulta natural: los pods de Reverb escalan de forma independiente a los pods de PHP-FPM, y Redis actúa como bus compartido.
Fiabilidad: reintentos, dead letter queue y monitorización
Un sistema de notificaciones fiable debe sobrevivir a fallos de servicios externos. Algunas prácticas recomendadas:
Reintentos con backoff exponencial
<?php
namespace App\Notifications;
use Illuminate\Notifications\Notification;
class EmailAlertNotification extends Notification
{
public int $tries = 5;
public int $maxExceptions = 3;
public function backoff(): array
{
// 1 min → 5 min → 15 min → 30 min → 60 min
return [60, 300, 900, 1800, 3600];
}
public function retryUntil(): \DateTime
{
return now()->addHours(6);
}
}
Dead Letter Queue
Los mensajes que agotan todos los reintentos se registran en la tabla failed_jobs. Configura la monitorización de esta tabla:
// En AppServiceProvider o en un servicio de monitorización independiente
Schedule::command('queue:failed-jobs-check')
->everyFiveMinutes()
->withoutOverlapping();
// Comando personalizado
public function handle(): void
{
$failedCount = DB::table('failed_jobs')
->where('failed_at', '>=', now()->subHour())
->count();
if ($failedCount > 10) {
// Alerta en Slack/PagerDuty
Notification::route('slack', config('alerts.slack_webhook'))
->notify(new QueueHealthAlert($failedCount));
}
}
Monitorización con Laravel Horizon
Laravel Horizon es una herramienta imprescindible para sistemas en producción con colas en Redis. Ofrece un panel visual con métricas de throughput, errores y tiempos de procesamiento.
// config/horizon.php — separación de workers por colas
'environments' => [
'production' => [
'supervisor-notifications-realtime' => [
'connection' => 'redis',
'queue' => ['notifications-critical', 'notifications-high'],
'balance' => 'auto',
'processes' => 8,
'tries' => 3,
],
'supervisor-notifications-async' => [
'connection' => 'redis',
'queue' => ['notifications-email', 'notifications-sms'],
'balance' => 'simple',
'processes' => 4,
'tries' => 5,
'timeout' => 120,
],
],
],
Conclusión: checklist de un sistema de notificaciones listo para producción
Antes de lanzar el sistema a producción, repasa esta lista:
- Modelo de datos: ampliado con los campos
status,priority,group_keyyexpires_at; índices creados. - Canales autenticados: los canales privados están protegidos mediante
Broadcast::channel(). - Broadcast implementado: la notificación implementa
toBroadcast()ybroadcastOn(). - Todos los canales son asíncronos: la notificación implementa
ShouldQueue. - Colas separadas por prioridad: la cola en tiempo real no queda bloqueada por tareas pesadas de email.
- Laravel Reverb configurado con Redis scaling para el escalado horizontal.
- Nginx/balanceador hace proxy correcto de las conexiones WebSocket (cabeceras Upgrade).
- Reintentos y backoff configurados para cada canal.
- Dead letter queue monitorizada automáticamente con alertas.
- Laravel Horizon en ejecución bajo Supervisor con grupos de supervisores separados.
- Pruebas de carga realizadas: el servidor WebSocket soporta el número objetivo de conexiones simultáneas.
- Política de expiración de notificaciones implementada: los registros antiguos se archivan o eliminan.
Un sistema reactivo de notificaciones no es una simple funcionalidad, sino un subsistema completo con su propia arquitectura, tolerancia a fallos y requisitos operativos. La inversión en unos cimientos sólidos se amortiza cuando la carga se multiplica por diez.
Al combinar Laravel, Redis y Reverb, obtienes un stack listo para producción que cubre la mayoría de los escenarios, desde un SaaS pequeño hasta una plataforma de alta carga. La clave está en la correcta separación de responsabilidades: la aplicación publica, Redis transporta y Reverb entrega.
Tecnologías
Etiquetas
Ruslan Ismailov
Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →