
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.
composer require cboden/ratchetAtençã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.
<?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.
{
"autoload": {
"psr-4": {
"MyApp\\": "src/"
}
}
}composer dump-autoloadO 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.
<?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();php server.phpUma precisão sobre a difusão para todos os clientes: IoServer não tem método broadcast(). Verifica-se por introspeção.
$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, handleErrorA 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.
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 insteadO 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.
<?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.
pecl install openswoole
docker-php-ext-enable openswoole # ou extension=openswoole.so no php.ini<?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
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
use RatchetServerIoServer; em vez de use RatchetServerIoServer; produz Fatal error: Uncaught Error: Class "IoServer" not found, precedido de quatro avisos sobre use sem efeito.namespace MyApp;, escrever Exception $e designa MyAppException. É preciso Throwable ou Exception.Implicitly marking parameter as nullable is deprecated logo ao carregar as classes. Última publicação em dezembro de 2021.wss://.

