Newsletter

Server WebSocket in PHP: tre approcci misurati e confrontati

Server WebSocket in PHP: tre approcci misurati e confrontati
Risposta rapida

Tre strade: Ratchet in puro Composer, i socket nativi senza dipendenze, oppure OpenSwoole come estensione PECL. Tutte richiedono un processo permanente, quindi un VPS o un container, mai un hosting condiviso. Il criterio di scelta misurato è la memoria per connessione: 22,4 KB con Ratchet, 5,3 KB in nativo, quasi nulla con OpenSwoole.

PHP sa tenere aperte delle connessioni WebSocket, a patto che tu accetti un modello di esecuzione che non ha per impostazione predefinita: un processo che gira in continuo invece di uno script che nasce e muore a ogni richiesta. Questo articolo costruisce un server di echo con i tre approcci disponibili nel 2026, Ratchet, i socket nativi, OpenSwoole, e li confronta su misure prese sulla stessa macchina.

Cosa sapere prima di iniziare

Un server WebSocket non si mette online come un sito PHP. Servono un processo permanente, un supervisore che lo riavvii quando cade e un server frontale che chiuda il TLS e inoltri la connessione. Su un hosting condiviso è quasi sempre impossibile: i processi di lunga durata sono vietati, e l’installazione di estensioni PECL pure. Serve un VPS o un container.

Secondo punto: un processo PHP che gira per giorni non ha più la rete di sicurezza del riavvio. Una perdita di memoria finisce per uccidere il servizio. Le variabili statiche, i log accumulati in memoria e le connessioni al database lasciate aperte diventano problemi veri.

Ratchet, l’approccio a libreria

Ratchet è la libreria WebSocket storica di PHP, costruita sul loop di eventi di ReactPHP. Si installa senza compilare niente.

bash
composer require cboden/ratchet

Attenzione ai namespace: i separatori sono backslash, e gli esempi che circolano in rete li perdono spesso nel passaggio da un editor all’altro. Senza, il codice solleva un errore fatale.

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

La classe vive in src/ sotto il namespace MyApp: dichiaratelo a Composer, altrimenti server.php si ferma con Class "MyApp\Chat" not found.

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

Il tipo di $e merita una nota. In un file con namespace MyApp;, scrivere Exception $e indica \MyApp\Exception, una classe che non esiste, e la firma non corrisponde più all’interfaccia. Serve il backslash iniziale, \Throwable oppure \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

Una precisazione sull’invio a tutti i client: IoServer non ha nessun metodo broadcast(). Si verifica per introspezione.

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

L’invio a tutti si fa scorrendo a mano la collezione di connessioni, come in onMessage() qui sopra.

Lo stato del progetto

È il punto che dovrebbe pesare nella decisione. cboden/ratchet è alla versione 0.4.4, pubblicata il 14 dicembre 2021. ratchet/rfc6455, il suo cuore protocollare, è del 9 dicembre 2021. Quasi cinque anni senza una release. Le dipendenze ReactPHP, invece, sono aggiornate (react/socket 1.17.0, novembre 2025).

Conseguenza concreta su PHP 8.5: il semplice caricamento delle classi emette tre avvisi di deprecazione.

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

Il server funziona, ma sporca i log e niente garantisce che sopravviva a PHP 9. Se parti oggi con un progetto nuovo, guarda anche ReactPHP direttamente, o AmPHP: entrambi sono mantenuti attivamente.

Senza nessuna dipendenza

Il protocollo WebSocket sta in poco codice: un handshake HTTP, poi un formato di frame binario. Per un’esigenza semplice, scriverlo da sé evita di aggiungere tredici pacchetti al progetto.

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;
    }
    // La costante qui sotto è imposta dalla 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 + opcode testo

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

        // Svuotare tutti i frame in attesa, non uno solo per giro di loop
        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]);
        }
    }
}

Il commento sullo svuotamento dei frame in attesa non è un dettaglio. La nostra prima versione trattava un solo messaggio per client e per giro di loop: il throughput misurato crollava, e la colpa era del nostro loop, non dei socket di PHP. È il tipo di errore che un banco di prova scopre e che una lettura del codice lascia passare.

Questo server resta volutamente incompleto. Non gestisce né i frame di controllo (ping, pong, close), né la frammentazione, né i payload binari, né TLS. Per un uso reale bisogna aggiungerli o passare a una libreria.

OpenSwoole

OpenSwoole è un’estensione in C che dà a PHP un vero server asincrono multiprocesso. Si installa con PECL e richiede quindi un accesso da amministratore alla macchina.

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

Il codice è il più corto dei tre, e l’invio a tutti i client arriva già dall’estensione. In cambio, l’estensione impone la sua presenza sulla macchina e il proprio modello di processi.

Il client, lato 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);
  // riconnessione con attesa crescente
});

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

In produzione l’URL è in wss://, non in ws://: da una pagina HTTPS il browser rifiuta una connessione WebSocket in chiaro. E la riconnessione non è automatica: il codice qui sopra deve riaprire la connessione quando scatta close, con un’attesa che cresce a ogni tentativo.

Le misure

I tre server implementano lo stesso echo, sulla stessa macchina, ciascuno da solo durante la propria misura: un container limitato a 1 core e 512 MB, PHP 8.5.10, Docker su macOS. Il client è uno script PHP che parla il protocollo direttamente.

Memoria per connessione

È la misura più stabile e la più decisiva. Si aprono 500 connessioni, si tengono aperte, e si legge VmRSS del processo server prima e durante.

Server A riposo Con 500 connessioni Costo per connessione
Ratchet 0.4.4 23.700 KB 34.940 KB 22,4 KB
Socket nativi 22.880 KB 25.540 KB 5,3 KB
OpenSwoole 26.2.0 26.512 KB 26.140 KB ≈ 0 KB

Il dato di OpenSwoole non è un errore: l’estensione alloca la sua tabella di connessioni all’avvio, il che spiega insieme la base più alta e l’assenza di crescita dopo. Ratchet costa quattro volte di più per connessione dei socket nudi, il prezzo degli oggetti ReactPHP e PSR-7 impilati dietro ogni client. A 10.000 connessioni, lo scarto tra Ratchet e il nativo supera i 170 MB.

Latenza di andata e ritorno

50 client connessi, 800 messaggi da 64 byte inviati uno alla volta sul primo client, aspettando l’echo prima di mandare il successivo.

Server p50 p95 p99 Connessione
Ratchet 0.4.4 0,27 ms 1,88 ms 3,90 ms 1,83 ms
Socket nativi 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

Tutti e tre rispondono in meno di un millisecondo alla mediana. Lo scarto sulla coda della distribuzione dice molto più della mediana: è la coda che si sente davvero in un’interfaccia.

Throughput: la misura che non decide

50 client in andata e ritorno stretto per sei secondi, cinque passate per server, in messaggi scambiati al secondo.

Server Passate Mediana
Ratchet 0.4.4 25.450 · 31.667 · 32.400 · 33.933 · 36.067 32.400
Socket nativi 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

Gli intervalli si sovrappongono completamente: da 22.000 a 46.000 messaggi al secondo, tutti i server messi insieme, con punte del 40 % di scarto tra due passate dello stesso server. Questo banco di prova non permette di classificarli sul throughput. La macchina di test, una VM Docker su macOS che condivide i core con il resto del sistema, fa troppo rumore. Preferiamo dirlo piuttosto che pubblicare una classifica che la misura non sostiene. Su un server dedicato e con un generatore di carico distribuito lo scarto sarebbe forse netto, non l’abbiamo misurato.

Quello che si può affermare: tutti e tre reggono ampiamente diverse decine di migliaia di messaggi al secondo su un solo core. Per la quasi totalità delle applicazioni, il throughput non è il criterio di scelta.

Scegliere

Ratchet Socket nativi OpenSwoole
Installazione Composer niente PECL, accesso root
Dipendenze aggiunte 19 pacchetti 0 1 estensione
Protocollo completo da scrivere
Memoria / connessione 22,4 KB 5,3 KB ≈ 0
Ultima release dic. 2021 attiva
Avvisi di deprecazione su 8.5 3 0 0
Hosting condiviso no no no

In pratica: OpenSwoole se controlli la macchina e punti a molte connessioni simultanee. I socket nativi per un’esigenza semplice e ben delimitata, o per capire il protocollo. Ratchet se vuoi restare in puro Composer, accettando una libreria ferma dal 2021, e in quel caso confrontala prima con ReactPHP o AmPHP, che sono mantenuti.

E prima di tutto: se il bisogno è spingere notifiche senza uno scambio bidirezionale intenso, i Server-Sent Events passano da HTTP ordinario, attraversano tutti i proxy senza configurazione e si riconnettono da soli. Spesso sono lo strumento giusto, e non richiedono nessuno di questi tre server.

Per gli scambi richiesta-risposta classici, una richiesta cURL resta lo strumento adatto. Lato client, il tutorial su come creare una chat online in JavaScript mostra l’altro capo della connessione. Il resto dei tutorial PHP è raccolto nell’hub Sviluppo web.

Errori frequenti

Backslash persi nei namespace use RatchetServerIoServer; al posto di use Ratchet\Server\IoServer; produce Fatal error: Uncaught Error: Class "IoServer" not found, preceduto da quattro avvisi su use senza effetto.
IoServer::broadcast() non esiste Il metodo non è presente nella classe, verificato per introspezione. L'invio a tutti si fa scorrendo a mano la collezione di connessioni.
Exception senza backslash dentro un namespace In un file con namespace MyApp;, scrivere Exception $e indica \MyApp\Exception. Servono \Throwable o \Exception.
Ratchet fa scattare deprecazioni su PHP 8.5 Tre avvisi Implicitly marking parameter as nullable is deprecated al solo caricamento delle classi. Ultima release a dicembre 2021.
Un frame letto per giro di loop Il nostro primo server nativo non svuotava i frame in attesa: il throughput crollava per colpa del loop, non dei socket di PHP.
ws:// da una pagina HTTPS Il browser rifiuta una connessione WebSocket in chiaro da una pagina cifrata. In produzione l'URL è in wss://.

OpenSwoolePHPRatchetTemps réelWebSocket

Damien Flandrin Sviluppatore web dal 2010, creatore di Gekkode e di Email Impact. Ogni articolo è testato su un progetto reale prima della pubblicazione. Contatti
Newsletter

I nuovi test, tutorial e progetti, via e-mail.

Test riproducibili, codice versionato, risultati datati. Mai spam.