knplabs/gaufrette
Gaufrette is a PHP filesystem abstraction layer that lets your app read and write files without knowing where they're stored, so you can switch between local storage, Amazon S3, FTP, and other adapters without changing your code.
Install the core plus exactly one adapter metapackage. knplabs/gaufrette ships the core (Filesystem, File, Stream, InMemory, Local, Zip vary by version) but every maintained backend now lives in its own metapackage that pulls its own third-party dependencies:
composer require knplabs/gaufrette gaufrette/local-adapter
# later, when you outgrow the disk:
composer require gaufrette/aws-s3-adapter
Where to look first
docs/adapters/<name>.md — one page per adapter, with the constructor signature and a runnable snippet. Read the page for your storage before reading the code.README.md — tells you which adapters are deprecated (e.g. AmazonS3/AclAwareAmazonS3) and who maintains each one.src/Gaufrette/Filesystem.php — the whole public API is ~15 methods; skim it once and you know the package.spec/ — the phpspec suite is the real behaviour spec for write, rename, error types.Minimal wiring in Laravel. There is no official Laravel bundle (Symfony uses KnpGaufretteBundle), so you bind it yourself. Keep the config dumb and the construction in one class:
// app/Storage/GaufretteManager.php
namespace App\Storage;
use Gaufrette\Adapter;
use Gaufrette\Adapter\AwsS3;
use Gaufrette\Adapter\Local;
use Gaufrette\Filesystem;
class GaufretteManager
{
/** @var array<string, Filesystem> */
private array $disks = [];
public function __construct(private array $config) {}
public function disk(?string $name = null): Filesystem
{
$name ??= $this->config['default'];
return $this->disks[$name] ??= new Filesystem(
$this->makeAdapter($this->config['disks'][$name])
);
}
private function makeAdapter(array $c): Adapter
{
return match ($c['adapter']) {
'local' => new Local($c['directory'], true, 0750),
'aws_s3' => new AwsS3($c['client'], $c['bucket'], $c['options'] ?? []),
default => throw new \InvalidArgumentException("Unknown adapter {$c['adapter']}"),
};
}
}
// app/Providers/GaufretteServiceProvider.php (register())
$this->app->singleton(GaufretteManager::class, fn ($app) =>
new GaufretteManager($app['config']['gaufrette'])
);
First use case — an avatar uploader. One write, one read, one delete, and the key stored in your own table:
$fs = app(GaufretteManager::class)->disk('media');
$key = sprintf('avatars/%d/%s.webp', $user->id, Str::uuid());
try {
$fs->write($key, $request->file('avatar')->getContent(), true); // true = overwrite
} catch (\Gaufrette\Exception\FileAlreadyExists $e) {
// only reachable when overwrite is false
}
$user->update(['avatar_key' => $key]);
// later
$bytes = $fs->read($user->avatar_key);
$fs->delete($user->avatar_key);
That is the entire day-to-day surface: write / read / has / delete / rename / keys / size / mtime / mimeType / get / createStream. Everything else is adapters.
Config-driven adapter swapping. The whole point of Gaufrette is that avatars/12/uuid.webp means the same thing on a local disk and in a bucket. Structure your config so the disk name is the only thing that changes:
// config/gaufrette.php
return [
'default' => env('GAUFRETTE_DISK', 'media'),
'disks' => [
'media' => [
'adapter' => env('GAUFRETTE_ADAPTER', 'local'),
'directory' => storage_path('app/media'),
'bucket' => env('AWS_BUCKET'),
'options' => ['directory' => 'media'], // S3 prefix
],
],
];
Never let a controller or model name Local or AwsS3. Inject GaufretteManager (or a thin MediaStorage service that wraps one disk) and keep the adapter namespace out of your domain code — that is what makes the migration to S3 a config change plus a copy job.
A dedicated storage service, not the Filesystem in controllers. Wrap it so you own the key convention, the overwrite policy, and the exception translation:
class MediaStorage
{
public function __construct(private Filesystem $fs) {}
public function putUploadedFile(UploadedFile $file, string $prefix): string
{
$key = $prefix.'/'.Str::uuid().'.'.$file->extension();
$this->fs->write($key, $file->getContent(), false);
return $key;
}
public function putStream($resource, string $key): void
{
$stream = $this->fs->createStream($key);
$stream->open();
while (! feof($resource)) {
$stream->write(fread($resource, 8192));
}
$stream->close();
}
}
For genuinely large files use createStream() (or register Gaufrette\StreamWrapper and use fopen('gaufrette://key') with stream_copy_to_stream) rather than read()/write(), which materialise the whole payload in a string.
Metadata, size, checksum. These are optional adapter capabilities — feature-detect instead of assuming:
use Gaufrette\Adapter\ChecksumCalculator;
use Gaufrette\Adapter\MetadataSupporter;
$adapter = $fs->getAdapter();
if ($adapter instanceof ChecksumCalculator) {
$hash = $fs->checksum($key); // may be null/false on unsupported backends
}
if ($adapter instanceof MetadataSupporter) {
$adapter->setMetadata($key, ['Cache-Control' => 'public, max-age=31536000']);
}
Listing with intent. $fs->keys() returns everything; on S3 that is a paginated round trip per 1000 objects. For anything user-facing, list by prefix using the adapter's native client, or keep a media table and query that. Reserve keys() for maintenance commands and tests.
Async and lifecycle. Uploads to remote adapters belong in a queued job (putStream inside handle()), with the DB row written as status = pending first and flipped on success. There is no transaction across storage + database, so always write the DB row before the object and treat orphans as a cleanup job's problem.
Testing. InMemory is the perfect fake — same interface, no filesystem, no network:
protected function setUp(): void
{
parent::setUp();
$fs = new Filesystem(new \Gaufrette\Adapter\InMemory());
$this->app->instance(MediaStorage::class, new MediaStorage($fs));
}
public function test_avatar_is_stored_under_user_prefix(): void
{
// ... assert $this->app->make(MediaStorage::class) has the key
}
For integration coverage of Local, point the adapter at a sys_get_temp_dir() folder created in setUp and removed in tearDown.
Interop with Laravel's Flysystem. If you already have a configured Storage disk and want to expose it through Gaufrette's API (or migrate incrementally), wrap the underlying Flysystem instance in Gaufrette\Adapter\Flysystem. Pin the versions: that adapter tracks a specific Flysystem major, and Laravel 9+ ships Flysystem 3 as FilesystemOperator, so verify the constructor argument before committing to the bridge.
write() throws on existing keys. The third argument defaults to false and you get Gaufrette\Exception\FileAlreadyExists, not a silent overwrite. Re-uploads of the same key will blow up in production; either always pass true or generate a unique key per version./. Local joins the key onto its root directory, so /avatars/a.png produces a path outside (or a mangled path inside) the root. Normalise with ltrim($key, '/') at the boundary. Trailing slashes and .. are your responsibility, not the abstraction's.Local and Zip can have real directories; S3 has only keys containing /. Deleting a "folder" works only on Local, and only via $filesystem->getAdapter()->delete($dirKey) — Filesystem deliberately has no directory API, and you cannot delete the adapter's root.SafeLocal base64-encodes keys on disk. Keys that look odd in the storage layer are expected; verify what keys() returns for your version rather than assuming decoded values, and never mix SafeLocal and Local on the same directory.mtime(), size(), checksum() can return false. They are adapter-dependent, not guaranteed. Guard with the instanceof checks above; don't put size() behind a int return type.S3Client in the container (or use Laravel's Storage::disk('s3')->temporaryUrl()) instead of reaching for adapter-specific methods that may not exist in your version.rename() is copy + delete on several adapters. It is not atomic and can half-fail, leaving both copies. Treat a failed rename as recoverable, not exceptional.createStream() on an adapter that doesn't implement the stream factory falls back to an in-memory buffer — meaning a 200 MB video silently becomes 200 MB of RAM. Check the adapter, and always close() in a finally.InMemory is per-request. It's a test double, not a cache; a second HTTP request sees an empty filesystem.AmazonS3 / AclAwareAmazonS3 are deprecated and were slated for removal in 1.0 — use AwsS3 (aws-sdk v2 and v3 both supported, but the client must be built with 'version' => 'latest' or a pinned API version, otherwise the SDK throws on construction). Use a dedicated IAM user with s3:PutObject, s3:GetObject, s3:DeleteObject on bucket/* and s3:ListBucket on the bucket only; create the bucket out-of-band and drop s3:CreateBucket.key, content, mtime, checksum) before the adapter is usable; the adapter will not create it for you.^0.x and read the CHANGELOG before minor bumps, especially around adapter splits and interface additions.get_class($filesystem->getAdapter()) and the exact key string — 90% of "file not found on S3 but it exists in the console" cases are a double slash, a leading slash, or an unexpected prefix option.Gaufrette\Adapter (read, write, exists, mtime, keys, delete, rename, isDirectory) and add ChecksumCalculator / SizeCalculator / MetadataSupporter / MimeTypeProvider only for what you can genuinely support. For cross-cutting behaviour — logging, per-disk prefixing, read-only enforcement — write a decorator that implements Adapter and delegates, then hand it to new Filesystem($decorator). That composes far better than subclassing a concrete adapter.How can I help you explore Laravel packages today?