PHP WebSocket-server: drie aanpakken, gemeten en vergeleken

PHP WebSocket-server: drie aanpakken, gemeten en vergeleken
Kort antwoord

Drie wegen: Ratchet in puur Composer, native sockets zonder dependency, of OpenSwoole als PECL-extensie. Alle drie vragen een permanent proces, dus een VPS of een container, nooit shared hosting. Het gemeten criterium om te kiezen is het geheugen per verbinding: 22,4 kB voor Ratchet, 5,3 kB voor native sockets, vrijwel nul met OpenSwoole.

PHP kan WebSocket-verbindingen openhouden, op voorwaarde dat je een uitvoeringsmodel accepteert dat het standaard niet heeft: één proces dat continu draait, in plaats van een script dat bij elke request wordt geboren en weer sterft. Dit artikel bouwt een echoserver met de drie aanpakken die in 2026 beschikbaar zijn, Ratchet, native sockets, OpenSwoole, en vergelijkt ze op metingen die op dezelfde machine zijn gedaan.

Wat je vooraf moet weten

Een WebSocket-server deploy je niet zoals een PHP-site. Je hebt een permanent proces nodig, een supervisor die het herstart als het omvalt, en een frontserver die TLS afhandelt en de verbinding doorgeeft. Op shared hosting is dat vrijwel altijd onmogelijk: langlopende processen zijn er verboden, en PECL-extensies installeren ook. Je hebt dus een VPS of een container nodig.

Tweede punt: een PHP-proces dat dagenlang draait, heeft het vangnet van de herstart niet meer. Een geheugenlek legt de dienst uiteindelijk plat. Statische variabelen, logs die zich in het geheugen opstapelen en databaseverbindingen die openblijven worden dan echte problemen.

Ratchet, de aanpak via een bibliotheek

Ratchet is de klassieke WebSocket-bibliotheek van PHP, gebouwd op de eventloop van ReactPHP. Je installeert hem zonder ook maar iets te compileren.

bash
composer require cboden/ratchet

Let op de namespaces: de scheidingstekens zijn backslashes, en de voorbeelden die rondgaan raken ze onderweg van de ene editor naar de andere vaak kwijt. Zonder die backslashes gooit de code een fatal error.

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();
    }
}

De klasse staat in src/ onder de namespace MyApp: declareer die bij Composer, anders stopt server.php met Class "MyApp\Chat" not found.

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

Het type van $e verdient een opmerking. In een bestand met namespace MyApp; verwijst Exception $e naar \MyApp\Exception, een klasse die niet bestaat, en komt de signatuur niet meer overeen met de interface. Je hebt de backslash vooraan nodig: \Throwable of \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

Nog een verduidelijking over het broadcasten naar alle clients: IoServer heeft geen methode broadcast(). Introspectie bevestigt dat.

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

Broadcasten doe je door zelf door de collectie verbindingen te lopen, zoals in onMessage() hierboven.

De staat van het project

Dit is het punt dat zwaar moet wegen in je beslissing. cboden/ratchet staat op versie 0.4.4, uitgebracht op 14 december 2021. ratchet/rfc6455, de protocolkern, dateert van 9 december 2021. Bijna vijf jaar zonder release. De ReactPHP-dependencies zijn wel bij de tijd (react/socket 1.17.0, november 2025).

Het concrete gevolg op PHP 8.5: alleen al het laden van de klassen levert drie deprecation notices op.

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

De server werkt, maar hij vervuilt de logs en niets garandeert dat hij PHP 9 haalt. Begin je vandaag een project, kijk dan ook rechtstreeks naar ReactPHP of naar AmPHP, die worden allebei actief onderhouden.

Zonder enige dependency

Het WebSocket-protocol past in weinig code: een HTTP-handshake, daarna een binair frameformaat. Voor een eenvoudige behoefte scheelt het zelf schrijven je dertien pakketten in het project.

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;
    }
    // De constante hieronder is voorgeschreven door 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 + tekst-opcode

    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;
        }

        // Alle wachtende frames leegmaken, niet één per lus
        stream_set_blocking($socket, true);
        $ferme = false;
        do {
            $message = lireTrame($socket);
            if ($message === null || $message === '') {
                $ferme = true;
                break;
            }
            ecrireTrame($socket, $message); // echo
            $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]);
        }
    }
}

De opmerking over het leegmaken van de wachtende frames is geen detail. Onze eerste versie verwerkte per client één bericht per lus: de gemeten doorvoer stortte in, en de oorzaak was onze lus, niet de PHP-sockets. Dat is het soort fout dat een benchmark blootlegt en dat je bij het lezen van de code over het hoofd ziet.

Deze server blijft bewust onvolledig. Hij kent geen controleframes (ping, pong, close), geen fragmentatie, geen binaire payloads en geen TLS. Voor echt gebruik moet je die toevoegen of overstappen op een bibliotheek.

OpenSwoole

OpenSwoole is een C-extensie die PHP een echte asynchrone server met meerdere processen geeft. Je installeert hem via PECL, wat dus beheerderstoegang tot de machine vraagt.

bash
pecl install openswoole
docker-php-ext-enable openswoole   # of extension=openswoole.so in 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); // echo
});

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

$serveur->start();

De code is de kortste van de drie, en het broadcasten naar alle clients zit al in de extensie. Daar staat tegenover dat de extensie op de machine aanwezig moet zijn en haar eigen procesmodel oplegt.

De client, in de browser

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);
  // opnieuw verbinden met oplopende wachttijd
});

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

In productie staat de URL op wss://, niet op ws://: een browser op een HTTPS-pagina weigert een WebSocket-verbinding in het klaar. En herverbinden gaat niet vanzelf: de code hierboven moet de verbinding opnieuw openen zodra close afgaat, met een wachttijd die bij elke poging oploopt.

De metingen

De drie servers implementeren dezelfde echo, op dezelfde machine, elk alleen tijdens zijn eigen meting: een container beperkt tot 1 core en 512 MB, PHP 8.5.10, Docker op macOS. De client is een PHP-script dat het protocol rechtstreeks spreekt.

Geheugen per verbinding

Dit is de stabielste en de meest doorslaggevende meting. Je opent 500 verbindingen, houdt ze open en leest VmRSS van het serverproces ervoor en tijdens.

Server In rust Met 500 verbindingen Kosten per verbinding
Ratchet 0.4.4 23.700 kB 34.940 kB 22,4 kB
Native sockets 22.880 kB 25.540 kB 5,3 kB
OpenSwoole 26.2.0 26.512 kB 26.140 kB ≈ 0 kB

Het cijfer van OpenSwoole is geen vergissing: de extensie reserveert haar verbindingstabel bij het opstarten, wat zowel de hogere basislijn als het uitblijven van groei daarna verklaart. Ratchet kost per verbinding vier keer meer dan kale sockets, de prijs van de ReactPHP- en PSR-7-objecten die achter elke client gestapeld zitten. Bij 10.000 verbindingen loopt het verschil tussen Ratchet en native op tot meer dan 170 MB.

Round-trip-latency

50 verbonden clients, 800 berichten van 64 bytes één voor één verstuurd op de eerste client, telkens wachtend op de echo voordat het volgende vertrekt.

Server p50 p95 p99 Verbinding
Ratchet 0.4.4 0,27 ms 1,88 ms 3,90 ms 1,83 ms
Native sockets 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

Alle drie antwoorden ze op de mediaan in minder dan een milliseconde. Het verschil in de staart van de verdeling zegt meer dan die mediaan: de staart is wat je in een interface voelt.

Doorvoer: de meting die niets beslist

50 clients in strikt heen-en-weerverkeer gedurende zes seconden, vijf passes per server, in uitgewisselde berichten per seconde.

Server Passes Mediaan
Ratchet 0.4.4 25.450 · 31.667 · 32.400 · 33.933 · 36.067 32.400
Native sockets 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

De bereiken overlappen volledig: van 22.000 tot 46.000 berichten per seconde, alle servers door elkaar, met tot 40% verschil tussen twee passes van dezelfde server. Deze benchmark kan ze niet rangschikken op doorvoer. De testmachine, een Docker-VM op macOS die haar cores met de rest van het systeem deelt, maakt te veel ruis. Dat zeggen we liever dan een ranglijst te publiceren die de meting niet draagt. Op een dedicated server en met een gedistribueerde loadgenerator zou het verschil misschien duidelijk zijn, dat hebben we niet gemeten.

Wat we wel kunnen stellen: alle drie halen ze ruimschoots enkele tienduizenden berichten per seconde op één core. Voor vrijwel elke applicatie is doorvoer niet het criterium waarop je kiest.

Kiezen

Ratchet Native sockets OpenSwoole
Installatie Composer niets PECL, roottoegang
Toegevoegde dependencies 19 pakketten 0 1 extensie
Volledig protocol ja zelf schrijven ja
Geheugen / verbinding 22,4 kB 5,3 kB ≈ 0
Laatste release dec. 2021 actief
Deprecation notices op 8.5 3 0 0
Shared hosting nee nee nee

In de praktijk: OpenSwoole als je de machine beheert en op veel gelijktijdige verbindingen mikt. Native sockets voor een eenvoudige, goed afgebakende behoefte, of om het protocol te doorgronden. Ratchet als je puur bij Composer wilt blijven en een bibliotheek accepteert die sinds 2021 stilstaat, vergelijk hem in dat geval eerst met ReactPHP of AmPHP, die wel onderhouden worden.

En vooral: als je alleen notificaties wilt pushen zonder intensief tweerichtingsverkeer, lopen Server-Sent Events over gewoon HTTP, komen ze zonder configuratie door elke proxy en verbinden ze zelf opnieuw. Dat is vaak het juiste gereedschap, en het vraagt geen van deze drie servers.

Voor klassiek request-responseverkeer blijft een cURL-request het geschikte gereedschap. Aan de clientkant laat de tutorial over een online chat bouwen in JavaScript het andere uiteinde van de verbinding zien. De rest van de PHP-tutorials staat bij elkaar op de hub Webontwikkeling.

Veelgemaakte fouten

Backslashes kwijt in de namespaces use RatchetServerIoServer; in plaats van use Ratchet\Server\IoServer; geeft Fatal error: Uncaught Error: Class "IoServer" not found, voorafgegaan door vier waarschuwingen over use-regels zonder effect.
IoServer::broadcast() bestaat niet De methode zit niet in de klasse, geverifieerd via introspectie. Broadcasten doe je door zelf door de collectie verbindingen te lopen.
Exception zonder backslash binnen een namespace In een bestand met namespace MyApp; verwijst Exception $e naar \MyApp\Exception. Je hebt \Throwable of \Exception nodig.
Ratchet veroorzaakt deprecations op PHP 8.5 Drie meldingen Implicitly marking parameter as nullable is deprecated bij het enkel laden van de klassen. Laatste release december 2021.
Eén frame per lus gelezen Onze eerste native server maakte de wachtende frames niet leeg: de doorvoer stortte in door de lus, niet door de PHP-sockets.
ws:// vanaf een HTTPS-pagina De browser weigert een WebSocket-verbinding in het klaar vanaf een versleutelde pagina. In productie staat de URL op wss://.

OpenSwoolePHPRatchetTemps réelWebSocket

Damien Flandrin Webdeveloper sinds 2010, maker van Gekkode en Email Impact. Elk artikel wordt vóór publicatie getest op een echt project. Contact
Nieuwsbrief

Nieuwe tests, tutorials en projecten, per e-mail.

Reproduceerbare tests, geversioneerde code, gedateerde resultaten. Nooit spam.