coraxster/flysystem-aws-s3-v3-minio
A Flysystem AWS S3 v3 adapter built for MinIO object storage. Use familiar S3 APIs against self-hosted MinIO buckets in Laravel and PHP projects, with straightforward configuration for custom endpoints, credentials, and bucket paths.
Before writing code, confirm the package still fits your stack. The last tagged release is from September 2017, which puts it in the Flysystem 1.x era (league/flysystem ^1.0, league/flysystem-aws-s3-v3 ^1.0, AWS SDK v3 early releases). That constrains you to Laravel ~5.4–5.8 / PHP 7.x. Run these two checks in a scratch branch before committing to it:
composer show coraxster/flysystem-aws-s3-v3-minio # look at requires[]
composer why league/flysystem # is anything forcing 1.x?
ls vendor/coraxster/flysystem-aws-s3-v3-minio/src # find the actual adapter FQCN
If you are on Laravel 9+ / Flysystem 3, do not adopt this package — the upstream league/flysystem-aws-s3-v3:^3.0 already does everything it advertises (endpoint + use_path_style_endpoint). The rest of this section assumes a legacy app where you are choosing it deliberately.
1. Install and pin.
composer require coraxster/flysystem-aws-s3-v3-minio
If Composer resolves to the dev-master branch (common for abandoned forks), pin the exact reference and commit composer.lock. Note the namespace the adapter ships in — most forks of this kind keep League\Flysystem\AwsS3v3\AwsS3Adapter as a drop-in replacement, some use Coraxster\.... The ls above settles it.
2. Add the environment and disk config.
MINIO_ENDPOINT=http://127.0.0.1:9000
MINIO_KEY=minioadmin
MINIO_SECRET=minioadmin
MINIO_BUCKET=uploads
MINIO_REGION=us-east-1
MINIO_URL=http://127.0.0.1:9000/uploads
// config/filesystems.php
'disks' => [
'minio' => [
'driver' => 'minio',
'key' => env('MINIO_KEY'),
'secret' => env('MINIO_SECRET'),
'region' => env('MINIO_REGION', 'us-east-1'),
'bucket' => env('MINIO_BUCKET'),
'endpoint' => env('MINIO_ENDPOINT'),
'use_path_style_endpoint' => true,
'url' => env('MINIO_URL'),
'options' => ['ACL' => 'private'],
],
],
3. Register the driver (Laravel 5.x; newer versions register s3 themselves):
// app/Providers/AppServiceProvider.php -> boot()
Storage::extend('minio', function ($app, $config) {
$client = new \Aws\S3\S3Client([
'version' => 'latest',
'region' => $config['region'] ?? 'us-east-1',
'endpoint' => $config['endpoint'],
'use_path_style_endpoint' => true,
'credentials' => [
'key' => $config['key'],
'secret' => $config['secret'],
],
]);
$adapter = new \League\Flysystem\AwsS3v3\AwsS3Adapter( // verify FQCN
$client,
$config['bucket'],
$config['root'] ?? ''
);
return new \League\Flysystem\Filesystem($adapter);
});
4. First use case — user avatar uploads. Create the bucket out of band first (mc mb local/uploads, or the MinIO console), then:
$path = $request->file('avatar')->storeAs(
"avatars/{$user->id}",
'original.jpg',
'minio'
);
Storage::disk('minio')->url($path); // http://127.0.0.1:9000/uploads/avatars/1/original.jpg
If that round-trips — upload visible in the MinIO console, URL 200s in the browser — the integration is healthy. Everything else is just Flysystem API surface (put, putFile, get, readStream, allFiles, deleteDirectory) behaving as it does on the local disk.
Keep disk names semantic, not backend-specific. 'driver' => 'minio' with a disk named uploads or documents means a later swap to S3/GCS is a config change. Never let minio leak into Storage::disk('minio') calls scattered across the codebase — wrap in a repository or use config('filesystems.default').
Direct Flysystem use outside Laravel (CLI workers, queue consumers, plain PHP services) is the cleanest use of the package:
$filesystem = new \League\Flysystem\Filesystem($adapter);
$filesystem->put('reports/2024-q1.csv', $csv);
$stream = $filesystem->readStream('reports/2024-q1.csv');
$filesystem->listContents('reports', true); // recursive
Streams for anything large. putFile/writeStream avoid loading whole payloads into memory; a 200 MB CSV through Storage::put($path, $contents) will blow memory_limit. For multipart behaviour, pass the real byte size when you have it — Flysystem 1.x's writeStream wants a size for multi-part decisions.
Presigned URLs for private objects. MinIO's killer feature in day-to-day work is handing out time-boxed links without proxying bytes through PHP:
$client = Storage::disk('minio')->getAdapter()->getClient();
$cmd = $client->getCommand('GetObject', [
'Bucket' => config('filesystems.disks.minio.bucket'),
'Key' => $path,
]);
$url = (string) $client->createPresignedRequest($cmd, '+15 minutes')->getUri();
If your Laravel version exposes Storage::disk('minio')->temporaryUrl($path, now()->addMinutes(15)), use that instead — it goes through the same code path. This is the pattern to standardise on for "download invoice" endpoints, replacing response()->download(Storage::get(...)).
Per-environment buckets behind one disk definition. Point MINIO_BUCKET at uploads-dev / uploads-staging / uploads per environment. Keeps test data out of prod without touching code, and lets you wipe a bucket freely in CI.
Local dev parity via Docker Compose. Run minio/minio server /data --console-address ":9001" alongside your app, with the app container reaching it at http://minio:9000. Because the adapter is path-style, the internal hostname works for both PHP and the browser only if you expose the same host/port to both — otherwise generated url() values break in the browser. The usual fix is a MINIO_URL env that differs from MINIO_ENDPOINT (e.g. http://localhost:9000/uploads for browser-facing URLs while the container talks to http://minio:9000).
Bucket-per-tenant. MinIO handles thousands of buckets cheaply, and this is often a better isolation story than key prefixes. Register disks dynamically:
config(["filesystems.disks.tenant-{$id}" => array_merge(
config('filesystems.disks.minio'),
['bucket' => "tenant-{$id}"]
)]);
Storage::disk("tenant-{$id}")->put(...);
Remember the bucket still has to be created before first write ($client->createBucket([...]) in your provisioning job).
Testing. Skip S3 mocks; point tests at a real MinIO container (testcontainers, or a CI service). Mocking the S3 client hides exactly the bugs you care about — signature errors, path-style misconfiguration, ACL rejections. Seed the bucket with mc mb and mc cp in the CI step.
Extension point: subclass the adapter rather than scattering config. Overriding getUrl() to normalise trailing slashes, or adding a getTemporaryUrl() helper so callers never touch the S3 client, keeps the SDK out of your controllers.
use_path_style_endpoint => true is non-negotiable. Without it, SDK v3 produces virtual-host-style requests like http://uploads.127.0.0.1:9000/..., which will never resolve. This is the single most common failure with MinIO and S3 SDKs.
The endpoint needs a scheme and a port. 127.0.0.1:9000 fails; http://127.0.0.1:9000 works. A trailing slash also causes double-slash key corruption.
Region is a fiction, but it must exist. MinIO ignores it, but the SigV4 signer needs a value; us-east-1 is the conventional choice. Omitting it yields Missing required client configuration options: region.
SignatureDoesNotMatch is almost never your credentials. In order of likelihood: (1) clock skew — Docker containers with a stale clock fail SigV4; (2) a reverse proxy (nginx/traefik) rewriting the Host header between client and MinIO; (3) secret with #, $, or spaces not quoted correctly in .env. Reproduce with curl -v first to separate network from SDK.
Storage::exists() lies on MinIO. Missing objects return 403 rather than 404 when the credentials lack s3:ListBucket; Flysystem's has() surfaces that as a thrown exception or a false negative. If you see mysterious Unable to read file errors for files that definitely exist, grant ListBucket on the bucket policy before debugging anything else.
visibility/ACLs don't work like S3. MinIO does not honour canned ACLs; Storage::put($path, $data, 'public') will not make an object world-readable, and depending on version it may error outright. Set 'options' => ['ACL' => 'private'] in the disk config, and expose objects via a bucket policy (mc anonymous set download local/public-assets) or presigned URLs. Never rely on Flysystem visibility for access control.
Buckets are not created for you. Storage::makeDirectory() creates prefixes (which are virtual), not buckets. A first write into a nonexistent bucket throws NoSuchBucket. Provision buckets in a deploy/migration step.
Server-side encryption options must be off. If you've cribbed S3 disk config from a Laravel app that sets 'options' => ['ServerSideEncryption' => 'AES256'], remove it — MinIO rejects or silently mishandles SSE headers unless SSE-KMS/SSE-S3 is configured server-side.
TLS with self-signed certs. If MinIO runs behind HTTPS with an internal CA, either add the CA to the container's trust store or pass Guzzle options through the client: 'http' => ['verify' => '/path/ca.pem']. 'http' => ['verify' => false] is acceptable in dev only — and remember that presigned URLs generated with http:// will be mixed-content blocked if your app is served over HTTPS.
Set timeouts. A dead MinIO host otherwise hangs PHP-FPM workers until max_execution_time. Add 'connect_timeout' => 2, 'timeout' => 10 to the S3Client config and let failures surface fast.
Debug with the SDK, not with dd(). Pass 'debug' => true plus a PSR-3 logger to S3Client to dump every request/response pair:
'debug' => env('MINIO_DEBUG', false),
'http' => ['debug' => env('MINIO_DEBUG', false)],
In Laravel: $app['log']->getLogger() (5.x) or Log::getLogger() supplies the logger. Compare the signed request URL against what mc sends — the diff is usually the path style or the Host header.
If the SDK complains about unknown client options, you're passing the whole Laravel disk array into S3Client. driver, root, url, and visibility are not client options — Arr::only() the keys you actually need instead of splicing the array wholesale.
Config caching bites here. php artisan config:cache snapshots MINIO_* values; changing .env without clearing produces "it worked yesterday" bugs. Always config:clear && config:cache in deploy scripts.
Plan the exit. Because the package is a 2017-era fork, treat it as a temporary bridge: keep all adapter-specific code behind your disk names and a thin StorageService, so migrating to league/flysystem-aws-s3-v3:^3.0 (Flysystem 3 + use_path_style_endpoint) is a composer bump plus a provider edit rather than a refactor. Verify the newer MinIO builds you deploy against still accept the ListObjects V1 calls this adapter makes — if you ever hit NotImplemented on directory listings, that's the signal to migrate.
How can I help you explore Laravel packages today?