WebSockets con PHP: de cero al chat en tiempo real
El problema: PHP se duerme
Imagina un bar. Cada vez que un cliente pide una cerveza, el camarero va al almacen, la busca, vuelve, la sirve, y se olvida de que ese cliente existe. Si el mismo cliente quiere otra, tiene que volver a la barra y repetir todo el proceso. El camarero no recuerda nada entre peticiones.
Asi funciona PHP tradicional. Cada peticion HTTP es un ciclo completo: recibe, procesa, responde, muere. El proceso PHP se destruye y se crea uno nuevo para la siguiente peticion. Esto se llama el modelo request-response y lleva 30 anos funcionando perfectamente para servir paginas web.
Pero hay un problema. Si quieres que el camarero te avise cuando llegue una cerveza nueva a la carta, no puedes. Tendrias que ir a la barra cada 5 segundos a preguntar "hay algo nuevo?" (eso es polling, y es exactamente lo que hace el 90% del software "en tiempo real" que usas a diario). Funciona, pero es ineficiente: cientos de clientes preguntando cada pocos segundos, el servidor respondiendo "no" la mayoria de las veces.
Lo que queremos es un camarero que recuerde que estas ahi y te grite desde la barra cuando haya novedades. Eso es una conexion persistente. Y para eso necesitamos cambiar el modelo.
El event loop: PHP que no duerme
En PHP tradicional, cuando haces una query a base de datos, el proceso se bloquea. Se queda parado esperando la respuesta. Si tarda 50ms, el proceso esta 50ms sin hacer nada. Multiplicado por miles de peticiones concurrentes, necesitas miles de procesos PHP (memoria, CPU, contexto switching). Apache/PHP-FPM gestiona esto con un pool de workers, pero cada worker solo hace una cosa a la vez.
El event loop es otra filosofia. Un solo proceso, un solo hilo, pero nunca se bloquea. En lugar de esperar a que la base de datos responda, le dice "avisame cuando tengas el resultado" y sigue atendiendo otras cosas. Cuando llega la respuesta, la procesa.
MODELO TRADICIONAL (php-fpm) MODELO EVENT LOOP (ReactPHP)
peticion 1 ──→ [worker 1] peticion 1 ─┐
peticion 2 ──→ [worker 2] peticion 2 ─┤
peticion 3 ──→ [worker 3] peticion 3 ─┼──→ [un solo proceso]
... ... ... │ ↻ loop infinito
peticion N ──→ [worker N] peticion N ─┘
N procesos, N copias en memoria 1 proceso, 1 copia en memoria
Cada worker bloqueado esperando I/O Nunca bloqueado, siempre listo
Esto es lo que hace Node.js, lo que hace Nginx internamente, lo que hace Go con sus goroutines. Y es lo que hace ReactPHP en el mundo PHP.
ReactPHP: el motor de Cohete
ReactPHP es una coleccion de componentes PHP para programacion asincrona. No es un framework web, es mas bajo nivel: proporciona el event loop, streams no-bloqueantes, sockets TCP, clientes HTTP, timers, y la base sobre la que construir todo lo demas.
Cohete esta construido entero sobre ReactPHP. El servidor HTTP, las conexiones a base de datos, el message bus… todo es no-bloqueante. Un solo proceso PHP atiende el blog completo, incluido el chat, el API REST y el servidor MCP.
El corazon es el loop:
use React\EventLoop\Loop;
$loop = Loop::get();
// Esto no bloquea. Registra un callback para "dentro de 2 segundos".
$loop->addTimer(2.0, function () {
echo "Han pasado 2 segundos\n";
});
// Esto no bloquea. Registra un callback para "cada segundo".
$loop->addPeriodicTimer(1.0, function () {
echo "tick\n";
});
// Aqui empieza todo. El loop gira hasta que no quede nada que hacer.
$loop->run();Salida:
tick
tick
Han pasado 2 segundos
tick
tick
...
Un solo proceso. Sin threads. Sin forks. El loop comprueba
continuamente si hay eventos pendientes (datos en un socket, un timer
que vence, una respuesta de base de datos) y ejecuta el callback
correspondiente. Si no hay nada, espera eficientemente (select / epoll a
nivel de sistema operativo).
WebSocket: la conexion que no se cierra
HTTP funciona asi: el cliente abre conexion, envia peticion, recibe respuesta, se cierra la conexion. Fin. Para el siguiente dato, otra conexion nueva.
WebSocket (RFC 6455) empieza como HTTP pero hace un upgrade: el cliente dice "quiero cambiar de protocolo" y el servidor responde "vale". A partir de ahi, la conexion TCP se queda abierta y ambos lados pueden enviar datos cuando quieran, sin necesidad de que uno pregunte primero.
HTTP (request-response):
Cliente ──GET /datos──→ Servidor
Cliente ←──200 OK────── Servidor
(conexion cerrada)
Cliente ──GET /datos──→ Servidor ← 3 segundos despues, pregunta otra vez
Cliente ←──200 OK────── Servidor
(conexion cerrada)
WebSocket (full-duplex, persistente):
Cliente ──GET / Upgrade:websocket──→ Servidor
Cliente ←──101 Switching Protocols── Servidor
│ │
│◄═══════ conexion TCP abierta ═══════════►│
│ │
Cliente ──"hola"──→ │ (cliente envia cuando quiere)
│ ←──"hey!"── Servidor │ (servidor envia cuando quiere)
│ ←──"news!"── Servidor │ (sin que nadie pregunte)
Cliente ──"adios"──→ │
│ │
(conexion abierta hasta que alguien la cierre)
Las ventajas clave:
- Latencia minima: no hay overhead de nueva conexion TCP + handshake HTTP por cada mensaje
- Server push: el servidor puede enviar datos al cliente sin que este pregunte
- Bidireccional: ambos lados envian y reciben al mismo tiempo (full-duplex)
- Ligero: un frame WebSocket tiene 2-14 bytes de overhead vs ~800 bytes de headers HTTP
Ratchet: WebSockets en PHP
Ratchet es la pieza que une ReactPHP con el protocolo WebSocket. Gestiona el handshake HTTP→WS, el framing de mensajes, el ping/pong keepalive, y te da una interfaz limpia para manejar las conexiones.
La interfaz que implementas es MessageComponentInterface:
interface MessageComponentInterface {
function onOpen(ConnectionInterface $conn); // nueva conexion
function onMessage(ConnectionInterface $from, $msg); // mensaje recibido
function onClose(ConnectionInterface $conn); // conexion cerrada
function onError(ConnectionInterface $conn, \Exception $e);
}Cuatro metodos. Eso es todo lo que necesitas implementar para tener un servidor WebSocket completo.
Y Ratchet se monta como capas de una cebolla:
IoServer → event loop de ReactPHP (TCP)
└─ HttpServer → parsea HTTP (para el upgrade inicial)
└─ WsServer → gestiona protocolo WebSocket (RFC 6455)
└─ Tu App → tu logica de negocio (Chat, Game, Dashboard...)
Cada capa se encarga de lo suyo y pasa los datos a la siguiente. Tu codigo solo toca la capa de arriba.
La implementacion: un chat en ~130 lineas de PHP
ConnectionPool: quienes estan conectados
Cada conexion WebSocket necesita una identidad. No tenemos sesiones HTTP, no tenemos cookies. Usamos un UUID aleatorio asignado al conectar.
class ConnectionPool
{
private \SplObjectStorage $objectStorage;
public function __construct()
{
$this->objectStorage = new \SplObjectStorage();
}
public function add(ConnectionInterface $conn): void
{
// Cada conexion recibe un UUID unico
$this->objectStorage->attach($conn, Uuid::uuid4());
}
public function remove(ConnectionInterface $conn): void
{
$this->objectStorage->detach($conn);
}
public function sendToAll(array $payload, ?ConnectionInterface $except = null): void
{
foreach ($this->objectStorage as $connection) {
if ($connection !== $except) {
$connection->send(json_encode($payload, JSON_THROW_ON_ERROR));
}
}
}
public function getUuid(ConnectionInterface $from): UuidInterface
{
return $this->objectStorage[$from];
}
}SplObjectStorage es una estructura de
datos nativa de PHP que asocia objetos con datos. Perfecta para mapear
conexion → UUID. No necesitamos mas.
Chat: la logica de broadcast
class Chat implements MessageComponentInterface
{
private ConnectionPool $connectionPool;
public function __construct()
{
$this->connectionPool = new ConnectionPool();
}
public function onOpen(ConnectionInterface $conn): void
{
$this->connectionPool->add($conn);
}
public function onClose(ConnectionInterface $conn): void
{
$this->connectionPool->remove($conn);
}
public function onError(ConnectionInterface $conn, \Exception $e): void
{
var_dump(ExceptionTo::arrayWithShortTrace($e));
}
public function onMessage(ConnectionInterface $from, $msg): void
{
$uuid = $this->connectionPool->getUuid($from);
$this->connectionPool->sendToAll(
['msg' => $msg, 'uuid' => $uuid],
$from // excluir al emisor
);
}
}Cuando llega un mensaje de un cliente, le anadimos su UUID y lo reenviamos a todos los demas. El emisor no se recibe a si mismo (se lo renderiza localmente en el cliente para evitar latencia).
wsServer.php: el entry point
$wsServer = new WsServer(new Chat());
$httpServer = new Ratchet\Http\HttpServer($wsServer);
$ioServer = IoServer::factory(
$httpServer,
8001, // puerto
'0.0.0.0' // acepta conexiones de cualquier IP
);
$ioServer->run(); // arranca el event loop - nunca retorna6 lineas de setup. El IoServer::factory
crea el event loop de ReactPHP, monta el stack de protocolos (TCP → HTTP
→ WS → Chat), y se queda escuchando en el puerto 8001.
El flujo completo de un mensaje
Usuario A escribe "hola" y pulsa Enter
│
│ ┌─ CLIENTE A ────────────────────────────┐
├─ │ userInput$ emite "hola" │
├─ │ webSocket.send("hola") │ → frame WS al server
├─ │ renderiza "hola" en su propio chat │ (feedback inmediato)
│ └─────────────────────────────────────────┘
│
│ ┌─ SERVIDOR ─────────────────────────────────────────────┐
├─ │ Chat::onMessage($connA, "hola") │
│ │ uuid = pool.getUuid($connA) → "abc-123..." │
│ │ pool.sendToAll( │
│ │ {msg: "hola", uuid: "abc-123..."}, │
│ │ except: $connA │
│ │ ) │
│ │ → $connB.send(json) │
│ │ → $connC.send(json) │
│ └────────────────────────────────────────────────────────┘
│
│ ┌─ CLIENTE B ────────────────────────────┐
├─ │ SocketMessage$ emite evento │
└─ │ renderIncomingMessage: "hola" en chat │
└─────────────────────────────────────────┘
El cliente: Web Components + RxJS (sin frameworks)
El frontend es un Web Component nativo. Sin React, sin Vue, sin
Angular. Un <chat-box> que se
registra en el DOM y encapsula todo en Shadow DOM.
<chat-box group="sala1" port="8001"></chat-box>Una linea de HTML. El componente se conecta, renderiza, y gestiona su propia vida.
Lo interesante es como modela los flujos de datos con RxJS:
// El WebSocket como Observable (stream de eventos)
SocketMessage$(url) {
return new rxjs.Observable(subscriber => {
this.webSocket = new WebSocket(url);
this.webSocket.onmessage = event => subscriber.next(event);
this.webSocket.onerror = error => subscriber.error(error);
// ...
}).pipe(
rxjs.operators.retryWhen(errors => errors.pipe(
rxjs.operators.delay(2000) // reconecta cada 2s si se cae
))
);
}
// El input del usuario como Observable (stream de teclas)
userInput$() {
return rxjs.fromEvent(this.elements.messageInput, 'keypress')
.pipe(
rxjs.operators.filter(({key}) => key === 'Enter'),
rxjs.operators.map(() => this.elements.messageInput.value.trim()),
rxjs.operators.filter(value => value !== '')
);
}Dos streams: uno de mensajes que llegan del servidor, otro de texto que escribe el usuario. El primero renderiza en el chat, el segundo envia por el WebSocket. Programacion reactiva en el front que conecta naturalmente con la programacion asincrona del back.
El auto-retry es clave: si el servidor se reinicia o hay un corte de red, el cliente reintenta la conexion automaticamente hasta 10 veces con 2 segundos de delay. El usuario ni se entera.
Esto no es solo un chat
El patron es siempre el mismo:
Evento ocurre
→ Server lo detecta
→ Server pushea a los clientes conectados
→ Clientes actualizan su UI instantaneamente
Cambiando la logica del onMessage y el
payload JSON, este mismo codigo sirve para:
- Comentarios live en articulos: un usuario comenta, todos los que estan leyendo el articulo lo ven aparecer al instante
- Dashboard de redaccion: metricas de lectores actuales, trending, publicaciones nuevas… pusheadas en tiempo real
- Breaking news: el CMS publica una urgente, todos los usuarios con la web abierta reciben el banner inmediatamente
- Subastas en directo: alguien puja, todos ven el nuevo precio al instante
- Notificaciones: nuevos mensajes, menciones, alertas personalizadas sin polling
La infraestructura es identica. Un proceso PHP, un event loop, un pool de conexiones. Lo que cambia es el payload.
Numeros
| Servidor | Cliente | |
|---|---|---|
| Ficheros | 3 | 2 |
| Lineas PHP | ~127 | - |
| Lineas JS | - | ~235 |
| Dependencias | ratchet, ramsey/uuid | RxJS (CDN) |
| Procesos | 1 | - |
| Threads | 0 | - |
| Conexiones concurrentes | miles | - |
| Memoria base | ~34MB | - |
Un solo proceso PHP manejando miles de conexiones concurrentes, sin threads, sin forks, sin pools de workers. El event loop de ReactPHP se encarga de multiplexar el I/O eficientemente a nivel de kernel (epoll en Linux).
Como arrancarlo
# Terminal 1: servidor WebSocket
nix develop --command php \
src/ddd/Infrastructure/scripts/Drafts/websocketServer/wsServer.php
# Terminal 2: servidor HTTP (sirve el frontend)
nix develop --command php src/bootstrap.php
# Browser: abrir 2+ pestanas en
# http://localhost:8000/html/websocketTest.html
# Escribir en una, aparece en las otrasLo que viene
Esto es una prueba de concepto que funciona. El siguiente paso es integrarlo como una feature de primera clase en Cohete: que el WebSocket server corra dentro del mismo event loop que el servidor HTTP, sin proceso aparte. ReactPHP lo permite porque todo comparte el mismo loop.
Tambien queremos rooms/canales (ahora todo es broadcast global), autenticacion de usuarios, persistencia de mensajes, y presencia (quien esta online). Pero la base esta aqui, y es solida.
La gracia de controlar el stack completo – desde el event loop hasta el Web Component – es que no dependemos de ningun servicio externo. No hay Firebase, no hay Pusher, no hay Ably. Es nuestro PHP, nuestro servidor, nuestro protocolo. Y lo entendemos de arriba a abajo.
Ambrosio
Comentarios (1)
Deja un comentario