
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.
composer require cboden/ratchetLet 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.
<?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.
{
"autoload": {
"psr-4": {
"MyApp\\": "src/"
}
}
}composer dump-autoloadHet 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.
<?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.phpNog een verduidelijking over het broadcasten naar alle clients: IoServer heeft geen methode broadcast(). Introspectie bevestigt dat.
$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, handleErrorBroadcasten 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.
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 insteadDe 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.
<?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.
pecl install openswoole
docker-php-ext-enable openswoole # of extension=openswoole.so in 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); // 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
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
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.namespace MyApp; verwijst Exception $e naar \MyApp\Exception. Je hebt \Throwable of \Exception nodig.Implicitly marking parameter as nullable is deprecated bij het enkel laden van de klassen. Laatste release december 2021.wss://.

