Tutorials

Mastering ReactPHP: Building Non-Blocking PHP Network Services

Mastering ReactPHP: Building Non-Blocking PHP Network Services

A non-blocking server is easy to demo and surprisingly difficult to finish. Accepting connections is only the beginning; a production service also needs bounded inputs, flow control, authentication, graceful shutdown, useful telemetry, and failure behavior that does not turn one troublesome client into an outage.

This tutorial builds a complete TCP key-value service with PHP 8.3 and the ReactPHP 1.x ecosystem. Clients exchange newline-delimited JSON over persistent connections. The service handles many connections in one process without creating a thread or process per client.

Prerequisites and version boundaries

You need PHP 8.3 with JSON, sockets, and PCNTL enabled, plus Composer 2. Signal handling requires a Unix-like host; the networking code itself does not depend on PCNTL.

The example deliberately stays within one ReactPHP major-version family:

  • react/event-loop:^1.5
  • react/socket:^1.16

Use Composer's lock file in deployment so every release receives the versions tested during development.

{
    "name": "example/react-kv",
    "type": "project",
    "require": {
        "php": "^8.3",
        "ext-json": "*",
        "ext-pcntl": "*",
        "react/event-loop": "^1.5",
        "react/socket": "^1.16"
    },
    "config": {
        "sort-packages": true
    }
}
composer install
php -m | grep -E 'json|pcntl|sockets'
composer show react/event-loop react/socket

Architecture and trade-offs

A SocketServer owns the listening socket. ReactPHP's event loop watches it, dispatches incoming data, and schedules timers and signals. Each connection has a small state object containing its incomplete frame, activity timestamp, and rate-limit window. The key-value map is process-local.

This design has useful properties: no blocking reads, no per-client workers, predictable protocol limits, and cheap persistent connections. It also has clear boundaries. Data disappears on restart, a single process cannot share state with replicas, and CPU-heavy request handlers would stall every connection. Real persistence or horizontal scaling would require an asynchronous external store or deliberate sharding.

The request-response protocol helps with backpressure. ReactPHP's duplex stream couples writable congestion to reading from the same connection, allowing TCP flow control to slow a client whose responses cannot drain. Unlike a broadcast server, this service does not accumulate independent application-level fan-out queues.

Project structure

react-kv/
├── composer.json
├── composer.lock
├── server.php
├── client.php
└── vendor/

Implementing the server

The server accepts ping, set, get, delete, and stats. Every request must carry the configured token. Frames, values, keys, clients, stored entries, and request rates are bounded.

<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use React\EventLoop\Loop;
use React\Socket\ConnectionInterface;
use React\Socket\SocketServer;

final class ClientState
{
    public string $buffer = '';
    public float $lastActivity;
    public float $windowStarted;
    public int $requestsInWindow = 0;
    public bool $processing = false;

    public function __construct(public readonly ConnectionInterface $connection)
    {
        $this->lastActivity = microtime(true);
        $this->windowStarted = $this->lastActivity;
    }
}

const MAX_CLIENTS = 256;
const MAX_INPUT_BUFFER = 65_536;
const MAX_LINE = 8_192;
const MAX_VALUE = 4_096;
const MAX_KEYS = 10_000;
const REQUESTS_PER_WINDOW = 100;
const RATE_WINDOW_SECONDS = 10.0;
const IDLE_TIMEOUT_SECONDS = 30.0;
const SHUTDOWN_BUDGET_SECONDS = 10.0;
const FRAMES_PER_TICK = 32;

$listen = getenv('REACT_KV_LISTEN') ?: '127.0.0.1:9010';
$token = getenv('REACT_KV_TOKEN');

if ($token === false || strlen($token) < 32) {
    fwrite(STDERR, "REACT_KV_TOKEN must contain at least 32 bytes\n");
    exit(1);
}

$log = static function (string $event, array $context = []): void {
    $record = ['time' => gmdate('c'), 'event' => $event] + $context;
    fwrite(STDOUT, json_encode($record, JSON_THROW_ON_ERROR) . "\n");
};

$server = new SocketServer($listen);
$clients = new SplObjectStorage();
$store = [];
$stopping = false;

$encode = static fn(array $message): string =>
    json_encode($message, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) . "\n";

$closeWith = static function (
    ClientState $state,
    array $message
) use ($encode): void {
    $state->connection->end($encode($message));
};

$handle = static function (
    ClientState $state,
    string $line
) use (&$store, $token, $encode, $closeWith): void {
    $now = microtime(true);

    if ($now - $state->windowStarted >= RATE_WINDOW_SECONDS) {
        $state->windowStarted = $now;
        $state->requestsInWindow = 0;
    }

    if (++$state->requestsInWindow > REQUESTS_PER_WINDOW) {
        $closeWith($state, ['ok' => false, 'error' => 'rate_limit']);
        return;
    }

    try {
        $request = json_decode($line, true, 16, JSON_THROW_ON_ERROR);
    } catch (JsonException) {
        $state->connection->write(
            $encode(['ok' => false, 'error' => 'invalid_json'])
        );
        return;
    }

    if (!is_array($request) ||
        !isset($request['token']) ||
        !is_string($request['token']) ||
        !hash_equals($token, $request['token'])) {
        $closeWith($state, ['ok' => false, 'error' => 'unauthorized']);
        return;
    }

    $operation = $request['op'] ?? null;

    if ($operation === 'ping') {
        $state->connection->write($encode(['ok' => true, 'pong' => true]));
        return;
    }

    if ($operation === 'stats') {
        $state->connection->write($encode([
            'ok' => true,
            'keys' => count($store),
            'memory_bytes' => memory_get_usage(true),
        ]));
        return;
    }

    $key = $request['key'] ?? null;
    if (!is_string($key) ||
        preg_match('/\A[a-zA-Z0-9:._-]{1,128}\z/', $key) !== 1) {
        $state->connection->write(
            $encode(['ok' => false, 'error' => 'invalid_key'])
        );
        return;
    }

    if ($operation === 'get') {
        $found = array_key_exists($key, $store);
        $state->connection->write($encode([
            'ok' => true,
            'found' => $found,
            'value' => $found ? $store[$key] : null,
        ]));
        return;
    }

    if ($operation === 'delete') {
        $deleted = array_key_exists($key, $store);
        unset($store[$key]);
        $state->connection->write(
            $encode(['ok' => true, 'deleted' => $deleted])
        );
        return;
    }

    if ($operation === 'set') {
        $value = $request['value'] ?? null;

        if (!is_string($value) || strlen($value) > MAX_VALUE) {
            $state->connection->write(
                $encode(['ok' => false, 'error' => 'invalid_value'])
            );
            return;
        }

        if (!array_key_exists($key, $store) && count($store) >= MAX_KEYS) {
            $state->connection->write(
                $encode(['ok' => false, 'error' => 'capacity_reached'])
            );
            return;
        }

        $store[$key] = $value;
        $state->connection->write($encode(['ok' => true]));
        return;
    }

    $state->connection->write(
        $encode(['ok' => false, 'error' => 'unknown_operation'])
    );
};

$process = null;
$process = static function (ClientState $state) use (
    &$process,
    $clients,
    $handle,
    $closeWith
): void {
    if (!$clients->contains($state->connection)) {
        return;
    }

    $processed = 0;

    while ($processed < FRAMES_PER_TICK) {
        $newline = strpos($state->buffer, "\n");

        if ($newline === false) {
            if (strlen($state->buffer) > MAX_LINE) {
                $closeWith($state, ['ok' => false, 'error' => 'frame_too_large']);
            }
            $state->processing = false;
            return;
        }

        if ($newline > MAX_LINE) {
            $closeWith($state, ['ok' => false, 'error' => 'frame_too_large']);
            return;
        }

        $line = rtrim(substr($state->buffer, 0, $newline), "\r");
        $state->buffer = substr($state->buffer, $newline + 1);
        ++$processed;

        if ($line !== '') {
            $handle($state, $line);
        }
    }

    Loop::futureTick(static fn() => $process($state));
};

$server->on('connection', static function (
    ConnectionInterface $connection
) use (
    $clients,
    &$stopping,
    $process,
    $closeWith,
    $log
): void {
    if ($stopping || count($clients) >= MAX_CLIENTS) {
        $connection->end("{\"ok\":false,\"error\":\"unavailable\"}\n");
        return;
    }

    $state = new ClientState($connection);
    $clients->attach($connection, $state);
    $log('client_connected', ['remote' => $connection->getRemoteAddress()]);

    $connection->on('data', static function (string $chunk) use (
        $state,
        $process,
        $closeWith
    ): void {
        $state->lastActivity = microtime(true);
        $state->buffer .= $chunk;

        if (strlen($state->buffer) > MAX_INPUT_BUFFER) {
            $closeWith($state, ['ok' => false, 'error' => 'buffer_limit']);
            return;
        }

        if (!$state->processing) {
            $state->processing = true;
            Loop::futureTick(static fn() => $process($state));
        }
    });

    $connection->on('error', static function (Throwable $error) use ($log): void {
        $log('connection_error', ['message' => $error->getMessage()]);
    });

    $connection->on('close', static function () use (
        $connection,
        $clients,
        $log
    ): void {
        if ($clients->contains($connection)) {
            $clients->detach($connection);
        }
        $log('client_closed', ['remote' => $connection->getRemoteAddress()]);
    });
});

Loop::addPeriodicTimer(5.0, static function () use ($clients, $closeWith): void {
    $cutoff = microtime(true) - IDLE_TIMEOUT_SECONDS;

    foreach ($clients as $connection) {
        $state = $clients[$connection];
        if ($state->lastActivity < $cutoff) {
            $closeWith($state, ['ok' => false, 'error' => 'idle_timeout']);
        }
    }
});

$shutdown = static function (int $signal) use (
    &$stopping,
    $server,
    $clients,
    $encode,
    $log
): void {
    if ($stopping) {
        return;
    }

    $stopping = true;
    $server->close();
    $log('shutdown_started', ['signal' => $signal]);

    foreach ($clients as $connection) {
        $connection->end($encode(['ok' => false, 'error' => 'shutdown']));
    }

    Loop::addTimer(SHUTDOWN_BUDGET_SECONDS, static function () use (
        $clients,
        $log
    ): void {
        foreach ($clients as $connection) {
            $connection->close();
        }
        $log('shutdown_deadline_reached');
    });
};

Loop::addSignal(SIGTERM, $shutdown);
Loop::addSignal(SIGINT, $shutdown);

$log('server_started', ['listen' => $listen]);
Loop::run();

Processing at most 32 frames per event-loop turn is important. Without that fairness limit, a client delivering a large batch could monopolize the process while other ready sockets and timers wait.

Building a deterministic test client

The test client uses blocking PHP intentionally: it represents an ordinary external consumer and places explicit two-second limits on connection establishment and reads. Its write helper handles partial writes instead of assuming one fwrite() transmits an entire frame.

<?php

declare(strict_types=1);

$token = getenv('REACT_KV_TOKEN');
if ($token === false) {
    throw new RuntimeException('REACT_KV_TOKEN is required');
}

$errno = 0;
$error = '';
$socket = stream_socket_client(
    'tcp://127.0.0.1:9010',
    $errno,
    $error,
    2.0,
    STREAM_CLIENT_CONNECT
);

if ($socket === false) {
    throw new RuntimeException("Connection failed: {$error} ({$errno})");
}

stream_set_timeout($socket, 2);

$requests = [
    ['token' => $token, 'op' => 'ping'],
    ['token' => $token, 'op' => 'set', 'key' => 'release', 'value' => 'ready'],
    ['token' => $token, 'op' => 'get', 'key' => 'release'],
    ['token' => $token, 'op' => 'stats'],
];

foreach ($requests as $request) {
    $payload = json_encode($request, JSON_THROW_ON_ERROR) . "\n";
    $remaining = $payload;

    while ($remaining !== '') {
        $written = fwrite($socket, $remaining);
        if ($written === false || $written === 0) {
            throw new RuntimeException('Socket write failed');
        }
        $remaining = substr($remaining, $written);
    }

    $line = fgets($socket, 16_384);
    $metadata = stream_get_meta_data($socket);

    if ($line === false) {
        $reason = $metadata['timed_out'] ? 'read timeout' : 'connection closed';
        throw new RuntimeException($reason);
    }

    $response = json_decode($line, true, 16, JSON_THROW_ON_ERROR);
    echo json_encode($response, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), "\n";
}

fclose($socket);
export REACT_KV_TOKEN='replace-with-at-least-32-random-bytes'
php server.php

# In a second terminal, with the same environment value:
php client.php

# Exercise concurrent connections without modifying the service:
seq 1 50 | xargs -P 10 -I '{}' php client.php >/dev/null

A successful run returns a pong, confirms the write, reads ready, and reports at least one stored key. Also test malformed JSON, an oversized frame, a wrong token, idle connections, and more than 100 requests inside ten seconds. Those paths should produce explicit errors or an orderly close, never unbounded memory growth.

Security and network exposure

The default loopback binding is intentional. The token protects protocol access but does not encrypt traffic. Do not expose this plaintext service directly to an untrusted network. For remote access, place it behind a mutually authenticated TLS tunnel, a VPN, or a purpose-built TLS proxy, then restrict the host firewall to that trusted entry point.

Generate a strong token with openssl rand -hex 32, store it outside the application directory, and never place it in logs or source control. The server uses hash_equals(), closes unauthorized connections, restricts key syntax, and caps values and total entries. Authentication is still connection-level work on every request; if the protocol evolves, an authenticated handshake with explicit session state may be more efficient.

Observability and performance

Logs are newline-delimited JSON, making them suitable for the system journal or a log collector. At minimum, alert on repeated connection errors, capacity rejections, authentication failures added as aggregate counters, unexpected restarts, and memory approaching the service limit.

The stats operation is useful for verification but should not be mistaken for a complete metrics system. A production extension could expose counters through a separate loopback-only HTTP endpoint. Avoid high-cardinality labels such as raw remote addresses or keys.

Keep handlers short and non-blocking. File operations, synchronous database clients, shell commands, password hashing, compression, and large JSON transformations can all stall the event loop. Offload CPU-heavy work to bounded workers, and use ReactPHP-compatible asynchronous clients for network dependencies. Every dependency needs its own connection and operation timeout; a connection timeout alone does not bound later reads or queries.

Deploying under systemd

Place a reviewed release in /opt/react-kv and install dependencies with composer install --no-dev --classmap-authoritative. Run Composer during the release build, not from the long-running service.

[Unit]
Description=ReactPHP non-blocking key-value service
After=network.target

[Service]
Type=simple
DynamicUser=yes
WorkingDirectory=/opt/react-kv
EnvironmentFile=/etc/react-kv.env
ExecStart=/usr/bin/php /opt/react-kv/server.php
Restart=on-failure
RestartSec=2
TimeoutStopSec=12
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ProtectProc=invisible
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryMax=256M
LimitNOFILE=1024
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

Create /etc/react-kv.env as a root-owned file with mode 0600, containing REACT_KV_TOKEN=... and REACT_KV_LISTEN=127.0.0.1:9010. Install the unit as /etc/systemd/system/react-kv.service. These host-level operations require root privileges; inspect those exact paths first and do not overwrite an existing service or configuration unintentionally.

sudo systemctl daemon-reload
sudo systemctl enable --now react-kv.service
sudo systemctl status react-kv.service
sudo journalctl -u react-kv.service -n 50 --no-pager
sudo ss -ltnp 'sport = :9010'
sudo systemctl kill --signal=TERM react-kv.service

The service shutdown budget is ten seconds and systemd allows twelve. On termination, the listener closes first, existing clients receive a shutdown frame, and remaining sockets are forcibly closed at the deadline. Because the listener is loopback-only, no inbound firewall rule is required or desirable.

Common failures

  • The server starts and immediately exits: verify that the token is at least 32 bytes and that port 9010 is not already occupied.
  • Clients wait forever: ensure every frame ends with a newline and every client configures independent connection and read timeouts.
  • One client causes latency spikes: look for blocking work or expensive transformations inside the request handler.
  • Memory grows: confirm the key and value limits remain enforced, then inspect connection counts and PHP extensions rather than simply raising MemoryMax.
  • Signals do not work: confirm PCNTL is enabled in the CLI PHP binary used by systemd, not merely in another PHP installation.
  • Remote clients cannot connect: the loopback binding is working as designed. Add a secure tunnel instead of casually changing the listener to all interfaces.

Final verification checklist

  • Composer installs the locked ReactPHP 1.x dependencies under PHP 8.3.
  • The listener is visible only on 127.0.0.1:9010.
  • Valid set, get, delete, ping, and stats requests succeed.
  • Invalid authentication, malformed JSON, excessive rates, long frames, and idle clients are rejected predictably.
  • Concurrent test clients complete without event-loop stalls.
  • Logs reach the journal as valid JSON without tokens or stored values.
  • SIGTERM stops new accepts and completes within systemd's shutdown budget.
  • Memory and file-descriptor limits match the configured client capacity.

The most valuable lesson is not that PHP can keep hundreds of sockets open. It is that non-blocking software must make every boundary explicit: how much it reads, how long it waits, how fairly it schedules, what it exposes, and how it stops. Once those boundaries are designed instead of assumed, ReactPHP becomes a disciplined foundation for network services rather than merely a clever event-loop demonstration.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.