S3 and Scoped Disks

S3-Compatible and Scoped Filesystems

The s3 disk speaks the S3 API, which R2, Spaces and self-hosted stores also implement, so an endpoint setting covers them all. MinIO 30,943 , the usual local choice, no longer fits: it stopped publishing community images in October 2025, entered maintenance mode that December, and its repository was archived on 25 April 2026; LocalStack 63,725 has needed an account token since 2026.3.0. The examples use SeaweedFS 35,029 (https://github.com/seaweedfs/seaweedfs 35,029 ) 4.47 (Apache 2.0), whose mini mode runs an S3 gateway in one container; RustFS 33,997 (https://github.com/rustfs/rustfs 33,997 ) (Apache 2.0) and Garage 77,877 (https://garagehq.deuxfleurs.fr/ 77,877 ) (AGPL 3.0) are live alternatives.

An S3 store in Docker, the Flysystem packages, and the settings that differShell
docker run -d --name bookshop-s3 -p 9017:8333 -e AWS_ACCESS_KEY_ID=bookshop \
  -e AWS_SECRET_ACCESS_KEY=bookshop-secret -e S3_BUCKET=bookshop chrislusf/seaweedfs:latest
composer require league/flysystem-aws-s3-v3 "^3.0" league/flysystem-path-prefixing "^3.0" -W
# .env, besides the two keys: AWS_BUCKET=bookshop
AWS_ENDPOINT=http://127.0.0.1:9017
AWS_USE_PATH_STYLE_ENDPOINT=true

Path style puts the bucket in the path, not a subdomain. A scoped disk confines another disk to a prefix:

A scoped disk and a presigned link from the object storePHP
// filesystems.php: 'covers' => ['driver' => 'scoped', 'disk' => 's3', 'prefix' => 'covers']
$covers = Storage::disk('covers');
$covers->put('BK-PHP-01.png', file_get_contents(base_path('samples/cover.png')));
echo implode(PHP_EOL, Storage::disk('s3')->allFiles()), PHP_EOL;
$url = $covers->temporaryUrl('BK-PHP-01.png', now()->plus(minutes: 10));
echo str_replace('&', "\n  &", $url), PHP_EOL;
Output
covers/BK-PHP-01.png
http://127.0.0.1:9017/bookshop/covers/BK-PHP-01.png?X-Amz-Content-Sha256=UNSIGNED-PAYLOAD
  &X-Amz-Algorithm=AWS4-HMAC-SHA256
...
  &X-Amz-Expires=600
  &X-Amz-Signature=7d149cb4043bea99e3bdae0ef745733c43200ca485c6ab25c14c7f7e30de7cea

The bucket is private: the plain URL got AccessDenied, the presigned one 200 OK. With a wrong secret, put() returned a bare false; with 'throw' => true it raised UnableToWriteFile wrapping SignatureDoesNotMatch, so turn on throw for cloud disks. On Amazon S3 24 itself, drop the endpoint lines and use an IAM role (Getting Started with AWS).