Introducción a Server-Sent Events (SSE) en Laravel: Notificaciones en Tiempo Real

- Andrés Cruz - EN In english

Video thumbnail

Los Server-Sent Events (SSE) son una tecnología basada en el protocolo HTTP estándar que permite al servidor enviar actualizaciones en tiempo real hacia el cliente a través de una conexión abierta. A diferencia de los WebSockets, no requiere un protocolo específico ni un servidor independiente de mensajería (como Pusher o Reverb); se puede utilizar directamente en la infraestructura existente.

En esta arquitectura, el cliente se construye con JavaScript (por ejemplo, utilizando la reactividad de Vue) y el backend se encarga de emitir el flujo de eventos (usando frameworks como Laravel, Django, FastAPI, Express o cualquier entorno en Node.js).

Ventajas y Limitaciones de SSE

Para determinar cuándo implementar SSE en lugar de WebSockets, es necesario considerar sus características principales:

Ventajas

  • Protocolo estándar: Funciona directamente sobre HTTP/HTTPS sin configuraciones especiales en el servidor.
  • Reconexión automática: Los navegadores modernos gestionan la reconexión de forma nativa si la señal se interrumpe.
  • Simplicidad de desarrollo: Formateo y procesamiento sencillo de datos (habitualmente en formato JSON).

Limitaciones

  • Unidireccionalidad: La comunicación fluye exclusivamente del servidor al cliente. Si el cliente necesita enviar datos, debe realizar una petición HTTP tradicional (POST, PUT, etc.).
  • Límite de conexiones: Bajo HTTP/1.1, los navegadores limitan las conexiones concurrentes a un máximo de 6 por dominio.
  • Frecuencia de emisión: No está diseñado para aplicaciones de ultra alta frecuencia (como videojuegos en tiempo real), siendo ideal para intervalos de un segundo o superiores.

Casos de Uso e Implementación Eficiente

SSE es idóneo para paneles de notificaciones, barras de progreso para tareas pesadas en segundo plano o sistemas de actualizaciones en vivo. Dado el límite de conexiones concurrentes en servidores compartidos o bajo HTTP/1.1, se recomienda gestionar la apertura y cierre del flujo de forma consciente:

  • Conexión bajo demanda: Abrir la conexión solo cuando el usuario interactúe con el módulo (por ejemplo, al abrir el menú de notificaciones o la interfaz de un chat).
  • Sondeo por intervalos (Polling distribuido): Activar la conexión durante lapsos breves cada cierto tiempo (por ejemplo, cada 5 o 10 minutos) para verificar si hay nuevos eventos y cerrarla inmediatamente después.

Mecanismo de Salida: Buffers y Flujo de Datos en PHP

Para transmitir datos de forma progresiva sin esperar a que finalice la ejecución total del script, es necesario manipular el sistema de almacenamiento en búfer de PHP y del servidor web mediante ob_flush() y flush().

El flujo técnico de transmisión de la salida opera en dos capas:

  1. ob_flush() (Nivel de Aplicación): Vacía el búfer de salida propio del interprete de PHP.
  2. flush() (Nivel de Servidor): Fuerza el envío de los datos almacenados en el búfer del servidor web hacia el navegador del cliente.

Sin la llamada secuencial a estas dos funciones, el servidor retendría todos los mensajes emitidos por los comandos echo y los entregaría en un único bloque al finalizar el bucle, perdiendo el comportamiento en tiempo real.

Estructura del Backend y Control de Conexión

Al implementar la respuesta en streaming (por ejemplo, utilizando response()->stream() en Laravel), se debe verificar constantemente el estado de la conexión para liberar recursos en caso de que el cliente cierre la pestaña o pierda la señal.

El siguiente flujo ejemplifica la estructura de emisión de eventos:

  • Verificación de desconexión: Se evalúa la función connection_aborted() antes de cada iteración. Si el cliente se ha desconectado, se interrumpe la ejecución del bucle.
  • Formato de mensaje SSE: La especificación SSE requiere la estructura básica event: nombre\ndata: { ... }\n\n.
  • Notificación de cierre: Al finalizar el proceso pesado o el bucle de datos, se envía un evento personalizado (por ejemplo, event: close) para avisar al cliente que la transmisión ha terminado.
  • Cabeceras HTTP requeridas: La respuesta debe incluir las cabeceras Content-Type: text/event-stream, Cache-Control: no-cache y Connection: keep-alive.

Recepción del Flujo en el Cliente (JavaScript / Vue)

En el navegador no se requieren librerías adicionales, ya que se utiliza la API nativa EventSource para consumir el endpoint:

  1. Instanciación: Se crea una instancia de EventSource('/api/v1/events') apuntando a la ruta de la API.
  2. Escucha de eventos personalizados: Se registran escuchadores mediante addEventListener('message', ...) para procesar la información entrante y agregarla a las estructuras de datos reactivas de la interfaz.
  3. Gestión de cierre y errores: Se escucha el evento de cierre configurado por el servidor o el evento de error (onerror) para invocar la función de desconexión (eventSource.close()) y restablecer el estado de la interfaz.

1. ¿Qué es Server-Sent Events?

Server-Sent Events (SSE) es una tecnología que permite al servidor de Laravel enviar actualizaciones en tiempo real a la aplicación Vue a través de una sola conexión HTTP abierta.

Ventajas de SSE

  • • Funciona sobre HTTP estándar
  • • No requiere servidor extra (Pusher, Reverb…)
  • • Reconexión automática integrada en el navegador
  • • Parseo automático del formato de datos
  • • Compatible con todos los navegadores modernos

Limitaciones

  • • Unidireccional (solo servidor → cliente)
  • • Máximo 6 conexiones por dominio (HTTP/1.1)
  • • No ideal para alta frecuencia (<1s entre eventos)
  • • Blocking de worker PHP durante el stream

SSE vs WebSockets

CaracterísticaSSEWebSockets
DirecciónUnidireccionalBidireccional
ProtocoloHTTP/1.1 o HTTP/2ws:// o wss://
ReconexiónAutomática (EventSource)Manual (implementar)
InfraestructuraHTTP estándarPusher, Reverb, Socket.io…
ComplejidadBajaMedia-Alta

2. Arquitectura del flujo

┌────────────┐       GET /api/v1/events        ┌────────────────┐
│            │ ───────────────────────────────▶ │                │
│            │                                  │    Laravel     │
│   Vue 3    │   HTTP 200                       │  (PHP Worker)  │
│  Browser   │ ◀── Content-Type: text/event-... │                │
│            │                                  │                │
│            │   data: {"msg":"Notif #1"}\n\n   │  sleep(2) × 10 │
│            │   data: {"msg":"Notif #2"}\n\n   │                │
│  EventSource  data: {"msg":"Notif #3"}\n\n   │  → event:      │
│  .onmessage   ...                            │    closed       │
│            │   event: closed\n\n               │                │
│            │ ──── connection.close() ──────▶ │  flush()       │
└────────────┘                                  └────────────────┘
  1. El cliente (Vue) abre una conexión HTTP normal hacia /api/v1/events.
  2. Laravel no cierra la respuesta: establece la cabecera Content-Type: text/event-stream.
  3. En un bucle, envía datos formateados como data: {JSON}\n\n.
  4. El EventSource del navegador recibe cada evento y ejecuta onmessage.
  5. Al terminar, el servidor envía un evento event: closed y cierra la conexión.
  6. El cliente llama EventSource.close() para limpiar el socket.

3. Backend — Controlador Laravel

Archivo: app/Http/Controllers/Api/SSEController.php

SSEController.php
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;

class SSEController extends Controller
{
    public function stream(): StreamedResponse
    {
        return response()->stream(function () {
            // Desactivar el buffering de PHP para enviar datos al instante
            if (ob_get_level() > 0) {
                ob_end_flush();
            }
            flush();

            // Simulamos envío de 10 notificaciones
            for ($i = 1; $i <= 10; $i++) {
                if (connection_aborted()) {
                    break;
                }

                echo $this->formatEvent($i);
                flush();
                sleep(2);
            }

            // Avisamos al cliente para que cierre la conexión limpiamente
            echo $this->formatClosedEvent();
            flush();
        }, 200, $this->sseHeaders());
    }

    /**
     * Formato obligatorio de SSE: data: {JSON}\n\n
     */
    public function formatEvent(int $iteration): string
    {
        $data = json_encode([
            'message' => "Notificación #{$iteration}",
            'time'    => now()->toTimeString(),
        ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);

        return "data: {$data}\n\n";
    }

    /**
     * Evento final para cerrar la conexión sin reconexión automática.
     */
    public function formatClosedEvent(): string
    {
        return "event: closed\ndata: {\"message\":\"Stream finalizado\"}\n\n";
    }

    /**
     * Cabeceras SSE: no-cache, keep-alive, Nginx buffering off.
     */
    public function sseHeaders(): array
    {
        return [
            'Cache-Control'         => 'no-cache',
            'Content-Type'          => 'text/event-stream; charset=utf-8',
            'Connection'            => 'keep-alive',
            'X-Accel-Buffering'     => 'no',
        ];
    }
}

Puntos clave del controlador

1

response()->stream() — Devuelve un StreamedResponse en vez de un JSON normal. El callback se ejecuta línea por línea, sin cerrar el socket HTTP.

2

ob_end_flush() — Desactiva el buffer de PHP para que cada echo llegue al navegador inmediatamente, no al final del script.

3

connection_aborted() — Si el usuario cierra la pestaña, el worker PHP se detiene sin consumir recursos innecesariamente.

4

X-Accel-Buffering: no — Obligatorio cuando se usa Nginx (Laravel Herd usa Nginx internamente). Sin esto, Nginx almacena la respuesta completa antes de enviarla.

5

event: closed — Evento con nombre (custom) que permite al cliente saber cuándo el stream terminó y cerrar la conexión antes de que el servidor la cierre, evitando el bucle de reconexión innecesario.

4. Backend — Ruta

Archivo: routes/api.php

routes/api.php
<?php

use App\Http\Controllers\Api\SSEController;

// Server-Sent Events: flujo unidireccional de notificaciones en tiempo real
Route::get('/v1/events', [SSEController::class, 'stream']);

Laravel prefixa automáticamente /api a todas las rutas en routes/api.php, por lo que la URL final es GET /api/v1/events. Se puede comprobar con php artisan route:list --path=events.

Nota sobre conexiones

La ruta no usa middleware auth:sanctum para poder probarla fácilmente con un navegador. En producción, añadir autenticación para proteger el endpoint. El EventSource del navegador envía cookies de sesión automáticamente si la ruta está en el mismo dominio.

5. Frontend — Componente Vue 3

Archivo: resources/js/vue/componets/SSEComponent.vue

SSEComponent.vue
<template>
  <div>
    <div>
      <h1>
        Notificaciones en vivo (SSE)
      </h1>

      <div>
        <o-button
          v-if="!isConnected"
          iconLeft="play"
          variant="success"
          @click="connect"
        >Conectar</o-button>

        <o-button
          v-else
          iconLeft="stop"
          variant="danger"
          @click="disconnect"
        >Desconectar</o-button>
      </div>

      <p v-if="isConnected">
        Conectado al stream...
      </p>

      <ul v-if="messages.length > 0">
        <li
          v-for="(msg, index) in messages"
          :key="index"
        >
          <strong>[{{ msg.time }}]:</strong> {{ msg.message }}
        </li>
      </ul>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      messages: [],
      eventSource: null,
      isConnected: false,
    }
  },

  beforeUnmount() {
    this.disconnect()
  },

  methods: {
    connect() {
      // EventSource gestiona la conexión y reconexión automática
      this.eventSource = new EventSource('/api/v1/events')

      this.eventSource.addEventListener('open', () => {
        this.isConnected = true
      })

      // Se ejecuta por cada data: {JSON}\n\n
      this.eventSource.addEventListener('message', (event) => {
        this.messages.push(JSON.parse(event.data))
      })

      // Escuchar evento custom "closed" del servidor
      this.eventSource.addEventListener('closed', () => {
        this.disconnect()
      })

      this.eventSource.addEventListener('error', () => {
        if (this.eventSource.readyState === 2) {
          this.isConnected = false
        }
      })
    },

    disconnect() {
      if (this.eventSource) {
        this.eventSource.close()
        this.eventSource = null
        this.isConnected = false
      }
    },
  },
}
</script>

Puntos clave del componente

1

new EventSource() — API nativa del navegador. No requiere librerías externas. Gestiona la conexión HTTP y el parseo del formato data: ... automáticamente.

2

Reconexión automática — Si la red se cae, EventSource reintenta la conexión solo. Al recibir event: closed, se cierra manualmente antes.

3

beforeUnmount() — Cierra el socket cuando el componente se destruye, evitando memory leaks.

4

readyState === 2 — Equivale a EventSource.CLOSED. Solo actualizamos isConnected cuando la conexión realmente se cerró (2), no cuando está reconectando (0).

6. Router y navegación

Ruta agregada en router.js

import SSE from "./componets/SSEComponent.vue";

// Agregado a la lista de rutas:
{
  name: 'sse',
  path:  '/vue/sse',
  component: SSE
}

Enlace en App.vue

<router-link
  :to="{ name: 'sse' }"
>
  SSE
</router-link>

El enlace está disponible para todos los usuarios (sin condición v-if="$root.isLoggedIn").

7. Tests (Pest PHP)

Archivo: tests/Feature/SSEControllerTest.php

SSEControllerTest.php
<?php

use App\Http\Controllers\Api\SSEController;

describe('SSEController', function () {
    describe('stream', function () {

        it('returns an SSE response with the correct headers', function () {
            $response = $this->get('/api/v1/events');

            $response->assertOk();
            expect($response->headers->get('Cache-Control'))
                ->toContain('no-cache');
            $response->assertHeader(
                'Content-Type',
                'text/event-stream; charset=utf-8'
            );
            $response->assertHeader('Connection', 'keep-alive');
            $response->assertHeader('X-Accel-Buffering', 'no');
        });

        it('streams events in the SSE format', function () {
            $controller = new SSEController;

            $event = $controller->formatEvent(1);

            expect($event)
                ->toMatch('/^data: \{"message":"Notificación #1",/')
                ->toEndWith("\n\n");
        });

        it('encodes JSON without escaping accents', function () {
            $controller = new SSEController;

            $json = trim(substr(
                $controller->formatEvent(2),
                strlen('data: ')
            ));

            expect(json_decode($json, true))
                ->toMatchArray([
                    'message' => 'Notificación #2',
                ]);
        });

        it('formats the closing event with the closed name', function () {
            $controller = new SSEController;

            expect($controller->formatClosedEvent())
                ->toStartWith('event: closed')
                ->toContain('"message":"Stream finalizado"');
        });
    });
});

Ejecutar los tests:

php artisan test --compact --filter=SSEControllerTest

8. Verificación con curl

Para comprobar el stream desde la terminal:

terminal
# Ver los primeros eventos (~6 segundos)
curl -sN --max-time 6 http://larafirststep.test/api/v1/events

# Salida esperada:
data: {"message":"Notificación #1","time":"13:18:42"}

data: {"message":"Notificación #2","time":"13:18:44"}


# Ver los 10 eventos completos + el cierre (~24 segundos)
curl -sN --max-time 24 http://larafirststep.test/api/v1/events | tail -3

# Últimas líneas:
event: closed
data: {"message":"Stream finalizado"}

El flag -N

El flag -N (equivalente a --no-buffer) le dice a curl que muestre cada línea a medida que llega, sin esperar al buffer completo del servidor.

9. Notas para producción

Worker blocking

Durante el stream, el worker PHP queda bloqueado (ocupado) durante todo el ciclo (sleep(2) × 10 = 20s). En producción con pocos workers (FPM), esto puede saturar el servidor.

Soluciones:

  • • Usar Swoole o Octane (servidor async)
  • • Publicar eventos vía Redis Pub/Sub con Redis::subscribe()
  • • Usar Queues para escribir a un archivo/Redis y el stream lo lee en tiempo real

Rate limiting

Las conexiones SSE duran mucho más que una petición normal. Añadir throttle o limitar por IP/usuario para evitar abusos.

Autenticación

El EventSource envía cookies automáticamente si la ruta está en el mismo dominio. Para APIs externas, usar tokens en la URL o en un header personalizado (requiere configurar withCredentials: true).

Escalabilidad

Si necesitas broadcasting a miles de clientes simultáneos, considera Laravel Reverb con WebSockets o servicios como Pusher. SSE es ideal para dashboards internos, notificaciones de admin o demos donde el volumen concurrente es moderado.

10. Archivos creados o modificados

git status --short
 M  resources/js/vue/App.vue           ← enlace "SSE" en el nav
 M  resources/js/vue/router.js          ← ruta /vue/sse
 M  routes/api.php                      ← GET /api/v1/events
??  app/Http/Controllers/Api/SSEController.php  ← controlador SSE
??  resources/js/vue/componets/SSEComponent.vue ← componente Vue
??  tests/Feature/SSEControllerTest.php         ← tests Pest (4)

Pasos para probar

  1. Asegurarse de que Vite esté corriendo: npm run dev
  2. Abrir la app en el navegador: http://larafirststep.test/vue/sse
  3. Hacer clic en Conectar
  4. Ver las notificaciones llegando cada 2 segundos
  5. Al finalizar (evento closed), la conexión se cierra automáticamente

Transmite datos en tiempo real desde Laravel a Vue 3 usando Server-Sent Events (SSE) sin WebSockets. Incluye código backend, frontend y pruebas automatizadas.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

Acepto recibir anuncios de interes sobre este Blog.