
Trois voies : Ratchet en pur Composer, les sockets natifs sans dépendance, ou OpenSwoole en extension PECL. Toutes exigent un processus permanent, donc un VPS ou un conteneur, jamais un hébergement mutualisé. Le critère de choix mesuré est la mémoire par connexion : 22,4 Ko pour Ratchet, 5,3 Ko en natif, quasi nulle avec OpenSwoole.
PHP sait tenir des connexions WebSocket, à condition d’accepter un modèle d’exécution qu’il n’a pas par défaut : un processus qui tourne en continu au lieu d’un script qui naît et meurt à chaque requête. Cet article monte un serveur d’écho avec les trois approches disponibles en 2026, Ratchet, les sockets natifs, OpenSwoole, et les compare sur des mesures prises sur la même machine.
Ce qu’il faut savoir avant de commencer
Un serveur WebSocket ne se déploie pas comme un site PHP. Il faut un processus permanent, un gestionnaire qui le relance s’il tombe, et un serveur frontal qui termine TLS et relaie la connexion. Sur un hébergement mutualisé, c’est presque toujours impossible : le lancement de processus longue durée y est interdit, et l’installation d’extensions PECL également. Il faut un VPS ou un conteneur.
Second point : un processus PHP qui tourne des jours n’a plus le filet du redémarrage. Une fuite mémoire finit par tuer le service. Les variables statiques, les journaux accumulés en mémoire et les connexions à la base laissées ouvertes deviennent des problèmes réels.
Ratchet, l’approche par bibliothèque
Ratchet est la bibliothèque WebSocket historique de PHP, construite sur la boucle d’événements ReactPHP. Elle s’installe sans compiler quoi que ce soit.
composer require cboden/ratchetAttention aux espaces de noms : ce sont des antislashs, et les exemples qui circulent les perdent souvent au passage d’un éditeur à l’autre. Sans eux, le code lève une erreur fatale.
<?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 classe vit dans src/ sous l’espace de noms MyApp : déclarez-le à Composer, sans quoi server.php s’arrête sur Class "MyApp\Chat" not found.
{
"autoload": {
"psr-4": {
"MyApp\\": "src/"
}
}
}composer dump-autoloadLe type de $e mérite une remarque. Dans un fichier avec namespace MyApp;, écrire Exception $e désigne MyAppException, une classe qui n’existe pas, et la signature ne correspond plus à l’interface. Il faut l’antislash de tête, 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.phpUne précision sur la diffusion à tous les clients : IoServer n’a pas de méthode broadcast(). On le vérifie par introspection.
$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, handleErrorLa diffusion se fait en parcourant soi-même la collection de connexions, comme dans onMessage() plus haut.
L’état du projet
C’est le point qui doit peser dans la décision. cboden/ratchet est en version 0.4.4, publiée le 14 décembre 2021. ratchet/rfc6455, son cœur protocolaire, date du 9 décembre 2021. Presque cinq ans sans publication. Les dépendances ReactPHP, elles, restent à jour (react/socket 1.17.0, novembre 2025).
Conséquence concrète sur PHP 8.5 : le simple chargement des classes émet trois avis de dépréciation.
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 insteadLe serveur fonctionne, mais il salit les journaux et rien ne garantit qu’il passera PHP 9. Si vous démarrez un projet aujourd’hui, regardez aussi du côté de ReactPHP directement, ou d’AmPHP, tous deux activement maintenus.
Sans aucune dépendance
Le protocole WebSocket tient dans peu de code : une poignée de main HTTP, puis un format de trame binaire. Pour un besoin simple, l’écrire soi-même évite d’ajouter treize paquets au projet.
<?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 ci-dessous est imposée par 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 texte
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;
}
// Vider toutes les trames en attente, pas une seule par tour de boucle
stream_set_blocking($socket, true);
$ferme = false;
do {
$message = lireTrame($socket);
if ($message === null || $message === '') {
$ferme = true;
break;
}
ecrireTrame($socket, $message); // écho
$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]);
}
}
}Le commentaire sur le vidage des trames n’est pas anecdotique. Notre première version ne traitait qu’un message par client et par tour de boucle : le débit mesuré s’effondrait, et la cause était notre boucle, pas les sockets PHP. C’est le genre d’erreur qu’un banc d’essai révèle et qu’une lecture de code laisse passer.
Ce serveur reste volontairement incomplet. Il ne gère ni les trames de contrôle (ping, pong, close), ni la fragmentation, ni les charges binaires, ni TLS. Pour un usage réel, il faut les ajouter ou passer à une bibliothèque.
OpenSwoole
OpenSwoole est une extension C qui donne à PHP un vrai serveur asynchrone multi-processus. Elle s’installe par PECL et demande donc un accès administrateur à la machine.
pecl install openswoole
docker-php-ext-enable openswoole # ou extension=openswoole.so dans 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); // écho
});
$serveur->on('close', function () {});
$serveur->start();Le code est le plus court des trois, et la diffusion à tous les clients est fournie par l’extension. En contrepartie, l’extension impose sa présence sur la machine et son propre modèle de processus.
Le client, côté navigateur
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);
// reconnexion avec temporisation croissante
});
socket.addEventListener('error', () => {
console.error('erreur de transport');
});En production, l’URL est en wss://, pas ws:// : un navigateur sur une page HTTPS refuse une connexion WebSocket en clair. Et la reconnexion n’est pas automatique : le code ci-dessus doit rouvrir la connexion quand close se déclenche, avec un délai qui augmente à chaque tentative.
Les mesures
Les trois serveurs implémentent le même écho, sur la même machine, chacun seul pendant sa mesure : un conteneur limité à 1 cœur et 512 Mo, PHP 8.5.10, Docker sur macOS. Le client est un script PHP qui parle le protocole en direct.
Mémoire par connexion
C’est la mesure la plus stable et la plus décisive. On ouvre 500 connexions, on les maintient, et on lit VmRSS du processus serveur avant et pendant.
| Serveur | Au repos | Avec 500 connexions | Coût par connexion |
|---|---|---|---|
| Ratchet 0.4.4 | 23 700 Ko | 34 940 Ko | 22,4 Ko |
| Sockets natifs | 22 880 Ko | 25 540 Ko | 5,3 Ko |
| OpenSwoole 26.2.0 | 26 512 Ko | 26 140 Ko | ≈ 0 Ko |
Le chiffre d’OpenSwoole n’est pas une erreur : l’extension alloue sa table de connexions au démarrage, ce qui explique la base plus élevée et l’absence de croissance ensuite. Ratchet coûte quatre fois plus par connexion que les sockets nus, le prix des objets ReactPHP et PSR-7 empilés derrière chaque client. À 10 000 connexions, l’écart entre Ratchet et le natif dépasse 170 Mo.
Latence aller-retour
50 clients connectés, 800 messages de 64 octets envoyés un par un sur le premier client, en attendant l’écho avant d’envoyer le suivant.
| Serveur | p50 | p95 | p99 | Connexion |
|---|---|---|---|---|
| Ratchet 0.4.4 | 0,27 ms | 1,88 ms | 3,90 ms | 1,83 ms |
| Sockets natifs | 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 |
Les trois répondent en moins d’une milliseconde à la médiane. L’écart sur la queue de distribution est plus parlant que la médiane : c’est lui qu’on ressent dans une interface.
Débit : la mesure qui ne tranche pas
50 clients en aller-retour strict pendant six secondes, cinq passes par serveur, en messages échangés par seconde.
| Serveur | Passes | Médiane |
|---|---|---|
| Ratchet 0.4.4 | 25 450 · 31 667 · 32 400 · 33 933 · 36 067 | 32 400 |
| Sockets natifs | 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 |
Les plages se recouvrent complètement : de 22 000 à 46 000 messages par seconde, tous serveurs confondus, avec jusqu’à 40 % d’écart entre deux passes du même serveur. Ce banc d’essai ne permet pas de les classer sur le débit. La machine de test, une VM Docker sur macOS partageant ses cœurs avec le reste du système, produit trop de bruit. Nous préférons le dire plutôt que de publier un classement que la mesure ne soutient pas. Sur un serveur dédié et avec un générateur de charge distribué, l’écart serait peut-être net, nous ne l’avons pas mesuré.
Ce qu’on peut affirmer : les trois tiennent largement plusieurs dizaines de milliers de messages par seconde sur un seul cœur. Pour la quasi-totalité des applications, le débit n’est pas le critère de choix.
Choisir
| Ratchet | Sockets natifs | OpenSwoole | |
|---|---|---|---|
| Installation | Composer | rien | PECL, accès root |
| Dépendances ajoutées | 19 paquets | 0 | 1 extension |
| Protocole complet | oui | à écrire | oui |
| Mémoire / connexion | 22,4 Ko | 5,3 Ko | ≈ 0 |
| Dernière publication | déc. 2021 | — | active |
| Avis de dépréciation sur 8.5 | 3 | 0 | 0 |
| Mutualisé | non | non | non |
En pratique : OpenSwoole si vous maîtrisez la machine et visez beaucoup de connexions simultanées. Les sockets natifs pour un besoin simple et bien délimité, ou pour comprendre le protocole. Ratchet si vous voulez rester en pur Composer, en acceptant une bibliothèque figée depuis 2021, et dans ce cas, comparez-la d’abord à ReactPHP ou AmPHP, qui sont maintenus.
Et avant tout : si le besoin est de pousser des notifications sans échange bidirectionnel intensif, les Server-Sent Events passent par du HTTP ordinaire, traversent tous les proxys sans configuration et se reconnectent seuls. C’est souvent le bon outil, et il ne demande aucun de ces trois serveurs.
Pour les échanges requête-réponse classiques, une requête cURL reste l’outil adapté. Côté client, le tutoriel sur la création d’un chat en ligne en JavaScript montre l’autre bout de la connexion. Le reste des tutoriels PHP est réuni sur le hub Développement web.
Erreurs fréquentes
use RatchetServerIoServer; au lieu de use Ratchet\Server\IoServer; produit Fatal error: Uncaught Error: Class "IoServer" not found, précédé de quatre avertissements sur des use sans effet.namespace MyApp;, écrire Exception $e désigne \MyApp\Exception. Il faut \Throwable ou \Exception.Implicitly marking parameter as nullable is deprecated au seul chargement des classes. Dernière publication en décembre 2021.wss://.

