WebSocket-Server in PHP: drei Ansätze im Messvergleich

WebSocket-Server in PHP: drei Ansätze im Messvergleich
Schnelle Antwort

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.

bash
composer require cboden/ratchet

Achte 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.

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

Die Klasse liegt in src/ unter dem Namensraum MyApp: Melden Sie ihn Composer, sonst bricht server.php mit Class "MyApp\Chat" not found ab.

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

Der 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.

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

Eine Klarstellung zum Broadcast an alle Clients: IoServer hat keine Methode broadcast(). Introspection bestätigt das.

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

Ein 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.

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

Der 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.

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

bash
pecl install openswoole
docker-php-ext-enable openswoole   # oder 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();

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

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);
  // 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

Backslashes in den Namespaces verloren use RatchetServerIoServer; statt use Ratchet\Server\IoServer; erzeugt Fatal error: Uncaught Error: Class "IoServer" not found, davor vier Warnungen zu wirkungslosen use-Anweisungen.
IoServer::broadcast() existiert nicht Die Methode fehlt in der Klasse, per Introspection geprüft. Ein Broadcast heißt, die Sammlung der Verbindungen selbst durchzugehen.
Exception ohne führenden Backslash im Namespace In einer Datei mit namespace MyApp; bezeichnet Exception $e die Klasse \MyApp\Exception. Es braucht \Throwable oder \Exception.
Ratchet löst unter PHP 8.5 Deprecations aus Drei Hinweise Implicitly marking parameter as nullable is deprecated allein beim Laden der Klassen. Letztes Release im Dezember 2021.
Ein Frame pro Schleifendurchlauf Unser erster nativer Server leerte die wartenden Frames nicht: Der Durchsatz brach wegen der Schleife ein, nicht wegen der PHP-Sockets.
ws:// von einer HTTPS-Seite Der Browser verweigert eine unverschlüsselte WebSocket-Verbindung von einer verschlüsselten Seite. In Produktion lautet die URL wss://.

OpenSwoolePHPRatchetTemps réelWebSocket

Damien Flandrin Webentwickler seit 2010, Gründer von Gekkode und Email Impact. Jeder Artikel wird vor der Veröffentlichung an einem echten Projekt getestet. Kontakt
Newsletter

Neue Tests, Tutorials und Projekte, per E-Mail.

Reproduzierbare Tests, versionierter Code, datierte Ergebnisse. Niemals Spam.