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.
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 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.
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.
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:
/ws/arcades/the-hall)
is exposed, so you can route a connection to
whatever room, game, or channel you
model.
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.
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.
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=CLASS —
MessageHandlerInterface 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.
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.
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.
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.
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.
Under the hood, ForgeSockets is a faithful RFC 6455 implementation. It's worth knowing what's handled so you can rely on it:
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.
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).
Ping is answered with a pong automatically; close begins the closing handshake. Control frames are processed immediately even when interleaved inside a fragmented message.
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.
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.
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 |