-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture
initphp/socket is layered so each part has one job and is replaceable in isolation. This page walks the layers top-down, then explains the design decisions behind the more interesting ones.
┌──────────────────────────────────────────────────────────┐
│ Socket (factory) │
│ Socket::server(Transport, …) / Socket::client(Transport,…) │
└──────────────────────────────────────────────────────────┘
│
┌─────────────────┴─────────────────┐
▼ ▼
┌───────────────────────┐ ┌───────────────────────┐
│ SocketServerInterface │ │ SocketClientInterface │
│ ── AbstractServer │ │ ── AbstractClient │
│ ├ TCP │ │ ├ TCP │
│ ├ UDP │ │ ├ UDP │
│ └ AbstractStream │ │ └ AbstractStream │
│ Server │ │ Client │
│ ├ TLS │ │ ├ TLS │
│ └ SSL │ │ └ SSL │
└───────────────────────┘ └───────────────────────┘
│ │
▼ │
┌───────────────────────┐ │ (clients hold the
│ ServerConnection │ ◀─────────────────┘ resource directly)
│ (per accepted client) │
└───────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ ChannelInterface │
│ ├ TcpChannel — ext-sockets, MSG_PEEK liveness │
│ ├ UdpChannel — ext-sockets, peer buffer │
│ └ StreamChannel — fopen / fread / feof │
└─────────────────────────────────────────────────┘
InitPHP\Socket\Socket is the only public entry point. It takes a Transport enum case and dispatches to the right concrete class:
public static function server(
Transport $transport,
string $host,
int $port,
?Domain $domain = null, // TCP/UDP only
?float $timeout = null, // TLS/SSL only
): SocketServerInterface;The factory does not bind, listen or connect. It returns a configured-but-passive instance — the caller decides when to drive the lifecycle. See Server Lifecycle and Client Lifecycle.
Every server implements SocketServerInterface:
| Method | What it does |
|---|---|
listen() |
Bind and start listening. Does not accept any client. |
live(cb, idle) |
Run the accept/dispatch loop until stop() is called. |
tick(cb, wait) |
One iteration of the loop. Returns event count. |
stop() |
Cooperatively exit live(). |
close() |
Tear down all clients + the listening socket. |
broadcast(msg, ids?) |
Fan out to all / by-id / by-list. |
register(id, conn) |
Map an addressable id to a connection. |
getClients() |
`array<int |
wait(seconds) |
Sub-second sleep helper. |
AbstractServer carries the registry / broadcast / wait helpers. Server\TCP and Server\UDP plug into ext-sockets. AbstractStreamServer plugs into the stream wrapper family and is the common parent of Server\TLS and Server\SSL.
Symmetric to servers, every client implements SocketClientInterface:
| Method | What it does |
|---|---|
connect() |
Open the connection. |
disconnect() |
Close it. Idempotent. |
read(len) |
Receive up to len bytes. null on no data / failure. |
write(data) |
Send data. Returns bytes written or null. |
Client\TCP and Client\UDP use ext-sockets directly. AbstractStreamClient (parent of Client\TLS and Client\SSL) wraps stream_socket_client and additionally exposes option(), timeout(), blocking() and crypto() chainables.
When a server accepts a new peer, it wraps the underlying handle in a Channel and the channel in a ServerConnection. The connection is only identity plus delegation:
final class ServerConnection implements SocketConnectionInterface
{
private int|string|null $id = null;
public function __construct(private readonly ChannelInterface $channel) {}
public function read(int $length = 1024): ?string { return $this->channel->read($length); }
public function write(string $data): ?int { return $this->channel->write($data); }
public function close(): bool { return $this->channel->close(); }
public function isAlive(): bool { return $this->channel->isAlive(); }
public function getSocket(): mixed { return $this->channel->getResource(); }
public function getChannel(): ChannelInterface { return $this->channel; }
public function setId(int|string $id): static { /* … */ }
public function getId(): int|string|null { /* … */ }
}This split keeps the per-connection object honest about its scope. Tests can replace the channel with a fake to exercise broadcasting / id mapping in isolation — see Testing Strategy.
The 1.x release had a switch ($transportType) ladder inside ServerClient::push(), read(), close() and isDisconnected(). The 2.x split moves the transport-specific I/O into dedicated channels:
-
TcpChannelusessocket_recv/socket_writeand detects peer close non-destructively withMSG_PEEK | MSG_DONTWAIT. -
StreamChannelusesfread/fwriteand asksfeof()whether the peer is gone — without consuming bytes. -
UdpChannelbinds an identity (peerHost:peerPort) to the server's listening socket. The server routes inbound datagrams into the channel's local buffer viafeed(); reads drain the buffer. Writes usesocket_sendtodirectly.
Adding a new transport is "implement ChannelInterface", not "edit every switch in the package".
The accept/dispatch flow has two layers — live() is the long-running while loop, tick() is a single iteration:
// AbstractServer::live (simplified)
public function live(callable $callback, float $idleSeconds = 0.05): void
{
$this->running = true;
while ($this->isRunning()) {
$this->tick($callback, $idleSeconds);
}
}tick() is implemented per-server. It always follows the same shape:
- Build the read set:
[listenSocket, ...activeClientSockets]. - Hand it to
socket_select/stream_selectwith the caller-supplied timeout. - For every resource the kernel reports readable:
- If it is the listening socket →
accept()a new client and wrap it in a channel. - If it is an existing client → check liveness, then invoke the callback.
- If it is the listening socket →
UDP departs slightly: it has no accept(), so the loop is recvfrom → lookup-or-create the UdpChannel for that peer → feed the buffer → invoke the callback.
The 1.x live() called blocking socket_accept() on every iteration, so existing clients were ignored until a new one connected. The 2.x loop tells the kernel about every resource it cares about and then services only the ones that are actually ready. This makes the server responsive to data on existing connections while still accepting new ones.
isAlive() never touches the application payload:
-
TCP —
socket_recv($sock, $buf, 1, MSG_PEEK | MSG_DONTWAIT). A 0 result means peer closed;EAGAIN/EWOULDBLOCKmeans alive-but-quiet. -
Stream (TLS / SSL) —
feof()after ais_resource()guard. -
UDP — an in-process
aliveflag; UDP has no connection state, so dead peers are detected by application-level TTL.
This was the worst 1.x bug: isDisconnected() read a line off the socket every iteration and discarded it, so the application callback never saw the payload it expected. 2.x's isAlive() is non-destructive by contract.
Every exception in the package implements SocketExceptionInterface, so a single catch covers them all:
SocketExceptionInterface (marker)
├─ SocketException (extends \RuntimeException)
│ ├─ SocketConnectionException
│ └─ SocketListenException
└─ SocketInvalidArgumentException (extends \InvalidArgumentException)
See Exceptions for the full reference.
- Server Lifecycle — the public method contract per state.
- Connection and Channel — the per-accepted-client object and its strategy.
-
Event Loop Integration — how to plug
tick()into an external loop.
initphp/socket · MIT · PHP 8.1+ · part of the InitPHP family · file issues at InitPHP/Socket/issues
Getting started
Transports
Concepts
Reference
Recipes
Operational