Streams: Wrappers, Filters, Contexts, and Custom Protocols

Understand PHP's stream abstraction as the single API behind files, network sockets, and in-memory buffers -- and use stream wrappers, filters, contexts, and a custom stream_wrapper_register() implementation to read and write data uniformly regardless of where it actually lives.

Every time you call fopen(), file_get_contents(), or even include a file over HTTP, you're going through PHP's stream layer -- a single, uniform abstraction that treats a local file, a network socket, an FTP connection, and a block of memory as the same kind of thing: something you can open, read from, write to, and close. This lesson goes well beyond the fopen()/fread()/fwrite() basics covered in the earlier 'Files and Streams' lesson. Here the focus is the stream system itself: how wrappers decide what a URL like php://memory or https://api.example.com/data actually means, how filters transform data as it flows through a stream without you touching the raw bytes yourself, how contexts let you configure a request's headers and timeouts before you ever open it, and how to build your own stream wrapper so that fopen('myproto://something', 'r') runs code you wrote.

The Stream Concept and Stream Wrappers

A stream in PHP is a resource that represents a sequence of bytes you can read or write incrementally, without necessarily having the whole thing in memory at once. What makes PHP's streams distinctive is that the same small set of functions -- fopen(), fread(), fwrite(), fclose(), feof(), fseek() -- works identically no matter what the stream actually is underneath. That uniformity exists because of stream wrappers: registered handlers that intercept a URL-style prefix (a 'scheme', like file://, http://, php://, ftp://, or a custom one you register yourself) and translate the generic stream operations into whatever protocol-specific work is actually needed.

When you call fopen('https://api.example.com/users', 'r'), PHP looks at the https:// prefix, finds the registered wrapper for it, and hands control to that wrapper. The wrapper opens a TCP connection, performs TLS negotiation, sends an HTTP GET request, and then presents the HTTP response body to you through the exact same fread()/feof()/fclose() calls you'd use on a plain local file. You can list every wrapper currently registered with stream_get_wrappers(), and check whether a specific scheme is available with in_array('https', stream_get_wrappers(), true) -- useful because the http/https wrappers depend on the allow_url_fopen ini setting being enabled.

This design means application code that reads a 'resource identified by a URL' rarely needs to know or care whether that resource is a local file, a network endpoint, or an in-memory buffer -- swapping file:///tmp/data.csv for php://memory or https://example.com/data.csv can be a one-line change, because the reading code just calls fread() in a loop either way.

• A stream is a uniform, incremental read/write abstraction over bytes, regardless of the underlying source. • A stream wrapper is a registered handler for a URL scheme (file://, http://, php://, ftp://, or a custom one). • fopen(), fread(), fwrite(), fclose() work identically across every wrapper -- the wrapper does the scheme-specific work behind the scenes. • stream_get_wrappers() lists every registered scheme; useful for checking availability before relying on one. • allow_url_fopen (an ini setting) must be enabled for the http:// and https:// wrappers to work with fopen()-family functions.

file://, php://, and FTP Wrappers

file:// is the default wrapper and is so common it's implicit -- fopen('/var/log/app.log', 'r') and fopen('file:///var/log/app.log', 'r') are equivalent. The php:// wrapper family gives you access to PHP's own I/O streams and special in-process buffers. php://stdin, php://stdout, and php://stderr map to the process's standard input, output, and error streams -- most useful in CLI scripts, where php://stdin lets you read piped input line by line with fgets(STDIN) (STDIN is actually just a predefined constant holding an already-opened php://stdin handle).

php://memory and php://temp are both in-memory, write-then-read scratch streams that behave like a regular file stream but never touch a real file on disk (php://temp spills to a temporary file on disk once the data exceeds a size threshold -- 2MB by default -- while php://memory always stays in RAM, growing without a disk fallback). Both are extremely useful for building up output (like a CSV or XML document) using ordinary fwrite() calls and then reading the whole thing back with rewind() + stream_get_contents(), without creating a real temp file on the filesystem or paying disk I/O cost for small, transient data.

php://input gives you the raw, unparsed body of an incoming HTTP request (useful for reading raw JSON POST bodies that $_POST won't parse for you), and php://output writes directly to the same output buffer that echo would use. The FTP wrapper (ftp:// and ftps://) lets you treat a remote FTP server like a stream: fopen('ftp://user:[email protected]/path/file.txt', 'r') opens a readable stream over an FTP connection, and file_put_contents('ftp://user:[email protected]/path/upload.txt', $data) uploads by simply writing to the stream -- no separate FTP-specific API needed.

• file:// -- the default, implicit wrapper for local filesystem paths. • php://stdin / php://stdout / php://stderr -- the process's standard streams, mainly used in CLI scripts. • php://memory -- always RAM-backed scratch stream; php://temp -- RAM-backed until a size threshold, then spills to disk. • php://input -- the raw HTTP request body; php://output -- writes directly to the output buffer. • ftp:// / ftps:// -- read and write remote files over FTP using the same fopen()/fread()/fwrite() calls as any other stream.

Stream Filters and Stream Contexts

A stream filter transforms data as it passes through a stream, without you needing to buffer the whole thing and transform it manually. Filters are attached with stream_filter_append() and are applied automatically on every subsequent read or write through that stream handle. PHP ships several built-in filters: string.rot13 (a trivial substitution cipher, mostly used for demonstration), string.toupper / string.tolower, convert.base64-encode / convert.base64-decode, and the zlib.deflate / zlib.inflate pair, which transparently compresses data as it's written or decompresses it as it's read -- letting you write $data to a stream and have gzip-compressed bytes land on disk, without calling gzencode() yourself first.

A stream context is a bundle of protocol-specific options you attach to a stream before opening it, created with stream_context_create() and passed as the last argument to fopen(), file_get_contents(), or file_put_contents(). For the http wrapper, context options let you set the request method, custom headers, a timeout, the request body, and whether to follow redirects -- effectively turning file_get_contents() into a lightweight HTTP client for simple requests, without needing curl. Options are organized by wrapper name: ['http' => [...]] configures the http/https wrapper, ['ftp' => [...]] configures FTP, and so on.

Contexts matter especially for reliability: an https:// request made with the default context has no timeout ceiling appropriate for production use and sends no custom headers (like Authorization or a JSON Content-Type), so any real integration should build an explicit context rather than relying on defaults. A context object, once created, can be reused across multiple fopen()/file_get_contents() calls that need the same settings.

• stream_filter_append() attaches a transform (rot13, base64, zlib compression, or a custom filter) to a stream handle. • Filters apply automatically to every subsequent read/write through that handle -- no manual buffering needed. • stream_context_create() builds a bundle of protocol-specific options (headers, method, timeout, body). • Context options are grouped by wrapper name: ['http' => [...]], ['ftp' => [...]], etc. • A context is passed as the final argument to fopen()/file_get_contents()/file_put_contents().

Custom Wrappers, Blocking, and Resource Cleanup

You can register your own stream wrapper with stream_wrapper_register('scheme', ClassName::class), making fopen('scheme://anything', 'r') route to methods on your class -- stream_open(), stream_read(), stream_write(), stream_eof(), and stream_close() (plus optional ones like stream_stat() and url_stat() for filesystem-like introspection). This is how testing libraries mock the filesystem, how some frameworks implement virtual, in-memory 'file' resources, and how cloud SDKs expose S3 buckets or similar object stores as though they were ordinary files -- writing to s3://bucket/key.txt instead of calling an upload API by name.

Streams can behave in blocking or non-blocking mode, set with stream_set_blocking($stream, false). In blocking mode (the default), a read call like fread() waits until data is actually available (or the stream ends), which is simple to reason about but means your script can stall on a slow network peer. In non-blocking mode, fread() returns immediately with whatever data is currently available -- possibly an empty string -- letting a script poll multiple streams (often with stream_select()) instead of getting stuck waiting on one of them. Non-blocking mode is what makes it possible to handle several sockets 'concurrently' in a single PHP process without threads.

Streams are also buffered by default: writes may sit in an internal buffer before actually reaching the underlying resource, which is why fflush() exists to force pending writes out immediately (useful, for example, right before you know a client is about to read what you just wrote). Regardless of mode or buffering, every opened stream is a finite OS-level resource (a file descriptor or socket), so failing to fclose() it -- especially in a long-running worker process or inside a loop that opens many streams -- leaks file descriptors and will eventually exhaust the process's limit, causing subsequent fopen() calls to fail entirely. A try/finally (or the with-resource-cleanup discipline of always closing what you opened) is the correct habit, not an optional nicety.

• stream_wrapper_register('scheme', Class::class) makes fopen('scheme://...') dispatch to your class's methods. • Custom wrappers implement stream_open(), stream_read(), stream_write(), stream_eof(), stream_close(), and optionally stream_stat(). • stream_set_blocking($stream, false) switches a stream to non-blocking mode; fread() then returns immediately, possibly empty. • stream_select() lets one process poll multiple non-blocking streams instead of stalling on any single one. • Every open stream holds a real OS resource (file descriptor/socket) -- always fclose() it, ideally in a finally block.

Opening a Stream with a Context

Multiple headers go on one 'header' string, each line terminated with \r\n. 'timeout' is in seconds and bounds how long the connection attempt/read may take. false is the (ignored, legacy) use_include_path parameter -- always false for URLs.

$context = stream_context_create([
    'http' => [
        'method'  => 'GET',
        'header'  => "Accept: application/json\r\nAuthorization: Bearer abc123\r\n",
        'timeout' => 5.0,
    ],
]);

$body = file_get_contents('https://api.example.com/users', false, $context);

Code Examples

php://memory behaves exactly like a regular file stream -- fputcsv() writes to it row by row -- but the bytes never touch disk. rewind() resets the internal pointer to the start so the subsequent stream_get_contents() reads everything that was written, and the finally block guarantees the resource is released even if something above it throws. Expected result: id,name 1,"Ada Lovelace" 2,"Grace Hopper" $stream = fopen('php://memory', 'r+');

The http wrapper sends the configured method, header, and timeout, and (with ignore_errors true) still returns the response body even on a non-2xx status instead of just failing. PHP automatically populates the magic $http_response_header array with the raw response headers, including the status line, right after a successful http:// stream read. For anything beyond simple GET requests -- retries, connection pooling, multipart uploads -- curl or an HTTP client library is usually a better fit than raw stream contexts, but for a single simple request, no extra dependency is needed.

$json = @file_get_contents('https://api.example.com/status', false, $context);

Once stream_filter_append() attaches the zlib.deflate filter for writes, every subsequent fwrite() on $out is transparently compressed before it reaches the file -- the calling code never sees or manages the compressed bytes directly. Closing the stream is what flushes the filter's internal buffer, so skipping fclose() (or crashing before it runs) can leave a truncated, corrupt compressed file.

stream_filter_append($out, 'zlib.deflate', STREAM_FILTER_WRITE, ['level' => 6]);

Common mistake: Forgetting to fclose() the stream after the last fwrite() -- the zlib filter buffers internally, so data written right before an unflushed exit can be silently lost or produce a corrupt archive.

stream_wrapper_register() maps the 'arraystore' scheme to our class, so fopen()/file_get_contents()/file_put_contents() calls against arraystore://... transparently invoke stream_open(), stream_write(), stream_read(), and stream_eof() on a fresh instance of ArrayStoreWrapper. The 'file' contents live entirely in a static PHP array rather than on disk -- exactly the technique testing libraries and mock filesystems use. Expected result: hello from a custom wrapper stream_wrapper_register('arraystore', ArrayStoreWrapper::class);

<?php
declare(strict_types=1);

$rows = [
    ['id' => 1, 'name' => 'Ada Lovelace'],
    ['id' => 2, 'name' => 'Grace Hopper'],
];

$stream = fopen('php://memory', 'r+');
if ($stream === false) {
    throw new RuntimeException('Could not open memory stream');
}

try {
    fputcsv($stream, ['id', 'name']);
    foreach ($rows as $row) {
        fputcsv($stream, [$row['id'], $row['name']]);
    }

    rewind($stream);
    $csv = stream_get_contents($stream);
} finally {
    fclose($stream);
}

echo $csv;
<?php
declare(strict_types=1);

$context = stream_context_create([
    'http' => [
        'method'        => 'GET',
        'header'        => "Accept: application/json\r\n",
        'timeout'       => 3.0,
        'ignore_errors' => true, // so a 4xx/5xx still returns a body instead of a warning-only false
    ],
]);

$json = @file_get_contents('https://api.example.com/status', false, $context);

if ($json === false) {
    throw new RuntimeException('Request failed or timed out');
}

// $http_response_header is populated automatically by the http wrapper after the call.
$statusLine = $http_response_header[0] ?? 'unknown status';
$data = json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR);

echo $statusLine . "\n";
echo $data['status'] . "\n";
<?php
declare(strict_types=1);

$out = fopen('/tmp/log-export.gz', 'wb');
if ($out === false) {
    throw new RuntimeException('Could not open output file');
}

stream_filter_append($out, 'zlib.deflate', STREAM_FILTER_WRITE, [
    'level' => 6,
]);

try {
    foreach (['line one', 'line two', 'line three'] as $line) {
        fwrite($out, $line . "\n");
    }
} finally {
    fclose($out); // flushes any buffered, not-yet-compressed bytes through the filter
}
<?php
declare(strict_types=1);

final class ArrayStoreWrapper
{
    /** @var array<string, string> path => contents, shared across the whole process */
    private static array $store = [];

    private string $key = '';
    private int $position = 0;

    public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
    {
        $this->key = parse_url($path, PHP_URL_HOST) . parse_url($path, PHP_URL_PATH);
        if (!isset(self::$store[$this->key]) && str_contains($mode, 'r')) {
            return false; // reading something that was never written
        }
        if (str_contains($mode, 'w')) {
            self::$store[$this->key] = '';
        }
        $this->position = 0;
        return true;
    }

    public function stream_write(string $data): int
    {
        self::$store[$this->key] .= $data;
        $this->position += strlen($data);
        return strlen($data);
    }

    public function stream_read(int $count): string
    {
        $chunk = substr(self::$store[$this->key], $this->position, $count);
        $this->position += strlen($chunk);
        return $chunk;
    }

    public function stream_eof(): bool
    {
        return 
…

Stream Architecture: Application to Backing Resource

One API, many backends -- the wrapper (plus any attached filters) is what translates generic stream calls into scheme-specific behavior.

Application code
  fopen() / fread() / fwrite() / fclose()
            |
            v
      Stream API (uniform interface)
            |
            v
   [ scheme resolved: file:// http:// php:// ftp:// custom:// ]
            |
            v
         Wrapper  --(optional)-->  Filters (rot13, zlib, base64, custom)
            |
   +--------+---------+----------+-----------+
   v        v         v          v           v
 File    Network   Memory     STDIO       Custom
(disk)  (http/ftp) (php://mem) (stdin/out) (your class)

Important Rules

Always fclose() every stream you open, ideally in a finally block -- open streams hold real OS file descriptors.

Use php://memory for small, transient data and php://temp when the size might occasionally be large (it spills to disk automatically).

Build an explicit stream context (method, headers, timeout) for any real http:// request -- don't rely on defaults.

Attach stream filters before reading/writing, and close the stream to flush a write-side filter's internal buffer.

A custom wrapper must be registered with stream_wrapper_register() once per process, before it's first used.

Prefer non-blocking mode + stream_select() over blocking reads when a single process needs to watch multiple streams at once.

Why This Matters in Production

Stream wrappers are why frameworks can swap a local upload directory for an S3 bucket, or a real file for a test double, by changing a URL prefix instead of rewriting file-handling code -- object storage SDKs commonly register an s3:// (or similar) wrapper so the rest of the application never needs a special-cased upload API. Stream contexts are the quiet foundation behind every file_get_contents('https://...') call that actually sets a sane timeout instead of hanging a worker process indefinitely on a slow upstream, which is a real, recurring cause of production incidents when it's skipped.

$data = file_get_contents('https://api.example.com/data'); // no context: no timeout, no headers, default settings

$ctx = stream_context_create(['http' => ['timeout' => 5.0, 'header' => "Accept: application/json\r\n"]]); $data = file_get_contents('https://api.example.com/data', false, $ctx);

Without an explicit context, the request has no meaningful timeout ceiling and sends no headers your API might require -- a slow or hanging upstream can block the request far longer than acceptable in a production request path.

$out = fopen('/tmp/data.gz', 'wb'); stream_filter_append($out, 'zlib.deflate'); fwrite($out, $data); // stream never closed, script continues to other work

$out = fopen('/tmp/data.gz', 'wb'); stream_filter_append($out, 'zlib.deflate', STREAM_FILTER_WRITE); try { fwrite($out, $data); } finally { fclose($out); }

The zlib.deflate filter buffers compressed output internally; closing the stream is what forces the remaining buffered bytes out. Skipping fclose() can leave a truncated .gz file that fails to decompress, on top of leaking the file descriptor.

[multiple-choice] What is the core job of a PHP stream wrapper? A. It compresses every stream automatically B. It intercepts a URL scheme (file://, http://, php://, or a custom one) and translates generic fopen()/fread()/fwrite() calls into the protocol-specific work needed for that resource C. It converts all streams to UTF-8 D. It is only used for reading, never writing

B. It intercepts a URL scheme (file://, http://, php://, or a custom one) and translates generic fopen()/fread()/fwrite() calls into the protocol-specific work needed for that resource — A wrapper is what lets the same fopen()/fread()/fwrite()/fclose() calls work identically whether the target is a local file, an HTTP endpoint, an FTP server, or an in-memory buffer -- it handles the scheme-specific mechanics behind that uniform interface.

[multiple-choice] What is the practical difference between php://memory and php://temp? A. They are identical in every respect B. php://memory always stays in RAM; php://temp starts in RAM but spills to a temporary disk file once the data exceeds a size threshold C. php://temp is read-only D. php://memory can only be used in CLI scripts

B. php://memory always stays in RAM; php://temp starts in RAM but spills to a temporary disk file once the data exceeds a size threshold — Both are transient, non-persistent scratch streams, but php://temp automatically falls back to disk for larger payloads (2MB by default) so it won't exhaust memory, while php://memory keeps growing in RAM regardless of size.

[multiple-choice] Why must a stream that has a write-side filter (like zlib.deflate) attached be explicitly closed with fclose()? A. fclose() is optional for filtered streams B. Filters can buffer data internally, and closing the stream is what flushes that remaining buffered, not-yet-processed data out -- skipping it can produce truncated or corrupt output C. fclose() re-registers the filter D. Filters only work after the stream is closed

B. Filters can buffer data internally, and closing the stream is what flushes that remaining buffered, not-yet-processed data out -- skipping it can produce truncated or corrupt output — A filter like zlib.deflate accumulates data before emitting compressed output; fclose() forces any remaining buffered bytes through the filter and out to the underlying resource, so skipping it risks a truncated or unreadable file.

Write a function exportUsersAsJsonGz(array $users, string $path) that writes $users as JSON to a gzip-compressed file at $path, using a stream filter (not gzencode()) so the compression happens as the data is written.

function exportUsersAsJsonGz(array $users, string $path): void { $out = fopen($path, 'wb'); if ($out === false) { throw new RuntimeException('cannot open ' . $path); } stream_filter_append($out, 'zlib.deflate', STREAM_FILTER_WRITE); try { fwrite($out, json_encode($users, JSON_THROW_ON_ERROR)); } finally { fclose($out); } }

Open the destination with fopen(..., 'wb'), attach zlib.deflate with stream_filter_append() before writing, encode $users with json_encode(), fwrite() the JSON string, then close the stream inside a finally block.