protonemedia/laravel-minio-testing-tools
Testing helpers for Laravel that make it easy to use MinIO, the S3-compatible object storage server, in your test suite. Spin up buckets, configure filesystem disks, and assert file uploads without touching real AWS S3, keeping tests fast, isolated, and repeatable.
This package exists to solve one specific problem: Storage::fake('s3') is a local filesystem wearing an S3 costume. It never signs a request, never issues a presigned URL, never touches multipart uploads, and never validates path-style vs. virtual-host addressing. If your app depends on any of that, you need a real S3 API in your test suite. MinIO is a single-binary, S3-compatible server — this package's job is to start it, point your s3 disk at it, and tear it down.
Install and get a MinIO binary available:
composer require protonemedia/laravel-minio-testing-tools --dev
# Get the server binary: either the package's download command
# (see the README for the exact artisan signature in your version)
php artisan minio:download
# ...or install it yourself, which is often more predictable in CI
brew install minio/stable/minio # macOS
# or: curl -O https://dl.min.io/server/minio/release/linux-amd64/minio && chmod +x minio
Wire the trait into your base test case:
// tests/TestCase.php
use ProtoneMedia\LaravelMinioTestingTools\InteractsWithMinio;
abstract class TestCase extends BaseTestCase
{
use InteractsWithMinio;
}
Point the s3 disk at the local server. Put this in phpunit.xml so the whole suite sees it, not in .env:
<env name="AWS_ACCESS_KEY_ID" value="minioadmin"/>
<env name="AWS_SECRET_ACCESS_KEY" value="minioadmin"/>
<env name="AWS_DEFAULT_REGION" value="us-east-1"/>
<env name="AWS_BUCKET" value="testing"/>
<env name="AWS_ENDPOINT" value="http://127.0.0.1:9000"/>
<env name="AWS_USE_PATH_STYLE_ENDPOINT" value="true"/>
First use case — the one that justifies the dependency. Write a test that proves your presigned-URL flow actually works end to end. This is the test Storage::fake() structurally cannot give you:
it('serves a private file through a signed url', function () {
Storage::disk('s3')->put('private/report.pdf', 'secret contents');
$url = Storage::disk('s3')->temporaryUrl('private/report.pdf', now()->addMinutes(5));
Http::get($url)->assertOk()->assertSee('secret contents');
// and that an unsigned URL is rejected
$unsigned = strtok($url, '?');
Http::get($unsigned)->assertForbidden();
});
If that test passes, the trait is doing its job.
Trait placement is a cost decision. Putting InteractsWithMinio on your base TestCase means every test in the suite pays for a MinIO boot, a bucket creation, and — worse — inherits leftover objects from previous tests. Prefer a dedicated base class or per-file usage, and keep the plain Storage::fake('s3') path for tests that only care that something was written:
// tests/Feature/S3TestCase.php
abstract class S3TestCase extends TestCase
{
use InteractsWithMinio;
}
// Pest
uses(S3TestCase::class)->in('Feature/Storage');
Bucket hygiene between tests. MinIO persists across tests within a run, so bucket contents are your new global state. Treat the disk like a database and truncate it in setUp():
protected function setUp(): void
{
parent::setUp();
Storage::disk('s3')->deleteDirectory('/');
}
If the trait boots MinIO once per process rather than once per test, this is the only thing keeping tests independent. Watch for a test that passes alone and fails in the suite — that's almost always a leaked object.
Resolve the disk after the trait sets config. Laravel caches resolved disk instances on the Storage manager. If anything resolves Storage::disk('s3') before the trait swaps the endpoint in, you'll hit real AWS (or fail) with a confusing error. Defend with Storage::forgetDisk('s3') in setUp() if you see this.
Test the S3-specific behaviours, not the generic ones. The high-value targets:
// visibility / ACLs — differs meaningfully between local and S3
Storage::disk('s3')->setVisibility('public/logo.png', 'public');
expect(Storage::disk('s3')->getVisibility('public/logo.png'))->toBe('public');
// metadata round-trip
Storage::disk('s3')->put('a.txt', 'x', ['Metadata' => ['owner' => 'u-1']]);
// multipart thresholds — force a small chunk size to exercise the code path cheaply
// (configure the disk with a low multipart threshold in the test itself)
// controller that streams a download from S3
$this->get('/downloads/1')->assertDownload();
Direct-to-S3 upload tests (the big one). If you use Vapor, Livewire's WithFileUploads S3 flow, or any "client PUTs to a presigned URL" pattern, this package lets you simulate the browser step faithfully:
$url = Storage::disk('s3')->temporaryUploadUrl('uploads/1.zip', now()->addMinutes(5));
$this->call('PUT', $url['url'], [], [], [], $url['headers'], 'payload');
Storage::disk('s3')->assertExists('uploads/1.zip');
Keep it out of the fast lane. Structure the suite so MinIO tests are a tag/group you can run alone in a pre-merge job:
phpunit --exclude-group minio # fast, mostly Storage::fake()
phpunit --group minio # slow, faithful
Parallelism requires port isolation. With phpunit --parallel or Pest's parallel runner, N processes will fight over port 9000 and the same data directory. Give each TEST_TOKEN its own port (and, if the package exposes it, its own storage dir) via config in setUp():
config(['minio-testing-tools.port' => 9000 + (int) env('TEST_TOKEN', 0)]);
CI. Service containers are more reliable than shelling out to a binary:
services:
minio:
image: minio/minio
env:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports: ['9000:9000']
options: >-
--health-cmd "curl -f http://localhost:9000/minio/health/live"
Then let the trait detect an already-running server, or skip its boot step. Verify what your version supports before relying on this — it's the single most likely place this package stops earning its keep.
AWS_USE_PATH_STYLE_ENDPOINT=true is non-negotiable. bucket.localhost:9000 does not resolve. Omitting this produces Could not resolve host or a 404 from MinIO, which reads like a credentials bug and isn't.Storage::fake('s3') in a MinIO test. It silently replaces the disk with the local adapter and your presigned-URL test passes for the wrong reason. If you're debugging "why isn't this hitting MinIO," grep for fake(.AWS_DEFAULT_REGION gets you AuthorizationHeaderMalformed or a signature mismatch. us-east-1 is the safe default; make it explicit in phpunit.xml..env has AWS_ACCESS_KEY_ID= for local dev, phpunit.xml must override it — env file values and PHPUnit env vars interact in ways that surprise people.minio holding port 9000, and the next run fails with a bind error rather than anything to do with your code. Check with lsof -i :9000 before blaming the package. Prefer a trait that registers tearDown/shutdown cleanup, and verify it does.minio server /tmp/minio-data --address :9000 by hand, point config at it, and iterate from there.Symfony\Process.S3TestCase so you can drop in a docker compose up -d minio in setUp if it ever goes stale. Do not let the trait name leak into 200 test files.binary_path lets you point at the minio already on PATH instead of downloading per-project.require. Also keep the endpoint config in phpunit.xml/config/testing/, not in .env.example, or someone will eventually ship it.Storage::fake() is fine. Asserting Storage::disk('s3')->assertExists(...) after a put() needs no server at all. Reserve MinIO for signing, streams, multipart, visibility, and SDK-level behaviour — that's where the fidelity is worth the seconds.How can I help you explore Laravel packages today?