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.
Table of Contents
- 1. What a stream wrapper is and how PHP resolves protocol prefixes
- 2. The signature of stream_wrapper_register and its constraints
- 3. Required and optional methods of the wrapper convention
- 4. stream_stat and url_stat: returning metadata correctly
- 5. Example: an in-memory filesystem for unit tests
- 6. Skeleton for an S3-like protocol using context options
- 7. Improving testability of file access with custom wrappers
- 8. Pitfalls: conflicts with include, performance, and error handling
- 9. When a custom wrapper pays off, and when an existing solution is enough
- 10. Summary
- 11. FAQ
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.