Networking with Sockets: TCP, UDP, and Stream Sockets
Create TCP and UDP sockets with PHP's sockets extension, walk through bind/listen/accept/connect and send/receive, understand blocking vs non-blocking sockets and timeouts, and use PHP's simpler stream-socket functions as a practical alternative -- including how all of this relates to WebSockets.
Every HTTP request, every database connection, every queue client -- everything PHP does over a network -- ultimately rides on top of sockets, the operating system's abstraction for a network communication endpoint. Most day-to-day PHP code never touches sockets directly because higher-level libraries (curl, PDO, a Redis client) hide them, but understanding sockets directly matters the moment you need something those libraries don't offer: a custom TCP protocol, a UDP-based service, or the low-level mechanics behind a WebSocket server. This lesson covers PHP's sockets extension (the raw, BSD-socket-style API) and its friendlier alternative, stream sockets, plus the TCP/UDP distinction and blocking behavior that trips up almost everyone the first time.
TCP vs UDP, and the Sockets Extension API
TCP (Transmission Control Protocol) is connection-oriented and reliable: before any data moves, a three-way handshake establishes a connection between two endpoints, and TCP guarantees data arrives in order and undamaged (retransmitting automatically if packets are lost), at the cost of some overhead and latency for that guarantee. It's the protocol underneath HTTP, database connections, and most application protocols where correctness matters more than raw speed. UDP (User Datagram Protocol) is connectionless: a sender just fires a self-contained datagram at a destination address with no handshake, no guarantee of delivery, no guarantee of ordering, and no automatic retransmission -- but correspondingly far lower overhead and latency, which is why it's chosen for things like DNS lookups, live video/voice streaming, and game state updates where an occasional dropped packet matters less than speed.
PHP's sockets extension (ext-sockets, not compiled in by default on every build) exposes the same low-level BSD socket API that C programs use directly: socket_create(AF_INET, SOCK_STREAM, SOL_TCP) creates a raw socket resource (SOCK_STREAM for TCP, SOCK_DGRAM for UDP), and from there a server-side flow is socket_bind() (claim a local address/port), socket_listen() (for TCP, start queuing incoming connection attempts), and socket_accept() (block until a client connects, returning a brand-new socket resource dedicated to that one client connection -- the original listening socket keeps listening for more). A client-side flow instead calls socket_connect() directly against the server's address/port, no bind/listen/accept needed.
Once a connection exists (server-side, the socket returned by socket_accept(); client-side, the socket socket_connect() was called on), both sides read and write with socket_read()/socket_write() (TCP) or socket_recvfrom()/socket_sendto() (UDP, since there's no persistent connection -- every send/receive names an address explicitly). Every one of these calls can fail, and socket_last_error($socket) plus socket_strerror() are how you turn that failure into a human-readable message -- production socket code checks return values relentlessly, since a network operation failing silently is a much worse bug than one failing loudly.
• TCP: connection-oriented, reliable, ordered -- handshake first, retransmits lost data; higher overhead. • UDP: connectionless, no delivery/order guarantee -- lower overhead; used for DNS, streaming, games. • Server TCP flow: socket_create() -> socket_bind() -> socket_listen() -> socket_accept() (blocks per client). • Client TCP flow: socket_create() -> socket_connect() directly, no bind/listen/accept needed. • socket_last_error() + socket_strerror() -- how to diagnose a failed socket call.
Stream Sockets: PHP's Simpler Alternative
For the large majority of real PHP networking needs, the sockets extension's raw BSD-style API is more ceremony than necessary -- PHP's stream sockets, built on the same streams abstraction used for files and pipes, cover the common cases with far less code and no separate extension requirement (stream functions are part of PHP core). stream_socket_server('tcp://0.0.0.0:8080', $errno, $errstr) creates a listening TCP server socket in one call (combining what socket_create + socket_bind + socket_listen do separately), and stream_socket_accept($server) blocks waiting for and accepting the next client connection, returning a regular PHP stream resource you can then read/write with the exact same fread()/fwrite() functions used for files.
On the client side, stream_socket_client('tcp://example.com:80', $errno, $errstr, $timeout) opens a connection in one call and, critically, accepts a $timeout parameter directly -- something the raw sockets extension requires several extra socket_set_option() calls to achieve. Because stream sockets return ordinary stream resources, all of PHP's existing stream machinery applies for free: stream_set_blocking() to toggle blocking/non-blocking mode, stream_set_timeout() for read/write timeouts, and even stream_select() to wait on multiple sockets at once (the same underlying select() mechanism the sockets extension's socket_select() exposes, just through the streams API).
The practical guidance is simple: reach for stream sockets by default -- they're part of core PHP, integrate cleanly with everything else that already speaks streams, and cover TCP client/server and UDP needs with a noticeably smaller API surface. Reach for the raw sockets extension only when you need something stream sockets don't expose directly, such as fine-grained socket options (SO_REUSEADDR, SO_KEEPALIVE and friends via socket_set_option()) or raw/exotic socket types.
• stream_socket_server('tcp://host:port', ...) -- creates a listening TCP socket in one call; no separate bind/listen. • stream_socket_accept($server) -- blocks for the next client, returns a normal stream resource (use fread()/fwrite()). • stream_socket_client('tcp://host:port', ..., $timeout) -- connects with a built-in timeout parameter. • Stream sockets integrate with fread()/fwrite()/stream_set_blocking()/stream_set_timeout()/stream_select() -- the same tools used for files. • Default to stream sockets; reach for ext-sockets only for low-level options stream sockets don't expose.
Blocking vs Non-Blocking, Timeouts, and the Road to WebSockets
By default, sockets are blocking: a call like socket_accept() or fread() on a socket stream simply pauses the entire script -- does nothing else -- until data arrives or a connection completes. For a simple single-client-at-a-time tool this is fine and often exactly what you want (the simplicity is worth it), but a server that needs to handle many clients at once cannot afford to have one slow or idle client block everything else. socket_set_nonblock() (or stream_set_blocking($stream, false) for stream sockets) makes a socket return immediately even when no data is ready, typically signaled by a specific error code (EWOULDBLOCK/EAGAIN) rather than actually waiting -- code using non-blocking sockets has to explicitly poll or use a mechanism like socket_select()/stream_select() to efficiently wait across many sockets simultaneously, which is the foundation every event-driven PHP socket server (and every event loop library like ReactPHP or Amp) is built on.
Timeouts prevent a blocking call from waiting forever: socket_set_option() with SO_RCVTIMEO/SO_SNDTIMEO (raw sockets) or stream_set_timeout() (stream sockets) bounds how long a read/write/connect attempt will wait before giving up and returning a failure/timeout indication instead of hanging indefinitely -- essential for anything talking to a network, since a peer going silent (a crashed server, a severed connection, a firewall silently dropping packets) is a routine, expected failure mode, not a rare edge case, and code that doesn't set a timeout can hang forever waiting for a response that will never come.
WebSockets -- the protocol behind real-time browser features like live chat and live dashboards -- are built directly on top of everything in this lesson: a WebSocket connection starts as an ordinary TCP connection (via an HTTP upgrade handshake, where the client sends a normal HTTP request with an Upgrade: websocket header and the server responds confirming the switch), after which both sides simply read and write WebSocket-framed messages over that same raw TCP socket for the connection's entire lifetime. Plain PHP-FPM can't hold a WebSocket connection open (its whole model is one short-lived request per worker), which is exactly why real-time PHP WebSocket servers are built as long-running processes using stream sockets or the sockets extension directly (libraries like Ratchet and Swoole wrap exactly this socket-level machinery) rather than running inside the traditional FPM request/response cycle.
• Blocking (default): a socket call pauses execution entirely until data/connection is ready -- simple, but doesn't scale to many simultaneous clients. • Non-blocking: socket_set_nonblock() / stream_set_blocking($s, false) -- calls return immediately; requires polling or select() to wait efficiently. • Timeouts: socket_set_option(SO_RCVTIMEO) / stream_set_timeout() -- bound how long a call waits before giving up. • WebSockets start as a plain TCP connection with an HTTP upgrade handshake, then stay open reading/writing framed messages over that same socket -- which is why they need a long-running process, not FPM.
Raw Sockets Extension vs Stream Sockets, Side by Side
SOL_TCP / SO_REUSEADDR and friends require the raw sockets extension; stream sockets hide those details behind sensible defaults. stream_socket_server() combines create+bind+listen into a single call -- that's the main ergonomic win over the raw sockets API. Both examples here handle exactly one client and then exit -- a real server loops, accepting client after client (see the examples below).
<?php
// ---- Raw sockets extension (ext-sockets): server side ----
$sock = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_set_option($sock, SOL_SOCKET, SO_REUSEADDR, 1);
socket_bind($sock, '0.0.0.0', 9000);
socket_listen($sock, 20); // 20 = backlog queue size
$client = socket_accept($sock); // blocks until a client connects
$data = socket_read($client, 1024);
socket_write($client, "echo: {$data}");
socket_close($client);
socket_close($sock);
// ---- Stream sockets (PHP core): the same server, far less code ----
$server = stream_socket_server('tcp://0.0.0.0:9000', $errno, $errstr);
if (!$server) {
fwrite(STDERR, "Failed to bind: {$errstr} ({$errno})" . PHP_EOL);
exit(1);
}
$conn = stream_socket_accept($server); // blocks until a client connects
$data = fread($conn, 1024);
fwrite($conn, "echo: {$data}");
fclose($conn);
fclose($server);Code Examples
stream_socket_server() does bind + listen in one call. Each pass through the while loop blocks on stream_socket_accept() (the -1 argument means "wait forever", no timeout) until a new client connects; once one does, this simple server reads one line, echoes it back prefixed with 'echo: ', and closes that connection -- a real long-lived server would typically keep the connection open for multiple messages, or handle many connections concurrently rather than one at a time. Expected result: Echo server listening on tcp://127.0.0.1:9000 Client connected. Received: hello there Client disconnected. Testing this from another terminal is as simple as printf 'hello there\n' | nc 127.0.0.1 9000 (using netcat), which will print back echo: hello there.
while ($conn = stream_socket_accept($server, -1)) {... } stream_socket_client()'s fourth argument bounds how long the *connection attempt* itself is allowed to take -- a server that's unreachable (wrong IP, firewall silently dropping packets) would otherwise hang the script indefinitely. stream_set_timeout() separately bounds how long a *read* is allowed to wait once connected; stream_get_meta_data($client)['timed_out'] is how you detect, after a read returns, whether it returned because data arrived or because the timeout elapsed with nothing received. Expected result: Server replied: echo: hello there stream_socket_client('tcp://127.0.0.1:9000', $errno, $errstr, $timeoutSeconds);
Common mistake: Omitting the timeout argument entirely (or passing an excessively large one) is a common cause of PHP scripts that hang forever against an unreachable host -- always set a connection AND a read timeout for any socket code that talks to a real network, not just localhost testing.
UDP has no connection to establish, so there's no socket_listen()/socket_accept() step at all -- socket_recvfrom() simply blocks until any datagram arrives at the bound address, and fills $from/$port with whoever sent it, since (unlike TCP) a UDP socket can receive from a different sender on every single call. Replying uses socket_sendto(), explicitly naming the destination address each time, because there's no ongoing connection to just 'write back' to. Expected result: UDP listener ready on 127.0.0.1:9001 Received 11 bytes from 127.0.0.1:52344: hello there socket_recvfrom($socket, $buffer, 1024, 0, $from, $port);
<?php
declare(strict_types=1);
$server = stream_socket_server('tcp://127.0.0.1:9000', $errno, $errstr);
if ($server === false) {
fwrite(STDERR, "Could not bind: {$errstr} ({$errno})" . PHP_EOL);
exit(1);
}
fwrite(STDOUT, "Echo server listening on tcp://127.0.0.1:9000" . PHP_EOL);
while ($conn = stream_socket_accept($server, -1)) {
fwrite(STDOUT, "Client connected." . PHP_EOL);
$line = fgets($conn);
fwrite(STDOUT, "Received: " . trim((string) $line) . PHP_EOL);
fwrite($conn, "echo: " . $line);
fclose($conn);
fwrite(STDOUT, "Client disconnected." . PHP_EOL);
}
fclose($server);<?php
declare(strict_types=1);
$timeoutSeconds = 3;
$client = stream_socket_client(
'tcp://127.0.0.1:9000',
$errno,
$errstr,
$timeoutSeconds
);
if ($client === false) {
fwrite(STDERR, "Connection failed: {$errstr} ({$errno})" . PHP_EOL);
exit(1);
}
// Also bound how long a read is allowed to wait, separately
// from the connection timeout above.
stream_set_timeout($client, 5);
fwrite($client, "hello there\n");
$response = fgets($client);
$meta = stream_get_meta_data($client);
if ($meta['timed_out']) {
fwrite(STDERR, "Read timed out waiting for a response." . PHP_EOL);
fclose($client);
exit(1);
}
fwrite(STDOUT, "Server replied: " . trim((string) $response) . PHP_EOL);
fclose($client);<?php
declare(strict_types=1);
// UDP "server": no connection, no accept -- just receive datagrams.
$socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
socket_bind($socket, '127.0.0.1', 9001);
fwrite(STDOUT, "UDP listener ready on 127.0.0.1:9001" . PHP_EOL);
// Blocks until a datagram arrives; $from/$port are filled with the sender's address.
$bytesReceived = socket_recvfrom($socket, $buffer, 1024, 0, $from, $port);
fwrite(STDOUT, "Received {$bytesReceived} bytes from {$from}:{$port}: {$buffer}" . PHP_EOL);
// Reply directly to whoever sent it -- there's no persistent connection to write back on.
socket_sendto($socket, "ack: {$buffer}", 0, $from, $port);
socket_close($socket);TCP Server/Client Socket Flow
accept() and connect() are the moment a TCP connection is actually established; UDP skips all of this and just sends/receives addressed datagrams directly.
Server Client
------ ------
socket() create endpoint
|
bind() claim local address:port
|
listen() start queuing connections
|
accept() <========== blocks until ============ connect() (client reaches out
| a client connects \ to server's address:port)
v \ |
new per-client socket \ v
| \ connection established
|<================= send() / recv() (both directions) =================>|
|
close() close()Important Rules
Choose TCP when delivery/order guarantees matter; choose UDP when raw speed matters more than guaranteed delivery.
Default to stream sockets (stream_socket_server/client, fread/fwrite) for ordinary needs; use the raw sockets extension only for low-level options stream sockets don't expose.
Always set both a connection timeout and a read/write timeout for any socket that talks to a real network -- never assume the other side will always respond.
Check every socket call's return value (or the stream equivalent) -- a silently failing network operation is far worse than one that fails loudly.
Remember a WebSocket connection is a long-lived TCP socket that starts with an HTTP upgrade handshake -- it needs a long-running process, not a traditional per-request FPM worker.
Why This Matters in Production
Real-time PHP features -- live notifications, collaborative editing cursors, live dashboards -- are almost never built on plain PHP-FPM precisely because FPM's model (one short request, then the worker resets) cannot hold a connection open for the minutes or hours a WebSocket session needs; instead, teams run a dedicated long-running process (built directly on stream sockets, or via a library like Ratchet, or a runtime like Swoole that has socket handling built in) specifically to hold thousands of concurrent long-lived TCP connections open and multiplex between them with non-blocking I/O and select()-style waiting, which is exactly the machinery this lesson introduces. Understanding blocking vs non-blocking sockets and timeouts also directly explains a very common production bug: an HTTP client library with no timeout configured that occasionally hangs a request-handling worker forever when a downstream service stops responding, silently eating a slot from your entire FPM pool's capacity.
$conn = stream_socket_client('tcp://api.example.com:443'); // no timeout argument, no error variables checked
$conn = stream_socket_client('tcp://api.example.com:443', $errno, $errstr, 5); if ($conn === false) { /* handle $errstr/$errno */ }
Without an explicit timeout, a connection attempt to an unresponsive or unreachable host can hang the script indefinitely; without checking the return value and $errno/$errstr, a failed connection produces a confusing 'call to a member function on false' error later instead of a clear, actionable failure message at the point of connection.
socket_recvfrom($udpSocket, $buffer, 1024, 0, $from, $port); // treating this like it will always be the same sender, e.g. caching $from once
Re-read $from/$port on every single socket_recvfrom() call and validate/authenticate the sender per-datagram if it matters
UDP is connectionless -- a socket bound to one address can receive datagrams from many different senders over its lifetime, and each call fills $from/$port fresh with that specific datagram's sender; assuming the sender never changes (as you reasonably could with a single accepted TCP connection) is a UDP-specific mistake that can lead to replying to, or trusting, the wrong peer.
[multiple-choice] What is the key functional difference between TCP and UDP? A. TCP is connectionless and UDP requires a handshake B. TCP guarantees reliable, ordered delivery via a connection; UDP sends connectionless datagrams with no delivery or ordering guarantee C. UDP is only usable for encrypted traffic D. There is no real functional difference, only naming
B. TCP guarantees reliable, ordered delivery via a connection; UDP sends connectionless datagrams with no delivery or ordering guarantee — TCP establishes a connection via a handshake and guarantees ordered, reliable delivery (retransmitting lost data); UDP simply fires datagrams with no handshake, no delivery guarantee, and no ordering guarantee, in exchange for lower overhead.
[multiple-choice] In the raw sockets extension's server-side TCP flow, what does socket_accept() do? A. It sends data to a connected client B. It blocks until a client connects, then returns a new socket resource dedicated to that one client connection C. It closes the listening socket D. It is only used for UDP sockets
B. It blocks until a client connects, then returns a new socket resource dedicated to that one client connection — socket_accept() blocks the calling code until an incoming connection arrives on the listening socket, at which point it returns a brand-new socket resource for that specific client -- the original listening socket remains open to accept further connections.
[true-false] PHP's stream sockets (stream_socket_server, stream_socket_client) require the separate ext-sockets extension to be installed. A. True B. False
B. False — False -- stream socket functions are part of PHP core (the streams system also used for files), unlike the raw sockets extension (ext-sockets), which is a separate, optionally-compiled extension.
[multiple-choice] Why can't a standard PHP-FPM worker serve a WebSocket connection the same way it serves an HTTP request? A. WebSockets don't use TCP, so FPM can't route them at all B. FPM's model is one short-lived request per worker that resets afterward, while a WebSocket connection needs to stay open on the same socket for the life of the session -- so it needs a long-running process instead C. PHP cannot open raw sockets under any circumstances D. WebSockets require a different programming language entirely
B. FPM's model is one short-lived request per worker that resets afterward, while a WebSocket connection needs to stay open on the same socket for the life of the session -- so it needs a long-running process instead — A WebSocket connection starts with an HTTP upgrade handshake but then stays open as a long-lived TCP socket for as long as the session lasts, which conflicts with FPM's per-request worker reset model -- real WebSocket servers in PHP run as dedicated long-running processes built on sockets directly (or a runtime like Swoole) instead.
Modify the TCP echo server example so that instead of closing the connection after one message, it keeps a single client's connection open and echoes back every line the client sends until the client disconnects (fgets() returns false).
while ($conn = stream_socket_accept($server, -1)) {\n while (($line = fgets($conn)) !== false) {\n fwrite($conn, "echo: {$line}");\n }\n fclose($conn); // client disconnected\n}
Replace the single fgets()/fwrite()/fclose() sequence with an inner while loop that keeps reading lines from the same $conn until fgets() returns false, then close it.