Servidor WebSocket em PHP: três abordagens medidas e comparadas

Servidor WebSocket em PHP: três abordagens medidas e comparadas
Resposta rápida

Três caminhos: Ratchet em Composer puro, os sockets nativos sem dependências, ou OpenSwoole como extensão PECL. Todos exigem um processo permanente, logo um VPS ou um contentor, nunca um alojamento partilhado. O critério de escolha medido é a memória por ligação: 22,4 KB no Ratchet, 5,3 KB em nativo, quase nula com OpenSwoole.

O PHP consegue manter ligações WebSocket abertas, desde que aceites um modelo de execução que não traz por omissão: um processo que corre em contínuo em vez de um script que nasce e morre a cada pedido. Este artigo monta um servidor de eco com as três abordagens disponíveis em 2026, Ratchet, os sockets nativos, OpenSwoole, e compara-as com medições feitas na mesma máquina.

O que é preciso saber antes de começar

Um servidor WebSocket não se publica como um site PHP. É preciso um processo permanente, um supervisor que o reinicie quando cai e um servidor frontal que termine o TLS e encaminhe a ligação. Num alojamento partilhado é quase sempre impossível: os processos de longa duração são proibidos, e a instalação de extensões PECL também. É preciso um VPS ou um contentor.

Segundo ponto: um processo PHP que corre durante dias deixa de ter a rede de segurança do reinício. Uma fuga de memória acaba por matar o serviço. As variáveis estáticas, os logs acumulados em memória e as ligações à base de dados deixadas abertas tornam-se problemas reais.

Ratchet, a abordagem por biblioteca

O Ratchet é a biblioteca WebSocket histórica do PHP, construída sobre o event loop do ReactPHP. Instala-se sem compilar seja o que for.

bash
composer require cboden/ratchet

Atenção aos namespaces: os separadores são barras invertidas, e os exemplos que circulam perdem-nas muitas vezes ao passar de um editor para outro. Sem elas, o código lança um erro 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();
    }
}

A classe vive em src/ sob o namespace MyApp: declare-o ao Composer, sem o que server.php para com Class "MyApp\Chat" not found.

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

O tipo de $e merece um comentário. Num ficheiro com namespace MyApp;, escrever Exception $e designa MyAppException, uma classe que não existe, e a assinatura deixa de corresponder à interface. Falta a barra invertida inicial, Throwable ou 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

Uma precisão sobre a difusão para todos os clientes: IoServer não tem método broadcast(). Verifica-se por introspeção.

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

A difusão faz-se percorrendo à mão a coleção de ligações, como em onMessage() mais acima.

O estado do projeto

É o ponto que deve pesar na decisão. O cboden/ratchet está na versão 0.4.4, publicada a 14 de dezembro de 2021, o ratchet/rfc6455, o seu núcleo protocolar, é de 9 de dezembro de 2021. Quase cinco anos sem publicação. As dependências ReactPHP, essas, continuam atualizadas (react/socket 1.17.0, novembro de 2025).

Consequência concreta em PHP 8.5: o simples carregamento das classes emite três avisos de depreciação.

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

O servidor funciona, mas suja os logs e nada garante que passe no PHP 9. Se começas um projeto hoje, olha também para o ReactPHP diretamente, ou para o AmPHP, ambos ativamente mantidos.

Sem qualquer dependência

O protocolo WebSocket cabe em pouco código: um handshake HTTP, depois um formato de trama binário. Para uma necessidade simples, escrevê-lo à mão evita acrescentar treze pacotes ao projeto.

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;
    }
    // A constante abaixo é imposta pela 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;
        }

        // Esvaziar todas as tramas em espera, não uma por iteração do ciclo
        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]);
        }
    }
}

O comentário sobre o esvaziamento das tramas não é um pormenor. A nossa primeira versão só tratava uma mensagem por cliente e por iteração do ciclo: o débito medido desabava, e a culpa era do nosso ciclo, não dos sockets do PHP. É o género de erro que um banco de ensaio revela e que uma leitura do código deixa passar.

Este servidor fica deliberadamente incompleto. Não trata as tramas de controlo (ping, pong, close), nem a fragmentação, nem as cargas binárias, nem TLS. Para uso real, é preciso acrescentá-los ou passar para uma biblioteca.

OpenSwoole

O OpenSwoole é uma extensão em C que dá ao PHP um verdadeiro servidor assíncrono multiprocesso. Instala-se por PECL e exige, portanto, acesso de administrador à máquina.

bash
pecl install openswoole
docker-php-ext-enable openswoole   # ou extension=openswoole.so no 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();

O código é o mais curto dos três, e a difusão para todos os clientes vem incluída na extensão. Em contrapartida, a extensão impõe a sua presença na máquina e o seu próprio modelo de processos.

O cliente, do lado do 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);
  // reconexão com espera crescente
});

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

Em produção, o URL é em wss://, não ws://: um navegador numa página HTTPS recusa uma ligação WebSocket em claro. E a reconexão não é automática: o código acima tem de reabrir a ligação quando close dispara, com um atraso que aumenta a cada tentativa.

As medições

Os três servidores implementam o mesmo eco, na mesma máquina, cada um sozinho durante a sua medição: um contentor limitado a 1 núcleo e 512 MB, PHP 8.5.10, Docker em macOS. O cliente é um script PHP que fala o protocolo diretamente.

Memória por ligação

É a medição mais estável e a mais decisiva. Abrem-se 500 ligações, mantêm-se abertas, e lê-se o VmRSS do processo servidor antes e durante.

Servidor Em repouso Com 500 ligações Custo por ligação
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

O número do OpenSwoole não é um erro: a extensão aloca a sua tabela de ligações no arranque, o que explica a base mais elevada e a ausência de crescimento depois. O Ratchet custa quatro vezes mais por ligação do que os sockets em cru, o preço dos objetos ReactPHP e PSR-7 empilhados atrás de cada cliente. Com 10 000 ligações, a diferença entre o Ratchet e o nativo passa dos 170 MB.

Latência de ida e volta

50 clientes ligados, 800 mensagens de 64 bytes enviadas uma a uma no primeiro cliente, esperando o eco antes de enviar a seguinte.

Servidor p50 p95 p99 Ligação
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

Os três respondem em menos de um milissegundo na mediana. A diferença na cauda da distribuição diz mais do que a mediana: é ela que se sente numa interface.

Débito: a medição que não decide

50 clientes em ida e volta estrita durante seis segundos, cinco passagens por servidor, em mensagens trocadas por segundo.

Servidor Passagens 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

As gamas sobrepõem-se por completo: de 22 000 a 46 000 mensagens por segundo, todos os servidores incluídos, com até 40 % de diferença entre duas passagens do mesmo servidor. Este banco de ensaio não permite classificá-los pelo débito. A máquina de teste, uma VM Docker em macOS que partilha os núcleos com o resto do sistema, produz demasiado ruído. Preferimos dizê-lo a publicar uma classificação que a medição não sustenta. Num servidor dedicado e com um gerador de carga distribuído, a diferença talvez fosse nítida, não a medimos.

O que se pode afirmar: os três aguentam largamente várias dezenas de milhares de mensagens por segundo num único núcleo. Para a quase totalidade das aplicações, o débito não é o critério de escolha.

Escolher

Ratchet Sockets nativos OpenSwoole
Instalação Composer nada PECL, acesso root
Dependências acrescentadas 19 pacotes 0 1 extensão
Protocolo completo sim por escrever sim
Memória / ligação 22,4 KB 5,3 KB ≈ 0
Última publicação dez. 2021 ativa
Avisos de depreciação em 8.5 3 0 0
Alojamento partilhado não não não

Na prática: OpenSwoole se dominas a máquina e apontas a muitas ligações simultâneas. Os sockets nativos para uma necessidade simples e bem delimitada, ou para perceber o protocolo. Ratchet se queres ficar em Composer puro, aceitando uma biblioteca parada desde 2021, e, nesse caso, compara-a primeiro com o ReactPHP ou o AmPHP, que são mantidos.

E antes de tudo: se a necessidade é empurrar notificações sem troca bidirecional intensiva, os Server-Sent Events passam por HTTP normal, atravessam todos os proxies sem configuração e voltam a ligar-se sozinhos. É muitas vezes a ferramenta certa, e não exige nenhum destes três servidores.

Para as trocas pedido-resposta clássicas, um pedido cURL continua a ser a ferramenta adequada. Do lado do cliente, o tutorial sobre a criação de um chat online em JavaScript mostra a outra ponta da ligação. O resto dos tutoriais PHP está reunido no hub Desenvolvimento web.

Erros frequentes

Barras invertidas perdidas nos namespaces use RatchetServerIoServer; em vez de use RatchetServerIoServer; produz Fatal error: Uncaught Error: Class "IoServer" not found, precedido de quatro avisos sobre use sem efeito.
IoServer::broadcast() não existe O método não está na classe, verificado por introspeção. A difusão faz-se percorrendo à mão a coleção de ligações.
Exception sem barra invertida dentro de um namespace Num ficheiro com namespace MyApp;, escrever Exception $e designa MyAppException. É preciso Throwable ou Exception.
O Ratchet dispara depreciações em PHP 8.5 Três avisos Implicitly marking parameter as nullable is deprecated logo ao carregar as classes. Última publicação em dezembro de 2021.
Uma trama lida por iteração do ciclo O nosso primeiro servidor nativo não esvaziava as tramas em espera: o débito caía por causa do ciclo, não dos sockets do PHP.
ws:// a partir de uma página HTTPS O navegador recusa uma ligação WebSocket em claro a partir de uma página cifrada. Em produção, o URL é em wss://.

OpenSwoolePHPRatchetTemps réelWebSocket

Damien Flandrin Programador web desde 2010, criador da Gekkode e do Email Impact. Cada artigo é testado num projeto real antes de ser publicado. Contacto
Newsletter

Os novos testes, tutoriais e projetos, por e-mail.

Testes reproduzíveis, código versionado, resultados datados. Nunca spam.