league/flysystem-vfs
Flysystem adapter for the PHP VFS library that lets you mount and use a virtual filesystem with Flysystem. Install via Composer and pass a VfsAdapter to a new Filesystem instance.
Check your Flysystem version before anything else. This adapter implements Flysystem 1.x AdapterInterface. Run:
composer show league/flysystem
If you see ^2 or ^3, stop — this package will not install cleanly and will not satisfy the current adapter contract. On modern Laravel, use Storage::fake('disk') or league/flysystem-memory instead. The rest of this document assumes a Flysystem 1.x codebase (Laravel ≤ 5.x era, or a legacy service).
Install (Flysystem 1.x only):
composer require league/flysystem-vfs
First working example. The whole package is a thin bridge — one adapter class. Instantiate it and hand it to Flysystem:
use League\Flysystem\Vfs\VfsAdapter;
use League\Flysystem\Filesystem;
use VirtualFileSystem\FileSystem as Vfs;
$vfs = new Vfs();
$filesystem = new Filesystem(new VfsAdapter($vfs));
$filesystem->write('hello.txt', 'world');
$filesystem->read('hello.txt'); // 'world'
$filesystem->has('hello.txt'); // true
Where to look first:
src/VfsAdapter.php — it is the entire package; read it end-to-end (it is small). Every method is a delegation to a VirtualFileSystem\FileSystem call.michael-donat/php-vfs source — all real behavior, quirks, and stream-wrapper mechanics live there.CHANGELOG.md — the fixes tell you what the sharp edges are (path-prefix removal, recursive folder deletion).First use case to target: replacing real disk I/O in a single test that currently writes to storage/app. Point the test at new Filesystem(new VfsAdapter(new Vfs)), run the production code against it, assert the file exists, and delete nothing — the process ends and the memory is reclaimed. That one test tells you whether the adapter is viable for your codebase.
Wrap it in a test helper — never use it raw in tests. Create one factory so the adapter is replaceable in a single place:
trait UsesVirtualDisk
{
protected $vfs;
protected $disk;
protected function useVirtualDisk(): Filesystem
{
$this->vfs = new Vfs();
$this->disk = new Filesystem(new VfsAdapter($this->vfs));
return $this->disk;
}
protected function tearDown(): void
{
$this->vfs = $this->disk = null;
parent::tearDown();
}
}
This is the single most valuable pattern: when the package is eventually replaced by flysystem-memory or a temp-dir local adapter, you edit one trait, not fifty tests.
Seed fixtures through the adapter, not the VFS root. Keep tests using only the Flysystem API so the test body is identical to production code:
$disk->write('imports/orders.csv', $this->fixture('orders.csv'));
$service->handleImport($disk);
$this->assertTrue($disk->has('archive/orders.csv'));
Register a Laravel disk driver (Laravel 5.x). In a service provider's boot():
Storage::extend('vfs', function ($app, $config) {
return new Filesystem(new VfsAdapter(new Vfs()));
});
Then config/filesystems.php gets a 'vfs' => ['driver' => 'vfs'] entry, and tests swap the default disk: Config::set('filesystems.default', 'vfs'). Production code keeps calling Storage::disk()->put(...) unchanged. Keep this registration inside the testing service provider so it can never be reached in production.
Hide behind a domain interface for anything non-trivial. If your feature does more than put/get, define a small contract and implement it with Flysystem:
interface UploadStore {
public function store(string $name, $stream): string;
public function exists(string $name): bool;
}
Tests inject the VFS-backed implementation; production injects the local/s3 one. This is what makes the eventual migration from this abandoned package a config change rather than a refactor.
Sandbox untrusted I/O. Because php-vfs lives in the process, it is a useful sandbox for plugin-generated files or user-supplied archive extraction: paths resolve inside the virtual root, so ../../etc/passwd never touches the real disk. Use $vfs->path('/some/dir') when you need the underlying vfs:// URI — for example, to pass to a library that insists on a real-looking path.
Test pipeline stages in isolation. Write-then-read across stages (upload → transform → export) with each stage asserting on the same virtual disk. Because nothing persists, each test starts from a guaranteed-empty filesystem, which eliminates the "works on my machine, fails on CI because a stale file exists" class of flake.
Flysystem 2/3 is a hard stop. The adapter implements AdapterInterface, not the current FilesystemAdapter (with write/writeStream/read/readStream/listContents/move/copy/fileExists/directoryExists/lastModified/mimeType/fileSize/visibility/setVisibility). Composer will usually refuse to co-install it with ^2|^3; do not force it. Porting the class is roughly 150 lines and is a better use of time than pinning Flysystem back to 1.x for the whole application.
This package is abandoned. Last release 2016-07-10, 19 stars, zero dependents. If you adopt it, you own it: pin an exact version ("1.1.0", not ^1.0), vendor or fork it if CI stability matters, and note that there is no security-response path.
Stream-wrapper global state is the number-one debugging trap. php-vfs registers the vfs:// scheme process-wide. If a test leaves a wrapper registered or a FileSystem instance alive, later tests can see stale paths or fail with "wrapper already registered." Mitigations:
Vfs per test, in setUp(), and null it in tearDown().Vfs instance across tests.file_get_contents($vfs->path('/')) or $disk->listContents('', true) after a failing operation.Metadata is approximate. getVisibility/setVisibility, getMimetype, and getTimestamp are best-effort delegations to a filesystem that has no real permissions or inodes. Do not write tests that assert on permissions or timestamps — they will pass here and mean nothing on local/s3, or vice versa. If your code depends on visibility semantics, verify explicitly on the real driver.
Memory is the ceiling. Everything lives in process RAM. Small fixtures, fine. Multi-megabyte CSVs, Stream copies of large archives, or a test that writes thousands of files will OOM (or silently be very slow). Keep test fixtures tiny, and delete intermediate files via $disk->delete() inside the test if a pipeline writes a lot.
Recursive delete was buggy historically. Changelog 1.0.3 fixed "deleting a VFS folder with files in it," and 1.0.1 fixed a path-prefix removal bug. If you are stuck on an older tag, deleteDir() on a non-empty directory may fail — either pin 1.1.0 or work around it by deleting contents first via listContents($dir, true).
Path normalization is the other historical sore spot. Because the adapter strips a prefix before delegating, //double//slashes, leading /, and .. behave differently than on a real disk. Normalize paths (trim($path, '/'), reject ..) in your own wrapper before handing them to Flysystem, so production and test behavior match.
Don't let it leak into production code paths. No type-hint in a production class should name VfsAdapter or VirtualFileSystem\FileSystem. Bind through an interface or a disk name. The failure mode if you skip this is a production deploy that silently writes to a virtual filesystem and loses every uploaded file — the exact "works in test, fails in prod" surprise this package invites.
Extension points are minimal by design. The adapter is a single concrete class with no configuration. If you need behavior it does not provide, extend VfsAdapter and override the specific method, or — better — put the logic in a decorator around Filesystem so it survives the adapter swap. Do not patch vendor code without a composer patches or fork strategy.
Prefer the maintained alternative where it exists. For Flysystem 1.x, mikey179/vfsstream is the de facto standard and has vastly more community coverage; for Flysystem 2/3, use league/flysystem-memory; for modern Laravel, Storage::fake(). Reach for this package only when a legacy codebase already standardizes on michael-donat/php-vfs and the swap cost is genuinely lower than the migration cost.
How can I help you explore Laravel packages today?