brianhenryie / bh-php-flysystem-readonly
A League Flysystem adapter which wraps another adapter: reads pass through, writes and deletes are captured in memory so nothing ever touches the underlying filesystem. For --dry-run modes.
Package info
github.com/BrianHenryIE/bh-php-flysystem-readonly
pkg:composer/brianhenryie/bh-php-flysystem-readonly
Requires
- php: ^7.4 || ^8.0
- ext-fileinfo: *
- league/flysystem: ^2.5 || ^3.0
- league/flysystem-memory: ^2.0 || ^3.0
Requires (Dev)
- mockery/mockery: ^1.4
- phpstan/extension-installer: ^1.3
- phpstan/phpstan: ^1.11 || ^2.0
- phpstan/phpstan-phpunit: ^1.4 || ^2.0
- phpunit/phpunit: ^9.6 || ^10 || ^11 || ^12
- squizlabs/php_codesniffer: ^3.9 || ^4.0
This package is auto-updated.
Last update: 2026-07-27 19:45:14 UTC
README
Flysystem Read-Only Adapter
A League Flysystem adapter which wraps another adapter: reads pass through to it, writes and deletes are captured in memory. The underlying filesystem is never modified, but code running on top of it sees its own changes as though they had been written. Uses league/flysystem-memory to store changes.
Created to use for --dry-run mode needs: the whole program runs normally – writing files, deleting them, listing directories, reading back what it just wrote – and nothing reaches the disk. This was extracted from Strauss, where it implements
strauss --dry-run.
Install
composer require brianhenryie/bh-php-flysystem-readonly
Use
use BrianHenryIE\FlysystemReadOnly\ReadOnlyFileSystemAdapter; use League\Flysystem\Filesystem; use League\Flysystem\Local\LocalFilesystemAdapter; $adapter = new LocalFilesystemAdapter('/path/to/project'); if ($isDryRun) { $adapter = new ReadOnlyFileSystemAdapter($adapter); } $filesystem = new Filesystem($adapter); $filesystem->write('src/Example.php', '<?php // rewritten'); // The new content is visible... echo $filesystem->read('src/Example.php'); // <?php // rewritten // ...but the file on disk is untouched. echo file_get_contents('/path/to/project/src/Example.php'); // Warning: file_get_contents(/path/to/project/src/Example.php): failed to open stream: No such file or directory in Command line code on line 1
How it works
Three layers are consulted, in order:
| Layer | Contents |
|---|---|
| deleted files | tombstones written by delete(); directory tombstones recorded by deleteDirectory() |
| modified files | everything written during this session, in an in-memory adapter |
| delegate | the real filesystem, read-only |
So read() throws if the path was deleted, returns the in-memory copy if it was written, and otherwise
reads from the delegate. listContents() merges the delegate's listing with the in-memory one, removing
deleted paths and preferring the in-memory version of any path present in both.
Mutating operations only ever touch the in-memory layers. setVisibility() on a file which exists only on
the delegate filesystem first copies that file into memory – so the whole file is read into memory, as it
also is for copy() and move().
Path normalization
A PathNormalizer may be injected; it defaults to League's WhitespacePathNormalizer (which, among other
things, strips the leading / so paths match those returned by the adapters' directory listings).
$adapter = new ReadOnlyFileSystemAdapter($localAdapter, $myPathNormalizer);
The normalizer is applied by this adapter and again by the in-memory layers it delegates to, so an
injected normalizer must be idempotent: normalize(normalize($path)) === normalize($path).
Flysystem 2 and 3
league/flysystem 3 added directoryExists() to FilesystemAdapter and FilesystemReader, and has() to
FilesystemReader. Two traits back-fill them, calling the genuine parent implementation when it exists:
FlysystemAdapterBackCompatTrait– for yourFilesystemAdapterimplementationsFlysystemReaderBackCompatTrait– for yourFilesystemReader/Filesystemimplementations
These require the class using the trait to implement ::normalizePath().
class MyAdapter extends SomeAdapter implements FlysystemAdapterBackCompatTraitInterface { use FlysystemAdapterBackCompatTrait; public function normalizePath(string $path): string { return (new WhitespacePathNormalizer())->normalizePath($path); } }
| PHP | Flysystem 2 | Flysystem 3 |
|---|---|---|
| 7.4 | ✅ | – (requires PHP 8.0.2) |
| 8.0 – 8.4 | ✅ | ✅ |
Known limitations
deleteDirectory()records the directory as deleted, but files inside it which exist only on the delegate filesystem are not individually tombstoned, so they remain readable by their full path. Writing a file into a deleted directory, or creating it again, restores it.- Files are held in memory in full. This is a dry-run tool, not a caching layer.
visibility()of a directory defaults topublicunless a visibility was set on it during the session.mimeType()of an in-memory file is detected from the path and contents byleague/mime-type-detection, which may disagree with what the delegate filesystem would report.
Tests
composer test # against whichever flysystem is installed composer test-flysystem2 # re-resolve against league/flysystem 2.x, then test composer test-flysystem3 # re-resolve against league/flysystem 3.x, then test composer cs # PHP_CodeSniffer and PHPStan XDEBUG_MODE=coverage composer test-coverage
Credits
Handwritten by Brian Henry for Strauss then extracted by Claude.