Registering Custom Stream Wrappers in PHP: stream_wrapper_register Explained
AI generated
8.4
PHP · Streams
Registering Custom Stream Wrappers in PHP
How fopen('s3://bucket/key') and friends actually work, and how to use that mechanism yourself

The fact that fopen, file_get_contents, and file_put_contents can handle not just local paths but also prefixes like http:// or php:// comes down to a single mechanism: the stream wrapper registry. Understanding how stream_wrapper_register works and which methods a wrapper class must implement lets you register your own protocols, whether for cloud storage or an in-memory filesystem that makes file access testable in unit tests without a real disk.

11 min read stream_wrapper_register Testable file access

1. What a stream wrapper is and how PHP resolves protocol prefixes

Every call to fopen, file_get_contents, or file_put_contents internally starts with the same step: PHP inspects the given path for a protocol prefix of the form protocol://. If it finds a colon followed by two slashes there, PHP looks up an internal registry to determine which class is responsible for that protocol, and delegates every filesystem operation to that class instead of executing it directly against the native operating system filesystem. If no prefix is present, the built-in file:// wrapper kicks in implicitly, delivering exactly that native behavior.

This registry is designed to be extensible from the ground up: alongside the built-in wrappers for http://, php://, zlib://, or data://, stream_wrapper_register lets you register your own protocol at any time, with an implementation that lives entirely in userland. Once registered, every standard function such as fopen, fread, fwrite, is_dir, or unlink works transparently with the new protocol, without the calling code ever needing to know that behind the prefix sits your own PHP code rather than a real disk.

2. The signature of stream_wrapper_register and its constraints

The function expects three parameters: the protocol name as a string without the :// characters, the fully qualified class name of the wrapper implementation, and an optional flag bitmask, such as STREAM_IS_URL, signaling that this is a URL-based resource. A central gotcha here concerns the wrapper class's constructor: PHP instantiates that class internally itself, but never calls a custom constructor with parameters, meaning any configuration cannot go through the constructor and must instead flow through static properties, a dependency injection registry, or the context mechanism.

A second gotcha concerns registration itself: an already taken protocol name, such as http, cannot simply be overwritten without first explicitly releasing it via stream_wrapper_unregister, and attempting to register the same custom name twice triggers a warning and returns false. In production code, registration should therefore always be defensive, for example by checking beforehand with in_array against stream_get_wrappers.


<?php

declare(strict_types=1);

/**
 * Defensively registers the memfs:// wrapper if it is not already
 * present, avoiding a duplicate registration error.
 *
 * @return void
 */
function registerMemfsWrapper(): void
{
    if (in_array('memfs', stream_get_wrappers(), true)) {
        return;
    }

    stream_wrapper_register('memfs', InMemoryStreamWrapper::class);
}

registerMemfsWrapper();

3. Required and optional methods of the wrapper convention

Unlike a classic interface, PHP does not check the wrapper class against a declared interface, it simply calls methods with a fixed name and a fixed signature if they exist. For read access, stream_open to open, stream_read to read individual chunks, stream_eof to check for end of file, and stream_close to close are practically indispensable, since without them even a simple fopen followed by fread already fails.

For write access, stream_write is added, for directory operations url_stat, dir_opendir, dir_readdir, and dir_closedir, and for metadata such as file size or modification time, the method stream_stat, which must return an array in the format of PHP's built-in stat. If a wrapper class does not implement one of these methods, PHP simply reports an error when the corresponding standard function is called, since the missing method is interpreted as an unsupported operation.

4. stream_stat and url_stat: returning metadata correctly

stream_stat and url_stat both use the same return format as PHP's built-in stat function, meaning an array with the classic 13 numerically indexed fields plus the same values again under readable string keys such as size, mtime, or mode. The difference between the two methods lies in when they are called: stream_stat is called on a resource already opened via stream_open, while url_stat operates directly on a path with no prior opening at all, for example when functions such as is_file, file_exists, or filesize are called.

If url_stat is missing from the wrapper class, exactly those functions wrongly report that the file does not exist, even though stream_open could subsequently open it successfully, because PHP specifically queries url_stat first for pure existence checks instead of opening a full resource. The mode field matters a great deal here, since a missing or incorrect directory bit value there makes functions such as is_dir wrongly return false, even though the wrapper is actually managing the corresponding virtual directory correctly.


<?php

declare(strict_types=1);

/**
 * Returns metadata for a path without opening the resource via
 * stream_open first, e.g. for is_file() or file_exists().
 *
 * @param string $path Full path including the memfs:// prefix
 * @param int $flags Bitmask of STREAM_URL_STAT_ flags
 * @return array<int|string, int>|false Stat array, or false if unknown
 */
public function url_stat(string $path, int $flags): array|false
{
    if (!isset(self::$files[$path])) {
        return false;
    }

    $size = strlen(self::$files[$path]);

    return [
        'dev' => 0, 'ino' => 0, 'mode' => 0100644, 'nlink' => 1,
        'uid' => 0, 'gid' => 0, 'rdev' => 0, 'size' => $size,
        'atime' => time(), 'mtime' => time(), 'ctime' => time(),
        'blksize' => -1, 'blocks' => -1,
    ];
}

5. Example: an in-memory filesystem for unit tests

The most practically valuable use case for a hand-written stream wrapper in a normal application is an in-memory filesystem for tests. Code that internally works with fopen, fwrite, or file_get_contents against a configurable path can be tested entirely without real disk access, simply by pointing the path to a memfs:// prefix in tests while it continues pointing to a real directory in production.

At its core, such a wrapper class simply holds file content in a static array property, keyed by path, while an internal read pointer offset simulates the position within the file. The following excerpt shows the most important methods of a minimal but functional implementation that supports both reading and writing.


<?php

declare(strict_types=1);

/**
 * Minimal in-memory stream wrapper for the memfs:// protocol, intended
 * for unit tests without real filesystem access.
 */
final class InMemoryStreamWrapper
{
    /** @var array<string, string> File contents keyed by path */
    private static array $files = [];

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

    /**
     * Opens an in-memory file for read or write access.
     *
     * @param string $path Full path including the memfs:// prefix
     * @param string $mode The fopen mode passed in, e.g. "r" or "w"
     * @param int $options Bitmask of STREAM_ flags
     * @param string|null $openedPath Reference to the actually opened path
     * @return bool True if opening succeeded
     */
    public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
    {
        $this->path = $path;

        if (str_starts_with($mode, 'w')) {
            self::$files[$path] = '';
        }

        return isset(self::$files[$path]) || str_starts_with($mode, 'w');
    }

    /**
     * Reads up to $count bytes from the current read position.
     *
     * @param int $count Maximum number of bytes to read
     * @return string The bytes that were read
     */
    public function stream_read(int $count): string
    {
        $chunk = substr(self::$files[$this->path] ?? '', $this->position, $count);
        $this->position += strlen($chunk);

        return $chunk;
    }

    /**
     * Appends data to the file content at the current position.
     *
     * @param string $data The data to write
     * @return int Number of bytes actually written
     */
    public function stream_write(string $data): int
    {
        self::$files[$this->path] = (self::$files[$this->path] ?? '') . $data;
        $this->position += strlen($data);

        return strlen($data);
    }

    /**
     * Checks whether the end of the in-memory file has been reached.
     *
     * @return bool True at end of file
     */
    public function stream_eof(): bool
    {
        return $this->position >= strlen(self::$files[$this->path] ?? '');
    }
}

6. Skeleton for an S3-like protocol using context options

For a protocol like s3://bucket/key, a plain in-memory store is not enough, since stream_open instead needs to actually issue an HTTP request against the S3 API. To get access credentials like an access key and secret key into the wrapper class, without a way to pass them through a parameterless constructor, the context mechanism comes into play: stream_context_create accepts an options array, and inside the wrapper class the $context property, which PHP sets automatically, gives access to exactly those options via stream_context_get_options.

That lets you cleanly separate the actual network access from the wrapper logic: stream_open reads the credentials from the context, builds a signed request from them, and internally initializes something like a buffer with the downloaded response, while stream_read subsequently only reads from that already loaded buffer. This separation keeps the wrapper class testable, since the actual HTTP client can be swapped out via dependency injection, even though the wrapper instance itself is created by PHP without constructor parameters.


<?php

declare(strict_types=1);

$context = stream_context_create([
    's3' => [
        'access_key' => getenv('S3_ACCESS_KEY'),
        'secret_key' => getenv('S3_SECRET_KEY'),
        'region' => 'eu-central-1',
    ],
]);

$handle = fopen('s3://invoices-bucket/2026/invoice-4711.pdf', 'r', false, $context);

7. Improving testability of file access with custom wrappers

The real payoff of a custom stream wrapper is rarely replacing a mature cloud SDK, but rather the ability to make code that is tightly wired to fopen, file_put_contents, or SplFileObject against a path testable without restructuring it. Instead of wrapping every file-writing class in a swappable filesystem abstraction, it is often enough to make the base path configurable and use a memfs:// prefix in tests.

It matters to explicitly reset the wrapper before every test, for example by clearing the static array property in a setUp method, since otherwise tests can unintentionally depend on each other through shared static state. Alternatives like the mikey179/vfsstream library already solve the same problem with considerably more functionality, such as permission simulation, but for simple cases that is often more than what is actually needed.

8. Pitfalls: conflicts with include, performance, and error handling

A custom wrapper generally also works with include and require, as long as the prefix is used, which in combination with dynamic code from a database or a network store is theoretically tempting but security-wise risky, since PHP performs no provenance check on the loaded code at that point. Likewise, a wrapper should never override PHP's own include_path for critical system files, since that produces hard-to-trace bugs once standard libraries suddenly run through a foreign wrapper.

Performance is another point: since every method call goes through the userland PHP code of the wrapper class instead of directly executing a system call, a custom wrapper is inherently slower than the native file:// wrapper, which becomes noticeable with very frequent small reads. On error handling, a stream wrapper should not throw exceptions, since PHP does not handle those consistently at that layer, and should instead use trigger_error with E_USER_WARNING, so that its error behavior matches that of the built-in wrappers.

9. When a custom wrapper pays off, and when an existing solution is enough

A hand-written stream wrapper pays off mainly when existing code is inseparably wired to the native file functions and restructuring it around a filesystem abstraction like league/flysystem would be too costly, or when an actually new protocol not yet supported in PHP needs to be connected. For pure testability, a minimal in-memory wrapper is often faster to write yourself than to pull in an external dependency, especially when only a handful of methods are actually needed.

For production cloud storage access, on the other hand, a custom implementation is rarely worth it: league/flysystem already offers mature adapters for S3, Google Cloud Storage, and Azure Blob Storage, including retry logic, streaming of large files, and consistent error handling, something a hand-written wrapper would only reach after many iterations. A custom wrapper thus remains primarily a tool for testability, prototyping, and integrating genuinely exotic data sources not supported elsewhere.

Method Purpose Needed for reading? Needed for writing?
stream_open() Opens the resource for a given path Yes Yes
stream_read() Reads a chunk from the current position Yes No
stream_write() Writes data at the current position No Yes
stream_eof() Checks whether end of file is reached Yes No
stream_stat() Returns metadata such as size and timestamps Recommended Recommended
stream_close() Closes the resource and releases it Yes Yes
url_stat() Returns metadata without opening first (for is_file etc.) Recommended Recommended

Mironsoft

PHP modernization, code quality, and legacy refactoring

Grown PHP code nobody wants to touch anymore?

We modernize PHP codebases to current language standards, introduce static analysis and coding standards, and refactor legacy code step by step without endangering live operations.

Legacy Refactoring

Modernize grown PHP code in a structured, low-risk way.

Establishing Code Quality

Anchor PHPStan, coding standards, and CI checks sustainably in the team.

Version Upgrades

Plan and execute PHP major version upgrades safely, without downtime.

10. Summary

Custom Stream Wrappers: The Essentials at a Glance

Protocol registry

PHP resolves every prefixed path through an internal registry and delegates to the matching wrapper class.

No constructor

PHP instantiates wrapper classes without parameters, configuration flows through context or static state.

Convention-based methods

PHP checks no interface, it calls known method names such as stream_open if they exist.

Testability as the core benefit

An in-memory wrapper makes file-based code testable without restructuring the application logic itself.

11. FAQ: Custom Stream Wrappers: The Essentials at a Glance

1Must a stream wrapper class implement a specific interface?
No, PHP checks no declared interface, it calls methods with a fixed name such as stream_open at runtime if the class has them. Missing methods lead to an 'unsupported' error on the corresponding calls.
2Why can a wrapper class not have a custom constructor with parameters?
PHP instantiates the class internally itself and does not call a custom constructor with arguments. Configuration must therefore flow through the context mechanism, static properties, or an external registry.
3How do you pass configuration data like access keys to a custom wrapper?
Through stream_context_create and the resulting context, which PHP automatically makes available to the wrapper instance as a $context property and which can be read via stream_context_get_options.
4Can the built-in http:// wrapper be replaced with a custom one?
Yes, but only after explicitly calling stream_wrapper_unregister for http, since PHP does not silently overwrite already taken protocol names. That is rarely a good idea and can unexpectedly affect many libraries.
5Is a custom stream wrapper slower than native file access?
Yes, because every method call goes through userland PHP code instead of directly executing a system call. With many small reads the difference is measurable, with a few large operations it is usually negligible.
6Should a stream wrapper throw exceptions when an access fails?
No, trigger_error with E_USER_WARNING is preferable, since PHP does not handle exceptions consistently in this context and calling code is often prepared for the classic warning mechanism instead.
7Does include work with a custom protocol prefix?
Generally yes, as long as the prefix is used in the include call, but that is security-wise risky, since PHP performs no provenance check on the loaded code in that case.
8How do you cleanly reset an in-memory wrapper between test cases?
Easiest by explicitly clearing the static array property holding the file content in the test case's setUp method, so tests do not depend on each other through shared static state.
9Is a custom wrapper worth writing for production S3 access?
Usually not. Libraries such as league/flysystem already offer mature S3 adapters with retry logic and streaming that a hand-written wrapper would only reach after many iterations.
10Can a stream wrapper also support directory operations like readdir?
Yes, through the methods dir_opendir, dir_readdir, and dir_closedir, which together simulate a virtual directory and are used by functions such as opendir or scandir once the protocol prefix is recognized.