PHP streams and non-blocking I/O
Concept
PHP streams are a unified abstraction over any data source or sink that behaves like a byte sequence: files, network sockets, stdin/stdout, compressed data, encrypted channels, custom user-space protocols. Every stream resource has a "wrapper" that implements the actual transport, a set of optional "filters" that transform data in flight, and a "context" that carries per-operation options.
By default, PHP I/O functions are blocking: fread($socket, 4096) will wait until 4096 bytes arrive or the connection closes. This is fine for FPM (one request per process, blocking is safe) but fatal for event loops — one blocking fread stalls the entire loop. Non-blocking mode is set with stream_set_blocking($resource, false). In non-blocking mode, fread() returns immediately with whatever bytes are available (possibly an empty string), and fwrite() may write less than requested. You then use stream_select() to find which streams are ready before reading or writing.
Stream wrappers are registered with stream_wrapper_register() and define how URIs like s3://bucket/key or compress.zlib://data.gz are opened. PHP ships with built-in wrappers: file://, http://, ftp://, php://stdin, php://memory, php://temp, data://. You can register your own by implementing streamWrapper (actually a duck-typed class with specific methods: stream_open, stream_read, stream_write, etc.).
Stream filters attach to any stream and transform data as it passes through: stream_filter_append($fp, 'zlib.deflate') compresses everything written to $fp. Built-in filters include string.rot13, string.toupper, convert.base64-encode, zlib.deflate, zlib.inflate, mcrypt.*. Custom filters extend php_user_filter.
Stream contexts (stream_context_create()) pass metadata to wrappers: HTTP headers for file_get_contents('http://...'), SSL peer certificate verification options, socket timeout values.
Code Example
<?php
declare(strict_types=1);
// -------------------------------------------------------
// 1. Non-blocking socket with stream_select()
// -------------------------------------------------------
$sockets = [];
// Open two non-blocking connections.
foreach (['httpbin.org:80', 'example.com:80'] as $host) {
$sock = stream_socket_client("tcp://{$host}", $errno, $errstr, 5);
if ($sock === false) {
throw new \RuntimeException("Connect failed: $errstr ($errno)");
}
stream_set_blocking($sock, false);
// Send an HTTP request immediately — may not flush entirely in non-blocking mode.
$request = "GET / HTTP/1.0\r\nHost: {$host}\r\nConnection: close\r\n\r\n";
fwrite($sock, $request);
$sockets[] = $sock;
}
$buffer = array_fill(0, count($sockets), '');
$pending = $sockets;
// Loop until all sockets have closed.
while (!empty($pending)) {
$read = $pending;
$write = null;
$except = null;
// stream_select() blocks until at least one socket is readable,
// or the 5-second timeout expires.
$changed = stream_select($read, $write, $except, 5);
if ($changed === false) {
throw new \RuntimeException('stream_select() failed');
}
foreach ($read as $socket) {
$chunk = fread($socket, 8192);
$key = array_search($socket, $sockets, true);
if ($chunk === false || feof($socket)) {
fclose($socket);
$pending = array_filter($pending, fn($s) => $s !== $socket);
} else {
$buffer[$key] .= $chunk;
}
}
}
foreach ($buffer as $i => $response) {
$lines = explode("\r\n", $response);
echo "Response {$i}: {$lines[0]}\n"; // e.g. "HTTP/1.0 200 OK"
}
// -------------------------------------------------------
// 2. php://memory — in-memory stream for testing I/O code
// -------------------------------------------------------
$memory = fopen('php://memory', 'r+');
fwrite($memory, "line one\nline two\nline three\n");
rewind($memory);
while (!feof($memory)) {
$line = fgets($memory);
if ($line !== false) {
echo trim($line) . "\n";
}
}
fclose($memory);
// -------------------------------------------------------
// 3. Stream filter — compress a file on the fly
// -------------------------------------------------------
$output = fopen('/tmp/demo.gz', 'wb');
stream_filter_append($output, 'zlib.deflate', STREAM_FILTER_WRITE, ['level' => 6]);
// Everything written here is compressed before hitting the file.
fwrite($output, "This content will be gzip-compressed.\n");
fwrite($output, str_repeat("Lorem ipsum. ", 1000));
fclose($output);
echo "Compressed file size: " . filesize('/tmp/demo.gz') . " bytes\n";
// -------------------------------------------------------
// 4. Custom stream context — HTTP with custom headers
// -------------------------------------------------------
$context = stream_context_create([
'http' => [
'method' => 'GET',
'header' => "Accept: application/json\r\nAuthorization: Bearer token123",
'timeout' => 5,
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
'cafile' => '/etc/ssl/cert.pem',
],
]);
$body = file_get_contents('https://httpbin.org/get', false, $context);Interview Q&A
Q: What is the difference between php://memory and php://temp, and when would you use each?
Both are in-memory read/write streams. php://memory always keeps its contents in RAM regardless of size — useful for small, bounded buffers like test fixtures or API response stubs. php://temp starts in memory but automatically spills to a temporary file once the data exceeds a configurable threshold (default 2 MB, controllable via php://temp/maxmemory:N). Use php://temp when you are streaming an HTTP upload, building a response body, or processing data whose size is unknown — it prevents out-of-memory errors while still being fast for small payloads. Both are automatically destroyed when the handle is closed.
Q: Why does stream_set_blocking($sock, false) still require stream_select() rather than just reading in a loop?
In non-blocking mode, fread() returns an empty string when no data is available yet (rather than waiting). If you spin in a tight loop calling fread() repeatedly, you waste an entire CPU core doing nothing — this is a busy-wait or spin-lock. stream_select() makes one syscall that tells the OS "wake me when any of these descriptors has data" — the process sleeps in the kernel scheduler with zero CPU usage until data arrives. stream_select() is what makes event loops efficient: the process is genuinely idle between I/O events, leaving the CPU free for other processes or to enter a low-power state.
Q: What happens to stream filters when data is written in chunks that do not align with the filter's internal buffer?
Filters maintain an internal buffer and process data in chunks appropriate to their algorithm. For zlib.deflate, the filter accumulates input until it has enough to produce a compressed block; it may not flush output on every fwrite(). This means fwrite($fp, 'small chunk') might produce no output bytes immediately — the data is inside the filter's buffer. Calling fflush($fp) forces all pending filtered data to flush to the underlying stream. When fclose() is called, all filters are flushed and finalized automatically. This is why compression filters produce different-sized output blocks for the same data depending on chunk size — the zlib block boundaries differ.