league/flysystem
Flysystem is a PHP filesystem abstraction library offering one API for local and remote storage. Adapters let you read, write, and manage files on S3, FTP, SFTP, Dropbox, and more, so you can swap backends without rewriting application code.
Flysystem is a filesystem abstraction: one Filesystem API, swappable adapters. The single biggest onboarding step is internalizing that core is just the API — every backend is a separate package.
composer require league/flysystem
composer require league/flysystem-local # local adapter (extracted in 3.15)
composer require league/flysystem-aws-s3-v3 # or -ftp, -sftp, -webdav, -google-cloud-storage, -azure-blob-storage, -zip
Minimal usage — this is the whole day-to-day surface:
use League\Flysystem\Filesystem;
use League\Flysystem\Local\LocalFilesystemAdapter;
$fs = new Filesystem(new LocalFilesystemAdapter(__DIR__.'/storage'));
$fs->write('reports/2024-q1.csv', $csv);
$fs->fileExists('reports/2024-q1.csv'); // bool
$fs->mimeType('reports/2024-q1.csv'); // string
$fs->fileSize('reports/2024-q1.csv'); // int
$fs->read('reports/2024-q1.csv'); // string
$fs->delete('reports/2024-q1.csv');
Where to look first: the League\Flysystem\Filesystem class itself (it is the entire contract — ~20 methods), then src/Adapter naming in the README to pick your backend, then League\Flysystem\FilesystemException and its subclasses. Config is passed as an array via the adapter constructor or per-call Config/array.
First use case to convert: any place you have file_put_contents / file_get_contents / mkdir / scandir / unlink scattered across your domain code. Migrate one service (e.g. invoice PDF storage) behind Filesystem, then swap the adapter to S3 in staging without touching the caller. If you are on Laravel 9+, you already have Flysystem 3 underneath the Storage facade — do not build a parallel new Filesystem(...) stack; use Storage::disk() unless you need an adapter Laravel doesn't ship.
Adapter-per-environment, resolved from config. Build the Filesystem once in a container binding/factory, keyed by disk name. Never new LocalFilesystemAdapter(...) at a call site — that is the exact coupling Flysystem exists to remove.
// config/filesystems.php equivalent
'uploads' => match (env('FILESYSTEM_DRIVER')) {
's3' => new Filesystem(new AwsS3V3Adapter($s3Client, 'my-bucket', 'uploads/')),
default => new Filesystem(new LocalFilesystemAdapter(storage_path('app/uploads'))),
},
Streams for anything that could be large. read()/write() buffer the whole payload in memory. readStream/writeStream do not.
$stream = $fs->readStream('exports/huge.csv');
fpassthru($stream);
fclose($stream);
$fs->writeStream('exports/huge.csv', fopen($localTemp, 'r'));
Listing is a lazy, non-rewindable generator. Use shallow by default, deep only when you mean it; wrap in toArray() if you need to iterate twice.
use League\Flysystem\Filesystem;
foreach ($fs->listContents('uploads', Filesystem::LIST_DEEP) as $item) {
$item->isFile(); // bool
$item->path(); // string, always relative to the root
$item->lastModified();
}
DirectoryListing supports filter()/map()/sortByPath() (typed for PHPStan since 3.13), which is where you should put "only images" logic rather than inside the loop.
MountManager when you have more than one backend. Paths become disk://path, and copy/move across filesystems works in one call — this is the cleanest way to do "upload from local temp to S3".
$mounts = new MountManager([
'local' => new Filesystem(new LocalFilesystemAdapter('/tmp')),
's3' => new Filesystem(new AwsS3V3Adapter($client, 'bucket')),
]);
$mounts->copy('local://staging/report.pdf', 's3://reports/report.pdf');
$mounts->write('s3://reports/readme.txt', 'hi');
Path prefixing for multi-tenancy. Rather than baking tenants/{id}/ into every call:
use League\Flysystem\PathPrefixing\PathPrefixedAdapter;
$tenantFs = new Filesystem(new PathPrefixedAdapter($s3Adapter, 'tenant-123'));
checksum, publicUrl, temporaryUrl are prefix-aware since 3.10.x — a strong argument for using the decorator over manual path juggling.
Read-only exposure. ReadonlyFilesystemAdapter for anything user-facing (downloads, public previews) so a bug can't delete the bucket.
Serving files. publicUrl() and temporaryUrl() mean you don't proxy bytes through PHP:
$url = $fs->temporaryUrl('reports/secret.pdf', new DateTimeImmutable('+15 minutes'));
Inject a custom PublicUrlGenerator (CDN domain) rather than string-replacing hosts downstream; ShardedPrefixPublicUrlGenerator and chained strategies exist for CDN sharding.
Integrity checks. checksum($path, Algo::MD5) returns provider-computed checksums where available (S3, GCS, Azure, AsyncAws) and falls back to ad-hoc hashing otherwise — good for verifying uploads and for ETag comparisons.
Testing. Never hit S3 in unit tests:
$fs = new Filesystem(new InMemoryFilesystemAdapter());
StaticInMemoryAdapterRegistry (3.3) is useful for functional tests where a container resolves filesystems. Pair with ReadonlyFilesystemAdapter to prove your code handles permission rejections.
Testing/production parity gotcha: run S3 tests against MinIO with use_path_style_endpoint => true instead of against the real bucket.
Adapter/decorator extension. Custom backends implement FilesystemAdapter; to wrap an existing adapter (logging, caching, metrics, retry), extend DecoratedAdapter (3.22) instead of reimplementing the 12-method interface.
League\Flysystem\Local\LocalFilesystemAdapter no longer ships with core (extracted in 3.15). Upgrading an old lockfile without adding league/flysystem-local yields confusing class-not-found errors. Old tutorials say League\Flysystem\Adapter\Local — that's v1.^1.0|^2.0 will block the upgrade. Also, Storage::put() applies Laravel's own visibility and root logic — raw Flysystem Config passed to a Laravel disk behaves subtly differently than calling Filesystem directly.directoryExists() costs a ListObjects call; listContents() on a bucket root can return the top-level prefix itself on some adapters (fixed in 3.0.13 / 3.30.1). Prefer explicit prefixes and shallow listings; deep listings on S3 are paginated network round-trips.lazy_root_creation (3.1), disable_relative_path_resolution (3.34), inconclusive_mime_type (3.23), permissions, file_lock. S3: ACL, stream_reads, version, use_path_style_endpoint, add_content_md5 (3.26), forwarded_options, metadata, multipart settings. Read the changelog for the specific adapter before debugging weird behavior — several "bugs" are just unset options.ACL key, a visibility you pass to copy() is ignored. Same class of issue for retain_visibility (3.19): copy/move visibility behavior differs per adapter and per config.move($p, $p), verify the configured behavior.catch (FilesystemException $e) covers UnableToReadFile, UnableToWriteFile, UnableToDeleteFile, UnableToCheckExistence, UnableToListContents (3.4), PathTraversalDetected, CorruptedPathDetected. The underlying driver reason is usually in getPrevious() — added progressively in 3.0.22/3.0.23 and FTP error context in 3.29; log it or you'll see "Unable to write file at location X" with no cause... are rejected (PathTraversalDetected), and broken UTF-8 now reports as a corrupted path (3.35.3). Don't interpolate raw user input into paths — use PathPrefixedAdapter or sanitize before calling."0" is falsy in PHP and has historically caused unfiltered listings (fixed 3.30.1). If you have numeric-named directories, stay current.fileExists()/mimeType() can lie until the cache clears (3.30 improved consistency); SFTP exposes a config to disable the stat cache (3.14).writeStream() is consumed; rewind or reopen it. Unclosed read streams leak file descriptors on long-running workers.visibility(); treat it as a local/S3/GCS/Azure concept only.checksum path must be normalized before the adapter sees it (3.29.1); if you compute checksums through a PathPrefixedAdapter, stay on a recent patch.temporaryUrl since 3.10.disconnect() (3.25) — otherwise queue workers leak sockets. SFTP disconnect_on_destruction and connectivity_pinging are opt-in for a reason.LocalFilesystemAdapter or calls file_get_contents next to Flysystem, it has already lost the abstraction.listContents returns DirectoryListing not arrays, getSize→fileSize, getMimetype→mimeType, exceptions are no longer League\Flysystem\FileNotFoundException. Budget a real migration pass; the v3 API is not a drop-in.How can I help you explore Laravel packages today?