A reliable WebSocket-to-TCP bridge for PHP applications, written in Rust. Each WebSocket connection spawns a dedicated PHP process that communicates via TCP, with the server acting as a transparent pipeline between the WebSocket client and the PHP process.
- WebSocket Server
- Per-connection PHP processes — each WebSocket client gets an isolated PHP process
- Bidirectional message bridging — WebSocket messages forwarded to PHP via TCP and vice versa
- TLS/SSL support — three modes: self-signed, manual certificates, and automatic Let's Encrypt (ACME) with hot-swappable renewal
- Resource governance — configurable limits on connections, processes, timeouts, and buffer sizes
- Graceful shutdown — drains active connections on SIGINT/SIGTERM with configurable timeout
- Connection metadata via environment variables — client IP, headers, request URI, and more passed to PHP
- No OpenSSL dependency — pure Rust TLS via
rustls
Client (WebSocket) <--> WebSocket Server <--> PHP Process (TCP)
- A client establishes a WebSocket connection to the server
- The server performs the WebSocket handshake, captures request metadata (URI, headers), and generates a unique connection ID
- The server registers a pending bridge entry keyed by the connection ID, then spawns a dedicated PHP process
- The PHP process receives connection details via environment variables, including
WSS_TCP_HOSTandWSS_TCP_PORT— the address it must connect back to - The PHP process connects back to the server's TCP bridge listener and sends its connection ID followed by a newline
- The server reads the connection ID, pairs the TCP stream with the pending WebSocket via the bridge, and begins forwarding data bidirectionally
- The connection remains alive for the lifetime of the PHP process
- When the PHP process exits, the WebSocket client disconnects, or a timeout is reached, the connection is torn down and all resources are cleaned up
websocket-server --php-executable /usr/bin/php --script /path/to/script.phpAll options can be set via CLI flags or the corresponding WSS_CONFIG_* environment variable (CLI flags take precedence).
| Option | Short | Default | Environment Variable | Description |
|---|---|---|---|---|
--ws-host |
-H |
0.0.0.0 |
WSS_CONFIG_WS_HOST |
WebSocket server bind address |
--ws-port |
-P |
8080 |
WSS_CONFIG_WS_PORT |
WebSocket server port |
--tcp-bind |
-B |
127.0.0.1 |
WSS_CONFIG_TCP_BIND |
TCP bind address for PHP back-connections |
--tcp-port |
0 |
WSS_CONFIG_TCP_PORT |
TCP port for PHP back-connections (0 = ephemeral) | |
--php-executable |
-e |
(required) | WSS_CONFIG_PHP_EXECUTABLE |
Path to the PHP executable |
--script |
-s |
(required) | WSS_CONFIG_SCRIPT |
PHP script to execute per WebSocket connection |
--php-arg |
-a |
(none) | WSS_CONFIG_PHP_ARGS |
Additional argument passed to PHP (repeatable) |
--max-connections |
-M |
256 |
WSS_CONFIG_MAX_CONNECTIONS |
Maximum simultaneous WebSocket connections |
--max-processes |
-m |
64 |
WSS_CONFIG_MAX_PROCESSES |
Maximum simultaneous PHP processes |
--connection-timeout |
-T |
0 |
WSS_CONFIG_CONNECTION_TIMEOUT |
Max connection lifetime in seconds (0 = no limit) |
--php-timeout |
-t |
0 |
WSS_CONFIG_PHP_TIMEOUT |
Max PHP process runtime in seconds (0 = no limit) |
--buffer-size |
-b |
65536 |
WSS_CONFIG_BUFFER_SIZE |
TCP read buffer size in bytes |
--log-level |
-l |
info |
WSS_CONFIG_LOG_LEVEL |
Log level: trace, debug, info, warn, error |
--php-connect-timeout |
30 |
WSS_CONFIG_PHP_CONNECT_TIMEOUT |
Timeout in seconds waiting for PHP to connect back | |
--php-connect-retries |
3 |
WSS_CONFIG_PHP_CONNECT_RETRIES |
Number of retries when spawning PHP fails |
| Option | Default | Environment Variable | Description |
|---|---|---|---|
--max-payload-size |
16777216 |
WSS_CONFIG_MAX_PAYLOAD_SIZE |
Maximum WebSocket payload size in bytes (16 MB) |
--write-buffer-size |
131072 |
WSS_CONFIG_WRITE_BUFFER_SIZE |
WebSocket write buffer size in bytes (128 KB) |
--max-write-buffer-size |
16777216 |
WSS_CONFIG_MAX_WRITE_BUFFER |
Maximum WebSocket write buffer size in bytes (16 MB) |
| Option | Default | Environment Variable | Description |
|---|---|---|---|
--tls-mode |
(none) | WSS_CONFIG_TLS_MODE |
TLS mode: manual, internal, or automate |
--tls-cert |
(none) | WSS_CONFIG_TLS_CERT |
Path to TLS certificate file (PEM) for manual mode |
--tls-key |
(none) | WSS_CONFIG_TLS_KEY |
Path to TLS private key file (PEM) for manual mode |
--tls-domain |
(none) | (not available) | Domain name(s) for certificate (repeatable, used by internal and automate) |
--tls-email |
(none) | WSS_CONFIG_TLS_EMAIL |
Email for ACME registration (automate mode) |
--tls-acme-state-dir |
./acme-state |
WSS_CONFIG_TLS_ACME_DIR |
Directory to store ACME account and certificate data |
--tls-force-acme |
false |
WSS_CONFIG_TLS_FORCE_AUTOMATE |
Force re-obtaining certificate even if cached |
--tls-renewal-window-ratio |
0.33 |
WSS_CONFIG_TLS_RENEWAL_WINDOW_RATIO |
Renew certificate when < this fraction of lifetime remains |
--tls-dns-provider |
(none) | WSS_CONFIG_TLS_DNS_PROVIDER |
DNS provider for ACME DNS-01 challenges |
--tls-dns-param |
(none) | WSS_CONFIG_TLS_DNS_PARAMS |
DNS provider parameters (repeatable, key=value format) |
--tls-client-auth |
(none) | WSS_CONFIG_TLS_CLIENT_AUTH_MODE |
Client authentication mode: request, require, verify-if-given, require-and-verify |
--tls-client-auth-ca |
(none) | WSS_CONFIG_TLS_CLIENT_AUTH_CA |
CA certificate file for client certificate verification |
--tls-ciphers |
(none) | WSS_CONFIG_TLS_CIPHERS |
Allowed TLS cipher suites (repeatable) |
--tls-curves |
(none) | WSS_CONFIG_TLS_CURVES |
Allowed TLS key exchange groups (repeatable) |
--tls-alpn |
(none) | WSS_CONFIG_TLS_ALPN |
ALPN protocol list (comma-separated, e.g. http/1.1,h2) |
--tls-key-type |
ed25519 |
WSS_CONFIG_TLS_KEY_TYPE |
Key type for self-signed/acme certs: ed25519, p256, p384, rsa2048, rsa4096 |
--tls-secrets-log |
(none) | WSS_CONFIG_TLS_INSECURE_SECRETS_LOG |
File path to log TLS session secrets (debugging only) |
| Option | Short | Description |
|---|---|---|
--help |
-h |
Print help information |
--version |
-V |
Print version information |
Three mutually exclusive TLS modes are supported via --tls-mode:
Generates a self-signed certificate at startup using --tls-domain (defaults to localhost). Useful for development
and internal networks.
websocket-server --php-executable /usr/bin/php --script ./handler.php \
--tls-mode internal \
--tls-domain example.com \
--tls-domain "*.example.com"Loads PEM-encoded certificate and private key files from disk.
websocket-server --php-executable /usr/bin/php --script ./handler.php \
--tls-mode manual \
--tls-cert /etc/ssl/certs/server.crt \
--tls-key /etc/ssl/private/server.keyAutomatically obtains and renews certificates from Let's Encrypt via HTTP-01 challenges (requires port 80). Certificates are cached in the ACME state directory and renewed automatically in the background.
websocket-server --php-executable /usr/bin/php --script ./handler.php \
--tls-mode automate \
--tls-domain example.com \
--tls-email admin@example.com \
--tls-acme-state-dir /var/lib/websocket-server/acmeThe renewal background task runs daily and checks whether the certificate lifetime remaining is below --tls-renewal-window-ratio (default 0.33). On renewal, the TlsAcceptor is hot-swapped without restarting the server.
websocket-server --php-executable /usr/bin/php --script ./handler.php \
--tls-mode manual \
--tls-cert /etc/ssl/certs/server.crt \
--tls-key /etc/ssl/private/server.key \
--tls-client-auth require-and-verify \
--tls-client-auth-ca /etc/ssl/ca.crtThe following environment variables are passed to the PHP process for each connection:
| Variable | Description |
|---|---|
WSS_ENABLED |
Always set to 1. Used by client libraries to detect the WebSocket Server environment |
WSS_CONNECTION_ID |
Unique UUID v4 identifier for this connection |
| Variable | Description |
|---|---|
WSS_CLIENT_IP |
IP address of the WebSocket client |
WSS_CLIENT_PORT |
Port of the WebSocket client |
WSS_SERVER_HOST |
Server host the client connected to |
WSS_SERVER_PORT |
Server port the client connected to |
| Variable | Description |
|---|---|
WSS_REQUEST_URI |
Full request URI (path + query string) |
WSS_REQUEST_PATH |
Path component of the URI |
WSS_REQUEST_QUERY |
Query string component of the URI |
WSS_REQUEST_HEADERS |
JSON object of all HTTP request headers |
| Variable | Description |
|---|---|
WSS_SEC_WEBSOCKET_PROTOCOL |
Sec-WebSocket-Protocol header value |
WSS_SEC_WEBSOCKET_VERSION |
Sec-WebSocket-Version header value |
WSS_ORIGIN |
Origin header value |
WSS_USER_AGENT |
User-Agent header value |
WSS_HOST |
Host header value |
WSS_X_FORWARDED_FOR |
X-Forwarded-For header value |
WSS_X_REAL_IP |
X-Real-IP header value |
| Variable | Description |
|---|---|
WSS_TCP_HOST |
Host address for the PHP process to connect back to |
WSS_TCP_PORT |
TCP port for the PHP process to connect back to |
The PHP script must:
- Read the
WSS_TCP_HOSTandWSS_TCP_PORTenvironment variables - Establish a TCP connection to that address
- Send the connection ID (
WSS_CONNECTION_ID) followed by a newline to identify itself - Read data from the TCP socket (incoming WebSocket messages forwarded as raw bytes)
- Write response data to the TCP socket (sent to the WebSocket client as binary messages)
- Continue until the script exits (closing the WebSocket connection)
<?php
$host = getenv('WSS_TCP_HOST');
$port = getenv('WSS_TCP_PORT');
$connId = getenv('WSS_CONNECTION_ID');
$fp = fsockopen($host, $port, $errno, $errstr, 30);
if (!$fp) {
fwrite(STDERR, "Failed to connect: $errstr ($errno)\n");
exit(1);
}
// Send connection ID to identify this PHP process
fwrite($fp, $connId . "\n");
fflush($fp);
// Read and respond to messages
while (!feof($fp)) {
$data = fread($fp, 8192);
if ($data === false) {
break;
}
if ($data === '') {
usleep(10000);
continue;
}
// Process the message (echo uppercase example)
$response = strtoupper($data);
fwrite($fp, $response);
fflush($fp);
}
fclose($fp);The WebsocketLib library handles all of this automatically:
<?php
use WebsocketLib\Websocket;
$ws = new Websocket();
$conn = $ws->getConnection();
fwrite(STDERR, "Connected: {$conn->getConnectionId()}\n");
while ($ws->isConnected()) {
$data = $ws->read(8192);
if ($data === null) break;
$ws->send(strtoupper($data));
}WebsocketServer is designed to be efficient and to run for long-term deployments.
- WebSocket client connects -> server accepts, performs WebSocket handshake, and captures request metadata
- A unique connection ID (UUID v4) is generated; a pending bridge is registered for it
- PHP process is spawned with the configured script and environment variables containing all connection details — including the TCP address (
WSS_TCP_HOST/WSS_TCP_PORT) to connect back to - PHP must connect back to the server's TCP bridge listener and send its connection ID followed by a newline to identify itself
- Server resolves the bridge, pairing the TCP stream with the WebSocket connection
- Bidirectional forwarding begins: WebSocket messages are forwarded as raw bytes to TCP, and TCP data is forwarded as binary WebSocket messages
- Connection persists until PHP exits, client disconnects, or a timeout is reached; the PHP process's stderr is captured and relayed to the server log
- All resources (PHP process, TCP sockets, memory) are cleaned up
- Max Connections: Prevents resource exhaustion from too many concurrent WebSocket connections
- Max Processes: Limits the number of concurrent PHP processes
- Connection Timeout: Automatically closes stale connections after the specified duration
- PHP Timeout: Kills PHP processes that run beyond their allowed time
- PHP Connect Timeout: Detects PHP processes that fail to connect back within the timeout
- Connect Retries: Automatically retries spawning PHP if the initial attempt fails
- Graceful Shutdown: On SIGINT/SIGTERM, waits up to 30 seconds for active connections to drain before exiting
When any part of the connection pipeline terminates:
- The remaining forwarder tasks are immediately cancelled
- The PHP process is killed (SIGKILL) if still running
- TCP sockets are closed
- Memory is freed
- Semaphore permits are released (allowing new connections)
An example nginx.config is included in the repository. It uses a front-controller pattern: all HTTP requests are
forwarded to PHP-FPM via index.php, while any WebSocket connection — regardless of path — is proxied to the Rust
WebSocket server.
This allows you to run both your regular web application and WebSocket server on the same domain and port, with nginx
handling the routing based on the presence of the Upgrade: websocket header. The WebSocket server captures the full
request URI (including query string) in the Host header, which it parses and exposes as WSS_REQUEST_URI for your
PHP scripts.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream websocket_backend {
server 127.0.0.1:8080;
}
upstream php_fpm_backend {
server unix:/var/run/php/php8.2-fpm.sock;
}
server {
listen 80;
server_name example.com;
root /var/www/html;
index index.php;
charset utf-8;
# WebSocket connections are proxied to the Rust WebSocket server.
# All other requests are forwarded to index.php via PHP-FPM.
location / {
if ($http_upgrade != "websocket") {
rewrite ^ /index.php last;
}
}
location /index.php {
if ($http_upgrade = "websocket") {
proxy_pass http://websocket_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
break;
}
include fastcgi_params;
fastcgi_pass php_fpm_backend;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param QUERY_STRING $query_string;
fastcgi_index index.php;
}
}How it works:
- Every request arrives at
/. If it's not a WebSocket upgrade, nginx internally rewrites to/index.phpand serves it via PHP-FPM — no.php$regex location needed. - If the request has
Upgrade: websocket, the/index.phplocation detects it and proxies to the WebSocket server instead. The full request URI (path + query) is preserved in theHostheader, which the server captures asWSS_REQUEST_URI. - The
proxy_set_headerdirectives pass the real client IP, protocol, host, and port so the server can populateWSS_CLIENT_IP,WSS_X_FORWARDED_FOR,WSS_X_REAL_IP,WSS_SERVER_HOST,WSS_SERVER_PORT, and theWSS_REQUEST_*environment variables correctly.
See nginx.config in the repository for the complete configuration.
| Command | Description |
|---|---|
make / make build / make release |
Build in release mode |
make debug |
Build in debug mode |
make test |
Run integration tests |
make test-all |
Run all tests (including unit tests) |
make check |
Run clippy with warnings denied |
make fmt |
Format code with cargo fmt |
make fmt-check |
Check formatting without changes |
make clean |
Clean build artifacts |
make install |
Install binary via cargo install --path . |
- Rust 1.75 or later (edition 2021)
- Cargo
cargo build --releaseThe compiled binary will be at target/release/websocket-server.
cargo install --path .Or copy the binary directly:
cp target/release/websocket-server /usr/local/bin/Integration tests are written in Rust using #[tokio::test]. They test plain WebSocket echo, WSS with self-signed and manual certificates, concurrent connections, clean close, and invalid request handling.
Prerequisite: PHP must be installed and on PATH.
make test # runs integration tests only
make test-all # runs all tests with outputOr directly with Cargo:
cargo test --test integration_test -- --nocaptureWebsocketServer is designed for simplicity and reliability rather than raw performance. Each WebSocket connection spawns a separate PHP process which costs more resources than an in-process handler. However, this isolation provides a layer of reliability as you avoid running into memory leaks if you were to run the WebsocketServer purely implemented in PHP.
Approximately ~100-500 concurrent connections can be handled on a typical server where's ~1,000-5,000 concurrent connections leads into a territory of ram usage accumulating up to 2GB or more depending on the PHP script being executed.
It would be more reliable to treat WebsocketServer as a means to extend your web application/service's capabilities or features by providing short-lived or moderately long-lived WebSocket connections to run PHP scripts that can perform tasks asynchronously or outside the request-response lifecycle of your main web application. For example, you can use WebsocketServer to run PHP scripts that perform background processing, long-running tasks, or real-time communication without blocking your main web server.
The project is licensed under the MIT License. See the LICENSE file for details.