ForgeSockets

Zero-dependency WebSocket primitives that your app can run as a long-lived worker — opening handshake, frame encoding and parsing, and a non-blocking event loop, all in pure PHP. Plug in a message handler and a stream of realtime connections is yours; there is no framework owning the page, just a transport your code reacts to.

Overview

ForgeSockets implements RFC 6455 — the WebSocket protocol — on top of PHP's built-in networking (no extensions, no external libraries). It ships as two halves:

The primitives

The Handshake, frame Parser/Codec, and EventLoop classes that speak RFC 6455 for you. Handlers and authenticators are injected, so the primitive knows nothing about rooms, games, or users — your app does.

The worker

A single long-running CLI process — modules:socket:serve — that binds a port, accepts connections, and pumps the event loop until asked to stop. The same process model as the Forge queue worker (pcntl signals, graceful drain).

The transport handles the hard, repetitive parts of the protocol — the opening handshake, masked frame parsing, control frames, heartbeat, and the closing handshake — so the code you write is only about what your app does with each connection and message.

Primitives, Not a Platform

ForgeSockets is deliberately not a turnkey realtime platform. It does not ship chat rooms, presence, channels, or pub/sub — because those are app concerns that depend on your domain. What it gives you is the transport, so you decide the semantics:

  • A message handler receives every completed frame — text or binary — and decides what it means for your app.
  • The request path from the handshake (for example /ws/arcades/the-hall) is exposed, so you can route a connection to whatever room, game, or channel you model.
  • Handling is single-process and single-threaded by default. You scale by adding worker processes, and your own logic decides how multiple workers coordinate.

A common pattern: a turn-based game hub or a chat/notifications hub is not something the kernel ships. It's code in your app — a MessageHandlerInterface that knows your data — running on top of this transport. Combine it with ForgeSprinkle or ForgeWire on the browser side and you've assembled a realtime app without a realtime framework.

Installation

php forge.php package:install-module --module=ForgeSockets

The capability has no web-facing UI, routes, or middleware — it's a CLI worker plus the protocol classes. Everything you wire up happens in your own handler and authenticator classes, and in the forge_sockets.* config:

forge_sockets:
    host: 127.0.0.1,
    port: 8282,
    max_payload: 65536,
    heartbeat_seconds: 30,
    workers: 1,
    handler: Modules\MyApp\Handlers\MyHandler,
    authenticator: null,

The default handler is a simple echo that proves the transport end-to-end before you plug in your real one.

Running the Worker

php forge.php modules:socket:serve

Binds the configured host and port and runs the event loop until it receives SIGINT or SIGTERM, at which point it drains every open connection (sending close code 1001, going away) and exits cleanly. Options:

  • --host=HOST / --port=PORT — override the bind address (defaults to config).
  • --handler=CLASSMessageHandlerInterface to use (config default).
  • --authenticator=CLASS — an AuthenticatorInterface to gate connections (config default).

You'd typically run this under a process supervisor (systemd, a container, or Forge's own tooling) so it restarts on crash and scales to as many workers as your app needs.

Your Message Handler

Implementing MessageHandlerInterface is the whole job of the application side. The transport handles handshake, framing, heartbeat, and close — your code reacts to the four lifecycle events:

use Modules\ForgeSockets\Contracts\ConnectionInterface;
use Modules\ForgeSockets\Contracts\MessageHandlerInterface;
use Modules\ForgeSockets\Server\Frames\Opcode;

final class MyHandler implements MessageHandlerInterface
{
    public function onOpen(ConnectionInterface $c): void
    {
        // handshake done and (if configured) authenticated
        $c->sendText('{"event":"welcome","id":' . $c->id() . '}');
    }

    public function onMessage(ConnectionInterface $c, Opcode $opcode, string $payload): void
    {
        // only complete data frames arrive here; control frames are auto-handled
        $c->sendText('you said: ' . $payload);
    }

    public function onClose(ConnectionInterface $c, int $code, string $reason): void
    {
        // socket fully torn down; 1000 normal, 1001 going away, 1006 abnormal...
    }

    public function onError(ConnectionInterface $c, \Throwable $error): void
    {
        // a frame parse or handler error on this connection
    }
}

Register it as the handler in your config, or pass it on the command line, and it becomes the heart of your realtime feature. Because the handler is resolved through the container, it can receive your own services (repositories, the event bus, your domain logic) via its constructor.

Note the contract: the transport answers ping, pong, and the closing handshake for you, so onMessage only ever sees data frames (text or binary), and it only ever receives complete messages — the parser reassembles fragmented frames before they reach you.

The Connection

Handlers interact with a ConnectionInterface — a narrow, safe view of one live socket. It can send and close; the transport owns buffering, backpressure, control frames, and the actual socket:

  • id() — a stable per-process connection id.
  • peer() — the remote host:port.
  • path() — the handshake request path, for routing to the right room/feature.
  • user() — the opaque user id resolved by your authenticator at handshake, or null without one.
  • sendText(string) / sendBinary(string) — queue UTF-8 text or binary to the client.
  • close(int $code = 1000, string $reason = '') — begin the closing handshake; no more data after.

To push a message to many clients, keep the connections you care about — by room, by user, by game — in a structure that makes sense for your app and call sendText on each. That lookup and routing is domain logic, so it lives in your handler, not the transport.

Game Clocks & Ticks

For turn-based and realtime simulations, you can opt into a periodic heartbeat for your own logic by implementing TickableHandler. When a handler does, the server calls onTick(float $now) on a cadence — good for game clocks, turn timeouts, and scheduled advancement:

use Modules\ForgeSockets\Contracts\TickableHandler;

final class GameLoop implements MessageHandlerInterface, TickableHandler
{
    public function onTick(float $now): void
    {
        // advance turns, expire timeouts, broadcast state to your rooms
    }
}

The server only calls onTick when the handler implements the interface, and the cadence is configurable — the default suits turn-based games, while a realtime hub can lower it to drive its simulation scheduler faster. The event loop tick stays independent of the transport heartbeat, which is purely about keeping stale sockets alive.

Authenticating

WebSocket upgrades carry the browser's cookie header, which means you can authenticate the connection against your existing session — the same sessions your HTTP routes use. An AuthenticatorInterface resolves the user behind a handshake:

use Modules\ForgeSockets\Contracts\AuthenticatorInterface;

final class SessionAuthenticator implements AuthenticatorInterface
{
    public function authenticate(string $path, array $headers): ?string
    {
        $cookie = $headers['cookie'] ?? '';
        // read your session id from the cookie and look up the user...
        return $userId;          // open the connection as this user
        // return null;          // reject the connection (closed with 1008)
    }
}

Returning a string opens the connection with $connection->user() set to that id; returning null rejects it (the handshake responds 403 and the connection closes with 1008, policy violation). It receives both the request path and the raw headers, so you can resolve the user from your session driver and route by path in one place.

The Protocol

Under the hood, ForgeSockets is a faithful RFC 6455 implementation. It's worth knowing what's handled so you can rely on it:

Opening handshake

A GET ... HTTP/1.1 upgrade is validated (version 13, correct Upgrade/Connection headers, a well-formed Sec-WebSocket-Key) and answered with 101 Switching Protocols carrying the Sec-WebSocket-Accept key computed per the spec.

Framing

Server frames are parsed incrementally — handling multiple frames per packet, frames split across packets, and fragmented messages reassembled into one complete frame. Client frames must be masked (an unmasked frame is a protocol error, 1002). Text frames are validated as UTF-8 (1007 on failure); reserved opcodes and RSV bits are rejected (1002).

Control frames

Ping is answered with a pong automatically; close begins the closing handshake. Control frames are processed immediately even when interleaved inside a fragmented message.

Heartbeat & limits

Every heartbeat_seconds, the server pings connections idle past the interval and aborts ones idle past twice it. Frames over max_payload (1009) and an overflowing send queue (a slow client, 1008) close the connection.

Close codes

1000 normal, 1001 going away (server shutdown), 1002 protocol error, 1006 abnormal/EOF, 1007 invalid payload, 1008 policy violation, 1009 message too big, 1011 internal error. 1006 is reported to the handler only — it's never sent on the wire.

The event loop is built on stream_select, present in every PHP build. Read interest stays steady per connection; write interest is toggled on only when a connection has queued bytes (backpressure-driven), so the loop never does wasted work and heartbeats and game ticks always fire on schedule.

Configuration

All settings are read from config or environment variables (env() wins over config defaults):

Config Env Default Purpose
host SOCKET_HOST 127.0.0.1 Bind address
port SOCKET_PORT 8282 Bind port
max_payload SOCKET_MAX_PAYLOAD 65536 Max frame/message bytes (1009)
heartbeat_seconds SOCKET_HEARTBEAT 30 Ping cadence; abort at 2x
workers SOCKET_WORKERS 1 Worker process count
handler EchoHandler Your MessageHandlerInterface
authenticator null Your AuthenticatorInterface