
Three routes: Ratchet in pure Composer, native sockets with no dependency, or OpenSwoole as a PECL extension. All three need a permanent process, so a VPS or a container, never shared hosting. The measured deciding factor is memory per connection: 22.4 KB for Ratchet, 5.3 KB for native sockets, close to zero with OpenSwoole.
PHP can hold WebSocket connections open, provided you accept an execution model it does not have by default: a process that runs continuously instead of a script that is born and dies with every request. This article builds an echo server with the three approaches available in 2026, Ratchet, native sockets, OpenSwoole, and compares them on measurements taken on the same machine.
What you need to know before you start
A WebSocket server is not deployed like a PHP website. It needs a permanent process, a supervisor that restarts it when it falls over, and a front-end server that terminates TLS and relays the connection. On shared hosting this is almost always impossible: long-running processes are forbidden there, and so is installing PECL extensions. You need a VPS or a container.
Second point: a PHP process that runs for days no longer has the safety net of a restart. A memory leak will eventually kill the service. Static variables, logs piling up in memory and database connections left open all become real problems.
Ratchet, the library approach
Ratchet is the long-standing WebSocket library of the PHP world, built on the ReactPHP event loop. It installs without compiling anything.
composer require cboden/ratchetWatch the namespaces: the separators are backslashes, and the examples floating around often lose them on the way from one editor to another. Without them, the code throws a 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();
}
}The class lives in src/ under the MyApp namespace: declare it to Composer, otherwise server.php stops with Class "MyApp\Chat" not found.
{
"autoload": {
"psr-4": {
"MyApp\\": "src/"
}
}
}composer dump-autoloadThe type of $e deserves a note. In a file with namespace MyApp;, writing Exception $e refers to \MyApp\Exception, a class that does not exist, and the signature no longer matches the interface. You need the leading backslash, \Throwable or \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.phpOne clarification about broadcasting to every client: IoServer has no broadcast() method. Introspection confirms it.
$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, handleErrorBroadcasting means walking the collection of connections yourself, as in onMessage() above.
The state of the project
This is the point that should weigh on the decision. cboden/ratchet is at version 0.4.4, released on 14 December 2021. ratchet/rfc6455, its protocol core, dates from 9 December 2021. Almost five years without a release. The ReactPHP dependencies, on the other hand, are current (react/socket 1.17.0, November 2025).
The concrete consequence on PHP 8.5: merely loading the classes emits three deprecation notices.
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 insteadThe server works, but it pollutes the logs and nothing guarantees it will survive PHP 9. If you are starting a project today, look at ReactPHP directly as well, or at AmPHP, both are actively maintained.
With no dependency at all
The WebSocket protocol fits into very little code: an HTTP handshake, then a binary frame format. For a simple need, writing it yourself saves adding thirteen packages to the 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;
}
// The constant below is mandated by 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 + 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;
}
// Flush every pending frame, not one per loop iteration
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]);
}
}
}The comment about flushing pending frames is not a detail. Our first version handled one message per client per loop iteration: the measured throughput collapsed, and the cause was our loop, not the PHP sockets. That is the kind of mistake a benchmark exposes and a code reading lets through.
This server stays deliberately incomplete. It handles neither control frames (ping, pong, close), nor fragmentation, nor binary payloads, nor TLS. For real use you have to add them or move to a library.
OpenSwoole
OpenSwoole is a C extension that gives PHP a genuine asynchronous, multi-process server. It installs through PECL and therefore needs administrator access to the machine.
pecl install openswoole
docker-php-ext-enable openswoole # or 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();The code is the shortest of the three, and broadcasting to every client comes with the extension. In exchange, the extension imposes its presence on the machine and its own process model.
The client, in the 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);
// reconnect with increasing back-off
});
socket.addEventListener('error', () => {
console.error('erreur de transport');
});In production the URL is wss://, not ws://: a browser on an HTTPS page refuses a plaintext WebSocket connection. And reconnection is not automatic, the code above has to reopen the connection when close fires, with a delay that grows on every attempt.
The measurements
The three servers implement the same echo, on the same machine, each one alone while it is being measured: a container limited to 1 core and 512 MB, PHP 8.5.10, Docker on macOS. The client is a PHP script that speaks the protocol directly.
Memory per connection
This is the most stable and the most decisive measurement. Open 500 connections, hold them, and read VmRSS for the server process before and during.
| Server | Idle | With 500 connections | Cost per connection |
|---|---|---|---|
| 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 |
The OpenSwoole figure is not a mistake: the extension allocates its connection table at start-up, which explains both the higher baseline and the flat curve afterwards. Ratchet costs four times more per connection than raw sockets, the price of the ReactPHP and PSR-7 objects stacked behind each client. At 10,000 connections, the gap between Ratchet and native sockets goes past 170 MB.
Round-trip latency
50 clients connected, 800 messages of 64 bytes sent one at a time on the first client, waiting for the echo before sending the next.
| Server | p50 | p95 | p99 | Connection |
|---|---|---|---|---|
| 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 |
All three answer in under a millisecond at the median. The gap in the tail of the distribution says more than the median does: the tail is what you feel in an interface.
Throughput: the measurement that settles nothing
50 clients in strict round-trip mode for six seconds, five passes per server, in messages exchanged per second.
| Server | Passes | 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 |
The ranges overlap completely: 22,000 to 46,000 messages per second, all servers taken together, with up to 40% between two passes of the same server. This benchmark cannot rank them on throughput. The test machine, a Docker VM on macOS sharing its cores with the rest of the system, is too noisy. We prefer to say so rather than publish a ranking the measurements do not support. On a dedicated server, with a distributed load generator, the gap might well be clear, we have not measured it.
What can be stated: all three comfortably sustain tens of thousands of messages per second on a single core. For nearly every application, throughput is not the deciding criterion.
Choosing
| Ratchet | Native sockets | OpenSwoole | |
|---|---|---|---|
| Installation | Composer | nothing | PECL, root access |
| Dependencies added | 19 packages | 0 | 1 extension |
| Complete protocol | yes | write it yourself | yes |
| Memory per connection | 22.4 KB | 5.3 KB | ≈ 0 |
| Latest release | Dec 2021 | — | active |
| Deprecation notices on 8.5 | 3 | 0 | 0 |
| Shared hosting | no | no | no |
In practice: OpenSwoole if you control the machine and are aiming for many simultaneous connections. Native sockets for a simple, well-bounded need, or to understand the protocol. Ratchet if you want to stay in pure Composer, accepting a library frozen since 2021, and in that case, compare it first with ReactPHP or AmPHP, which are maintained.
Before any of that: if the need is to push notifications without intensive two-way traffic, Server-Sent Events travel over ordinary HTTP, cross every proxy without configuration and reconnect on their own. They are often the right tool, and they need none of these three servers.
For plain request-response exchanges, a cURL request is still the tool for the job. On the client side, the tutorial on building an online chat in JavaScript shows the other end of the connection. The rest of the PHP tutorials are gathered on the Web development hub.
Common errors
use RatchetServerIoServer; instead of use Ratchet\Server\IoServer; produces Fatal error: Uncaught Error: Class "IoServer" not found, preceded by four warnings about use statements with no effect.namespace MyApp;, writing Exception $e refers to \MyApp\Exception. You need \Throwable or \Exception.Implicitly marking parameter as nullable is deprecated notices from loading the classes alone. Last release December 2021.wss://.

