Servidor WebSocket en PHP: tres enfoques medidos y comparados

Servidor WebSocket en PHP: tres enfoques medidos y comparados
Respuesta rápida

Tres vías: Ratchet en Composer puro, los sockets nativos sin dependencias o OpenSwoole como extensión PECL. Las tres exigen un proceso permanente, es decir un VPS o un contenedor, nunca un alojamiento compartido. El criterio medido que decide es la memoria por conexión: 22,4 KB con Ratchet, 5,3 KB en nativo, casi nula con OpenSwoole.

PHP puede mantener conexiones WebSocket abiertas, siempre que aceptes un modelo de ejecución que no trae por defecto: un proceso que corre de forma continua en lugar de un script que nace y muere con cada petición. Este artículo monta un servidor de eco con los tres enfoques disponibles en 2026, Ratchet, los sockets nativos, OpenSwoole, y los compara con mediciones tomadas en la misma máquina.

Lo que conviene saber antes de empezar

Un servidor WebSocket no se despliega como un sitio PHP. Necesita un proceso permanente, un supervisor que lo reinicie cuando se caiga y un servidor frontal que termine el TLS y retransmita la conexión. En un alojamiento compartido es casi siempre imposible: los procesos de larga duración están prohibidos, y la instalación de extensiones PECL también. Hace falta un VPS o un contenedor.

Segundo punto: un proceso PHP que lleva días en marcha ya no tiene la red de seguridad del reinicio. Una fuga de memoria acaba por matar el servicio. Las variables estáticas, los registros acumulados en memoria y las conexiones a la base de datos que quedan abiertas se convierten en problemas reales.

Ratchet, el enfoque por biblioteca

Ratchet es la biblioteca WebSocket de toda la vida en PHP, construida sobre el bucle de eventos de ReactPHP. Se instala sin compilar nada.

bash
composer require cboden/ratchet

Cuidado con los espacios de nombres: los separadores son barras invertidas, y los ejemplos que circulan las pierden a menudo al pasar de un editor a otro. Sin ellas, el código lanza un error fatal.

src/Chat.php
<?php

declare(strict_types=1);

namespace MyApp;

use Ratchet\ConnectionInterface;
use Ratchet\MessageComponentInterface;
use SplObjectStorage;

final class Chat implements MessageComponentInterface
{
    private SplObjectStorage $clients;

    public function __construct()
    {
        $this->clients = new SplObjectStorage();
    }

    public function onOpen(ConnectionInterface $conn): void
    {
        $this->clients->attach($conn);
        echo "Connexion {$conn->resourceId}\n";
    }

    public function onMessage(ConnectionInterface $from, $msg): void
    {
        foreach ($this->clients as $client) {
            if ($from !== $client) {
                $client->send($msg);
            }
        }
    }

    public function onClose(ConnectionInterface $conn): void
    {
        $this->clients->detach($conn);
    }

    public function onError(ConnectionInterface $conn, \Throwable $e): void
    {
        error_log('WebSocket : ' . $e->getMessage());
        $conn->close();
    }
}

La clase vive en src/ bajo el espacio de nombres MyApp: declárelo a Composer, si no, server.php se detiene con Class "MyApp\Chat" not found.

composer.json
{
    "autoload": {
        "psr-4": {
            "MyApp\\": "src/"
        }
    }
}
bash
composer dump-autoload

El tipo de $e merece un comentario. En un archivo con namespace MyApp;, escribir Exception $e designa \MyApp\Exception, una clase que no existe, y la firma deja de coincidir con la interfaz. Hace falta la barra invertida inicial, \Throwable o \Exception.

server.php
<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Ratchet\Server\IoServer;
use Ratchet\Http\HttpServer;
use Ratchet\WebSocket\WsServer;
use MyApp\Chat;

$server = IoServer::factory(
    new HttpServer(new WsServer(new Chat())),
    8080,
    '0.0.0.0',
);

$server->run();
bash
php server.php

Una precisión sobre la difusión a todos los clientes: IoServer no tiene ningún método broadcast(). Se comprueba por introspección.

php
$r = new ReflectionClass(Ratchet\Server\IoServer::class);
echo implode(', ', array_map(fn($m) => $m->getName(), $r->getMethods(ReflectionMethod::IS_PUBLIC)));
// __construct, factory, run, handleConnect, handleData, handleEnd, handleError

La difusión consiste en recorrer tú mismo la colección de conexiones, como en onMessage() más arriba.

El estado del proyecto

Este es el punto que debería pesar en la decisión. cboden/ratchet va por la versión 0.4.4, publicada el 14 de diciembre de 2021. ratchet/rfc6455, su núcleo protocolario, es del 9 de diciembre de 2021. Casi cinco años sin publicaciones. Las dependencias de ReactPHP, en cambio, están al día (react/socket 1.17.0, noviembre de 2025).

Consecuencia concreta en PHP 8.5: la simple carga de las clases emite tres avisos de obsolescencia.

code
Deprecated: Ratchet\Server\IoServer::__construct(): Implicitly marking parameter
$loop as nullable is deprecated, the explicit nullable type must be used instead
Deprecated: Ratchet\Http\HttpServerInterface::onOpen(): Implicitly marking parameter
$request as nullable is deprecated, the explicit nullable type must be used instead
Deprecated: Ratchet\WebSocket\WsServer::onOpen(): Implicitly marking parameter
$request as nullable is deprecated, the explicit nullable type must be used instead

El servidor funciona, pero ensucia los registros y nada garantiza que sobreviva a PHP 9. Si empiezas un proyecto hoy, mira también ReactPHP directamente, o AmPHP: los dos tienen mantenimiento activo.

Sin ninguna dependencia

El protocolo WebSocket cabe en muy poco código: un handshake HTTP y luego un formato de trama binario. Para una necesidad sencilla, escribirlo tú mismo evita añadir trece paquetes al proyecto.

serveur-natif.php
<?php

declare(strict_types=1);

$serveur = stream_socket_server('tcp://0.0.0.0:8081', $errno, $errstr);
if (!$serveur) {
    fwrite(STDERR, "Écoute impossible : {$errstr}\n");
    exit(1);
}
stream_set_blocking($serveur, false);

$clients = [];
$prets   = [];

function poigneeDeMain($socket): bool
{
    $requete = fread($socket, 8192);
    if (!$requete || !preg_match('#Sec-WebSocket-Key:\s*(\S+)#i', $requete, $m)) {
        return false;
    }
    // La constante de abajo la impone la RFC 6455
    $accept = base64_encode(sha1($m[1] . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true));
    fwrite($socket, "HTTP/1.1 101 Switching Protocols\r\n"
        . "Upgrade: websocket\r\nConnection: Upgrade\r\n"
        . "Sec-WebSocket-Accept: {$accept}\r\n\r\n");
    return true;
}

function lireTrame($socket): ?string
{
    $entete = fread($socket, 2);
    if ($entete === false || strlen($entete) < 2) {
        return null;
    }
    $second  = ord($entete[1]);
    $masquee = (bool) ($second & 0x80);
    $taille  = $second & 0x7F;

    if ($taille === 126) {
        $taille = unpack('n', fread($socket, 2))[1];
    } elseif ($taille === 127) {
        $taille = unpack('J', fread($socket, 8))[1];
    }

    $masque  = $masquee ? fread($socket, 4) : '';
    $donnees = '';
    while (strlen($donnees) < $taille) {
        $bloc = fread($socket, $taille - strlen($donnees));
        if ($bloc === '' || $bloc === false) {
            break;
        }
        $donnees .= $bloc;
    }

    if ($masquee) {
        for ($i = 0; $i < strlen($donnees); $i++) {
            $donnees[$i] = $donnees[$i] ^ $masque[$i % 4];
        }
    }
    return $donnees;
}

function ecrireTrame($socket, string $charge): void
{
    $taille = strlen($charge);
    $trame  = chr(0x81); // FIN + opcode de texto

    if ($taille < 126) {
        $trame .= chr($taille);
    } elseif ($taille < 65536) {
        $trame .= chr(126) . pack('n', $taille);
    } else {
        $trame .= chr(127) . pack('J', $taille);
    }
    fwrite($socket, $trame . $charge);
}

while (true) {
    $lecture = array_merge([$serveur], $clients);
    $ecriture = $exception = null;

    if (@stream_select($lecture, $ecriture, $exception, 0, 200000) === false) {
        continue;
    }

    foreach ($lecture as $socket) {
        if ($socket === $serveur) {
            $nouveau = @stream_socket_accept($serveur, 0);
            if ($nouveau) {
                stream_set_blocking($nouveau, false);
                $clients[(int) $nouveau] = $nouveau;
                $prets[(int) $nouveau]   = false;
            }
            continue;
        }

        $id = (int) $socket;

        if (!($prets[$id] ?? false)) {
            if (poigneeDeMain($socket)) {
                $prets[$id] = true;
            } else {
                fclose($socket);
                unset($clients[$id], $prets[$id]);
            }
            continue;
        }

        // Vaciar todas las tramas pendientes, no una por vuelta de bucle
        stream_set_blocking($socket, true);
        $ferme = false;
        do {
            $message = lireTrame($socket);
            if ($message === null || $message === '') {
                $ferme = true;
                break;
            }
            ecrireTrame($socket, $message); // eco
            $meta = stream_get_meta_data($socket);
        } while (($meta['unread_bytes'] ?? 0) > 0);
        stream_set_blocking($socket, false);

        if ($ferme) {
            fclose($socket);
            unset($clients[$id], $prets[$id]);
        }
    }
}

El comentario sobre el vaciado de las tramas pendientes no es un detalle. Nuestra primera versión solo procesaba un mensaje por cliente y por vuelta de bucle: el throughput medido se desplomaba, y la causa era nuestro bucle, no los sockets de PHP. Es el tipo de error que un banco de pruebas destapa y que una lectura del código deja pasar.

Este servidor es deliberadamente incompleto. No gestiona ni las tramas de control (ping, pong, close), ni la fragmentación, ni las cargas binarias, ni TLS. Para un uso real hay que añadirlos o pasar a una biblioteca.

OpenSwoole

OpenSwoole es una extensión en C que le da a PHP un servidor asíncrono multiproceso de verdad. Se instala con PECL y por tanto exige acceso de administrador a la máquina.

bash
pecl install openswoole
docker-php-ext-enable openswoole   # o extension=openswoole.so en php.ini
serveur-openswoole.php
<?php

declare(strict_types=1);

$serveur = new OpenSwoole\WebSocket\Server('0.0.0.0', 8082);

$serveur->set([
    'worker_num' => 1,
    'log_level'  => OpenSwoole\Constant::LOG_ERROR,
]);

$serveur->on('message', function (OpenSwoole\WebSocket\Server $srv, $frame) {
    $srv->push($frame->fd, $frame->data); // eco
});

$serveur->on('close', function () {});

$serveur->start();

El código es el más corto de los tres, y la difusión a todos los clientes viene incluida en la extensión. A cambio, la extensión impone su presencia en la máquina y su propio modelo de procesos.

El cliente, en el navegador

public/client.js
const socket = new WebSocket('wss://exemple.com/ws');

socket.addEventListener('open', () => {
  console.log('connexion ouverte');
  socket.send('Bonjour serveur');
});

socket.addEventListener('message', (e) => {
  console.log('reçu :', e.data);
});

socket.addEventListener('close', (e) => {
  console.log('fermée', e.code, e.reason);
  // reconexión con espera creciente
});

socket.addEventListener('error', () => {
  console.error('erreur de transport');
});

En producción la URL va en wss://, no en ws://: un navegador en una página HTTPS rechaza una conexión WebSocket en claro. Y la reconexión no es automática: el código de arriba tiene que reabrir la conexión cuando se dispara close, con un retardo que aumenta en cada intento.

Las mediciones

Los tres servidores implementan el mismo eco, en la misma máquina, cada uno solo durante su medición: un contenedor limitado a 1 núcleo y 512 MB, PHP 8.5.10, Docker sobre macOS. El cliente es un script PHP que habla el protocolo directamente.

Memoria por conexión

Es la medición más estable y la más decisiva. Se abren 500 conexiones, se mantienen y se lee VmRSS del proceso servidor antes y durante.

Servidor En reposo Con 500 conexiones Coste por conexión
Ratchet 0.4.4 23.700 KB 34.940 KB 22,4 KB
Sockets nativos 22.880 KB 25.540 KB 5,3 KB
OpenSwoole 26.2.0 26.512 KB 26.140 KB ≈ 0 KB

La cifra de OpenSwoole no es un error: la extensión reserva su tabla de conexiones al arrancar, lo que explica a la vez la base más alta y la ausencia de crecimiento después. Ratchet cuesta cuatro veces más por conexión que los sockets pelados, el precio de los objetos ReactPHP y PSR-7 apilados detrás de cada cliente. Con 10.000 conexiones, la diferencia entre Ratchet y el nativo supera los 170 MB.

Latencia de ida y vuelta

50 clientes conectados, 800 mensajes de 64 bytes enviados de uno en uno por el primer cliente, esperando el eco antes de mandar el siguiente.

Servidor p50 p95 p99 Conexión
Ratchet 0.4.4 0,27 ms 1,88 ms 3,90 ms 1,83 ms
Sockets nativos 0,03 ms 0,25 ms 0,43 ms 1,52 ms
OpenSwoole 26.2.0 0,03 ms 0,75 ms 3,02 ms 0,76 ms

Los tres responden en menos de un milisegundo en la mediana. La diferencia en la cola de la distribución dice más que la mediana: es la cola lo que se nota en una interfaz.

Throughput: la medición que no decide nada

50 clientes en ida y vuelta estricta durante seis segundos, cinco pasadas por servidor, en mensajes intercambiados por segundo.

Servidor Pasadas Mediana
Ratchet 0.4.4 25.450 · 31.667 · 32.400 · 33.933 · 36.067 32.400
Sockets nativos 25.258 · 27.375 · 33.425 · 35.975 · 46.417 33.425
OpenSwoole 26.2.0 22.333 · 23.175 · 25.050 · 32.317 · 34.192 25.050

Los rangos se solapan por completo: de 22.000 a 46.000 mensajes por segundo con todos los servidores juntos, y hasta un 40 % de diferencia entre dos pasadas del mismo servidor. Este banco de pruebas no permite clasificarlos por throughput. La máquina de pruebas, una VM Docker sobre macOS que comparte sus núcleos con el resto del sistema, mete demasiado ruido. Preferimos decirlo antes que publicar una clasificación que la medición no sostiene. En un servidor dedicado y con un generador de carga distribuido, la diferencia quizá sería nítida, no lo hemos medido.

Lo que sí se puede afirmar: los tres aguantan de sobra decenas de miles de mensajes por segundo en un solo núcleo. Para casi cualquier aplicación, el throughput no es el criterio de elección.

Elegir

Ratchet Sockets nativos OpenSwoole
Instalación Composer nada PECL, acceso root
Dependencias añadidas 19 paquetes 0 1 extensión
Protocolo completo hay que escribirlo
Memoria por conexión 22,4 KB 5,3 KB ≈ 0
Última publicación dic. 2021 activa
Avisos de obsolescencia en 8.5 3 0 0
Alojamiento compartido no no no

En la práctica: OpenSwoole si controlas la máquina y apuntas a muchas conexiones simultáneas. Los sockets nativos para una necesidad sencilla y bien acotada, o para entender el protocolo. Ratchet si quieres quedarte en Composer puro, aceptando una biblioteca congelada desde 2021, y en ese caso, compárala antes con ReactPHP o AmPHP, que sí reciben mantenimiento.

Y antes de nada: si lo que necesitas es enviar notificaciones sin un intercambio bidireccional intensivo, los Server-Sent Events viajan sobre HTTP normal, atraviesan todos los proxies sin configuración y se reconectan solos. Suelen ser la herramienta adecuada, y no requieren ninguno de estos tres servidores.

Para los intercambios petición-respuesta clásicos, una petición cURL sigue siendo la herramienta indicada. En el lado del cliente, el tutorial sobre cómo crear un chat en línea en JavaScript muestra el otro extremo de la conexión. El resto de tutoriales de PHP está reunido en el hub de Desarrollo web.

Errores frecuentes

Barras invertidas perdidas en los espacios de nombres use RatchetServerIoServer; en lugar de use Ratchet\Server\IoServer; produce Fatal error: Uncaught Error: Class "IoServer" not found, precedido de cuatro avisos sobre use sin efecto.
IoServer::broadcast() no existe El método no está en la clase, comprobado por introspección. La difusión consiste en recorrer tú mismo la colección de conexiones.
Exception sin barra invertida dentro de un espacio de nombres En un archivo con namespace MyApp;, escribir Exception $e designa \MyApp\Exception. Hace falta \Throwable o \Exception.
Ratchet dispara obsolescencias en PHP 8.5 Tres avisos Implicitly marking parameter as nullable is deprecated solo con cargar las clases. Última publicación en diciembre de 2021.
Una trama leída por vuelta de bucle Nuestro primer servidor nativo no vaciaba las tramas pendientes: el throughput se desplomaba por culpa del bucle, no de los sockets de PHP.
ws:// desde una página HTTPS El navegador rechaza una conexión WebSocket en claro desde una página cifrada. En producción, la URL va en wss://.

OpenSwoolePHPRatchetTemps réelWebSocket

Damien Flandrin Desarrollador web desde 2010, creador de Gekkode y de Email Impact. Cada artículo se prueba en un proyecto real antes de publicarse. Contacto
Newsletter

Las nuevas pruebas, tutoriales y proyectos, por correo.

Pruebas reproducibles, código versionado, resultados fechados. Nunca spam.