Arquitectura

Construcción de un sistema reactivo de notificaciones con Laravel y WebSocket: arquitectura, colas y escalabilidad

Ruslan Ismailov Publicado 14 min de lectura
C

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-jobs y --max-time para 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:

  1. Modelo de datos: ampliado con los campos status, priority, group_key y expires_at; índices creados.
  2. Canales autenticados: los canales privados están protegidos mediante Broadcast::channel().
  3. Broadcast implementado: la notificación implementa toBroadcast() y broadcastOn().
  4. Todos los canales son asíncronos: la notificación implementa ShouldQueue.
  5. Colas separadas por prioridad: la cola en tiempo real no queda bloqueada por tareas pesadas de email.
  6. Laravel Reverb configurado con Redis scaling para el escalado horizontal.
  7. Nginx/balanceador hace proxy correcto de las conexiones WebSocket (cabeceras Upgrade).
  8. Reintentos y backoff configurados para cada canal.
  9. Dead letter queue monitorizada automáticamente con alertas.
  10. Laravel Horizon en ejecución bajo Supervisor con grupos de supervisores separados.
  11. Pruebas de carga realizadas: el servidor WebSocket soporta el número objetivo de conexiones simultáneas.
  12. 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í →