
Drei Wege: Ratchet in reinem Composer, native Sockets ohne Abhängigkeit oder OpenSwoole als PECL-Erweiterung. Alle drei brauchen einen permanenten Prozess, also einen VPS oder einen Container, niemals Shared Hosting. Das gemessene Entscheidungskriterium ist der Speicher pro Verbindung: 22,4 KB bei Ratchet, 5,3 KB nativ, nahezu null mit OpenSwoole.
PHP kann WebSocket-Verbindungen offen halten – vorausgesetzt, du akzeptierst ein Ausführungsmodell, das die Sprache von Haus aus nicht mitbringt: einen dauerhaft laufenden Prozess statt eines Skripts, das mit jeder Anfrage entsteht und wieder stirbt. Dieser Artikel baut einen Echo-Server mit den drei Ansätzen, die 2026 zur Verfügung stehen – Ratchet, native Sockets, OpenSwoole – und vergleicht sie anhand von Messungen auf derselben Maschine.
Was du vorher wissen musst
Ein WebSocket-Server wird nicht wie eine PHP-Website ausgeliefert. Er braucht einen permanenten Prozess, einen Supervisor, der ihn nach einem Absturz neu startet, und einen vorgelagerten Server, der TLS terminiert und die Verbindung weiterreicht. Auf Shared Hosting ist das fast immer unmöglich: langlaufende Prozesse sind dort verboten, das Installieren von PECL-Erweiterungen ebenfalls. Du brauchst einen VPS oder einen Container.
Zweiter Punkt: Ein PHP-Prozess, der tagelang läuft, hat das Sicherheitsnetz des Neustarts nicht mehr. Ein Speicherleck bringt den Dienst irgendwann um. Statische Variablen, im Speicher angesammelte Logs und offen gelassene Datenbankverbindungen werden zu echten Problemen.
Ratchet, der Weg über eine Bibliothek
Ratchet ist die traditionsreiche WebSocket-Bibliothek der PHP-Welt, gebaut auf der Event-Loop von ReactPHP. Sie lässt sich installieren, ohne irgendetwas zu kompilieren.
composer require cboden/ratchetAchte auf die Namespaces: Die Trenner sind Backslashes, und die Beispiele, die im Netz kursieren, verlieren sie oft beim Weg von einem Editor zum nächsten. Ohne sie wirft der Code einen 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();
}
}Die Klasse liegt in src/ unter dem Namensraum MyApp: Melden Sie ihn Composer, sonst bricht server.php mit Class "MyApp\Chat" not found ab.
{
"autoload": {
"psr-4": {
"MyApp\\": "src/"
}
}
}composer dump-autoloadDer Typ von $e verdient eine Anmerkung. In einer Datei mit namespace MyApp; bezeichnet Exception $e die Klasse \MyApp\Exception, die es nicht gibt, und die Signatur passt nicht mehr zum Interface. Es braucht den führenden Backslash, \Throwable oder \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.phpEine Klarstellung zum Broadcast an alle Clients: IoServer hat keine Methode broadcast(). Introspection bestätigt das.
$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, handleErrorEin Broadcast heißt, die Sammlung der Verbindungen selbst durchzugehen, wie in onMessage() weiter oben.
Der Zustand des Projekts
Das ist der Punkt, der bei der Entscheidung wiegen sollte. cboden/ratchet steht bei Version 0.4.4, veröffentlicht am 14. Dezember 2021. ratchet/rfc6455, der Protokollkern, stammt vom 9. Dezember 2021. Fast fünf Jahre ohne Release. Die ReactPHP-Abhängigkeiten dagegen sind aktuell (react/socket 1.17.0, November 2025).
Konkrete Folge unter PHP 8.5: Schon das Laden der Klassen erzeugt drei Deprecation-Hinweise.
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 insteadDer Server läuft, aber er verschmutzt die Logs, und nichts garantiert, dass er PHP 9 übersteht. Wenn du heute ein Projekt startest, sieh dir auch ReactPHP direkt an oder AmPHP – beide werden aktiv gepflegt.
Ganz ohne Abhängigkeit
Das WebSocket-Protokoll passt in wenig Code: ein HTTP-Handshake, danach ein binäres Frame-Format. Für einen einfachen Bedarf erspart es dir dreizehn zusätzliche Pakete im Projekt, wenn du es selbst schreibst.
<?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;
}
// Die folgende Konstante ist durch RFC 6455 vorgegeben
$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 + Text-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 wartenden Frames leeren, nicht nur eines pro Schleifendurchlauf
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]);
}
}
}Der Kommentar zum Leeren der wartenden Frames ist keine Nebensache. Unsere erste Version verarbeitete pro Client nur eine Nachricht je Schleifendurchlauf: Der gemessene Durchsatz brach ein, und schuld war unsere Schleife, nicht die PHP-Sockets. Genau diese Art Fehler deckt ein Benchmark auf und ein Blick in den Code lässt sie durch.
Dieser Server bleibt bewusst unvollständig. Er beherrscht weder Control-Frames (ping, pong, close) noch Fragmentierung, weder binäre Payloads noch TLS. Für den echten Einsatz musst du das ergänzen oder auf eine Bibliothek wechseln.
OpenSwoole
OpenSwoole ist eine C-Erweiterung, die PHP einen echten asynchronen Server über mehrere Prozesse verschafft. Sie wird über PECL installiert und verlangt damit administrativen Zugriff auf die Maschine.
pecl install openswoole
docker-php-ext-enable openswoole # oder 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();Der Code ist der kürzeste der drei, und den Broadcast an alle Clients liefert die Erweiterung mit. Im Gegenzug erzwingt sie ihre Anwesenheit auf der Maschine und ihr eigenes Prozessmodell.
Der Client im 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);
// Wiederverbindung mit wachsender Wartezeit
});
socket.addEventListener('error', () => {
console.error('erreur de transport');
});In Produktion lautet die URL wss://, nicht ws://: Ein Browser auf einer HTTPS-Seite verweigert eine unverschlüsselte WebSocket-Verbindung. Und die Wiederverbindung läuft nicht von allein – der Code oben muss die Verbindung neu öffnen, sobald close feuert, mit einer Wartezeit, die bei jedem Versuch wächst.
Die Messungen
Alle drei Server implementieren dasselbe Echo, auf derselben Maschine, jeder allein während seiner Messung: ein Container mit 1 Kern und 512 MB, PHP 8.5.10, Docker unter macOS. Der Client ist ein PHP-Skript, das das Protokoll direkt spricht.
Speicher pro Verbindung
Das ist die stabilste und die aussagekräftigste Messung. Wir öffnen 500 Verbindungen, halten sie offen und lesen VmRSS des Serverprozesses davor und währenddessen.
| Server | Im Leerlauf | Mit 500 Verbindungen | Kosten pro Verbindung |
|---|---|---|---|
| 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 |
Die Zahl von OpenSwoole ist kein Fehler: Die Erweiterung legt ihre Verbindungstabelle beim Start an, was den höheren Grundverbrauch und das ausbleibende Wachstum danach erklärt. Ratchet kostet pro Verbindung viermal so viel wie nackte Sockets – der Preis für die ReactPHP- und PSR-7-Objekte, die sich hinter jedem Client stapeln. Bei 10.000 Verbindungen liegen zwischen Ratchet und der nativen Variante mehr als 170 MB.
Roundtrip-Latenz
50 verbundene Clients, 800 Nachrichten zu 64 Byte, einzeln über den ersten Client gesendet, jeweils mit Warten auf das Echo vor der nächsten.
| Server | p50 | p95 | p99 | Verbindung |
|---|---|---|---|---|
| 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 drei antworten im Median in weniger als einer Millisekunde. Der Abstand am Ende der Verteilung sagt mehr aus als der Median: Genau den spürst du in einer Oberfläche.
Durchsatz: die Messung, die nichts entscheidet
50 Clients im strikten Roundtrip über sechs Sekunden, fünf Durchläufe pro Server, in ausgetauschten Nachrichten pro Sekunde.
| Server | Durchläufe | Median |
|---|---|---|
| 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 |
Die Bereiche überlappen sich vollständig: von 22.000 bis 46.000 Nachrichten pro Sekunde über alle Server hinweg, mit bis zu 40 % Abstand zwischen zwei Durchläufen desselben Servers. Dieser Benchmark erlaubt keine Rangfolge beim Durchsatz. Die Testmaschine, eine Docker-VM unter macOS, die ihre Kerne mit dem restlichen System teilt, rauscht zu stark. Wir sagen das lieber, als eine Rangfolge zu veröffentlichen, die die Messung nicht trägt. Auf einem dedizierten Server und mit einem verteilten Lastgenerator wäre der Abstand vielleicht deutlich, gemessen haben wir das nicht.
Was sich sagen lässt: Alle drei halten auf einem einzigen Kern locker mehrere Zehntausend Nachrichten pro Sekunde aus. Für nahezu jede Anwendung ist der Durchsatz nicht das Auswahlkriterium.
Die Wahl
| Ratchet | Native Sockets | OpenSwoole | |
|---|---|---|---|
| Installation | Composer | nichts | PECL, Root-Zugriff |
| Zusätzliche Abhängigkeiten | 19 Pakete | 0 | 1 Erweiterung |
| Vollständiges Protokoll | ja | selbst zu schreiben | ja |
| Speicher pro Verbindung | 22,4 KB | 5,3 KB | ≈ 0 |
| Letztes Release | Dez. 2021 | — | aktiv |
| Deprecation-Hinweise unter 8.5 | 3 | 0 | 0 |
| Shared Hosting | nein | nein | nein |
In der Praxis: OpenSwoole, wenn du die Maschine kontrollierst und viele gleichzeitige Verbindungen anpeilst. Native Sockets für einen einfachen, klar abgegrenzten Bedarf oder um das Protokoll zu verstehen. Ratchet, wenn du rein bei Composer bleiben willst und eine seit 2021 eingefrorene Bibliothek in Kauf nimmst – und vergleiche sie in dem Fall zuerst mit ReactPHP oder AmPHP, die gepflegt werden.
Und vor allem: Wenn es nur darum geht, Benachrichtigungen zu pushen, ohne intensiven Verkehr in beide Richtungen, laufen Server-Sent Events über gewöhnliches HTTP, kommen ohne Konfiguration durch jeden Proxy und verbinden sich von selbst neu. Oft ist das das richtige Werkzeug, und es braucht keinen dieser drei Server.
Für klassische Anfrage-Antwort-Aufrufe bleibt eine cURL-Anfrage das passende Werkzeug. Auf der Clientseite zeigt das Tutorial zum Aufbau eines Online-Chats in JavaScript das andere Ende der Verbindung. Die übrigen PHP-Tutorials sind im Hub Webentwicklung gesammelt.
Häufige Fehler
use RatchetServerIoServer; statt use Ratchet\Server\IoServer; erzeugt Fatal error: Uncaught Error: Class "IoServer" not found, davor vier Warnungen zu wirkungslosen use-Anweisungen.namespace MyApp; bezeichnet Exception $e die Klasse \MyApp\Exception. Es braucht \Throwable oder \Exception.Implicitly marking parameter as nullable is deprecated allein beim Laden der Klassen. Letztes Release im Dezember 2021.wss://.

