siberfx / flysystem-wasabi
Wasabi Hot Cloud Storage filesystem driver for Laravel 12 and 13 (Flysystem v3).
Requires
- php: ^8.3
- aws/aws-sdk-php: ^3.337
- illuminate/filesystem: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- league/flysystem: ^3.25
- league/flysystem-aws-s3-v3: ^3.25
Requires (Dev)
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.8|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A Wasabi Hot Cloud Storage filesystem driver for Laravel 12 and 13.
The wasabi driver is built on Laravel's own S3 driver (AWS SDK for PHP v3 + Flysystem v3), so a Wasabi disk is a regular
Illuminate\Filesystem\AwsS3V3Adapter. Everything the Storage facade offers for S3 works: url(), temporaryUrl(),
temporaryUploadUrl(), streams, visibility, directories, and $request->file()->store(). You only set the region and
the package works out the Wasabi endpoint.
- Requirements
- Installation
- Configuration
- Wasabi account setup: policies & permissions
- Verifying your setup
- Usage
- Frontend examples (Blade, Twig, Vue, React)
- Troubleshooting
- Testing
Requirements
| Package | Version |
|---|---|
| PHP | ^8.3 |
| Laravel | 12.x, 13.x |
| Storage | Flysystem v3, AWS SDK v3 |
Installation
composer require siberfx/flysystem-wasabi
Laravel auto-discovers the service provider. You don't need to require league/flysystem-aws-s3-v3 yourself because
this package depends on it.
Configuration
Add a disk to config/filesystems.php:
'disks' => [ // ... 'wasabi' => [ 'driver' => 'wasabi', 'key' => env('WASABI_ACCESS_KEY_ID'), 'secret' => env('WASABI_SECRET_ACCESS_KEY'), 'region' => env('WASABI_DEFAULT_REGION', 'eu-central-1'), 'bucket' => env('WASABI_BUCKET'), 'root' => env('WASABI_ROOT'), // optional prefix, e.g. "production" 'url' => env('WASABI_URL'), // optional CDN / custom domain for url() 'endpoint' => env('WASABI_ENDPOINT'), // optional, overrides the region endpoint 'use_path_style_endpoint' => env('WASABI_USE_PATH_STYLE_ENDPOINT', false), 'visibility' => 'private', 'throw' => false, 'report' => false, ], ],
Then set your .env:
WASABI_ACCESS_KEY_ID=your-access-key WASABI_SECRET_ACCESS_KEY=your-secret-key WASABI_DEFAULT_REGION=eu-central-1 WASABI_BUCKET=your-bucket-name # Optional: make Wasabi the default disk FILESYSTEM_DISK=wasabi
Options
| Option | Default | Description |
|---|---|---|
key / secret |
- | Access key pair of a Wasabi sub-user (see policies). |
region |
us-east-1 |
The bucket's region. It has to match the region the bucket was created in. |
bucket |
- (required) | Bucket name. |
root |
'' |
Prefix applied to every path, which lets several apps or environments share one bucket. |
url |
Wasabi object URL | Base URL used by url(), e.g. a CDN in front of the bucket. |
endpoint |
https://s3.{region}.wasabisys.com |
Override the service URL, e.g. a regional alias. |
use_path_style_endpoint |
false |
true gives s3.region.wasabisys.com/bucket/key instead of bucket.s3.region.wasabisys.com/key. Required for bucket names that contain dots. |
visibility |
private |
Default visibility for new files. public sends a public-read ACL (see public access). |
request_checksum_calculation |
when_required |
AWS SDK checksum behaviour (see checksums). |
response_checksum_validation |
when_required |
Same as above, for downloads. |
throw |
false |
Throw exceptions instead of returning false (Laravel default behaviour). |
options |
[] |
Extra S3 options for every write, e.g. ['ServerSideEncryption' => 'AES256']. |
Any other S3Client option
(http, retries, ...) is passed through to the AWS SDK.
Regions
| Region | Location | Endpoint |
|---|---|---|
us-east-1 |
N. Virginia | s3.us-east-1.wasabisys.com |
us-east-2 |
N. Virginia | s3.us-east-2.wasabisys.com |
us-central-1 |
Plano, Texas | s3.us-central-1.wasabisys.com |
us-west-1 |
Hillsboro, Oregon | s3.us-west-1.wasabisys.com |
us-west-2 |
San Jose, California | s3.us-west-2.wasabisys.com |
ca-central-1 |
Toronto | s3.ca-central-1.wasabisys.com |
eu-central-1 |
Amsterdam | s3.eu-central-1.wasabisys.com |
eu-central-2 |
Frankfurt | s3.eu-central-2.wasabisys.com |
eu-west-1 |
London | s3.eu-west-1.wasabisys.com |
eu-west-2 |
Paris | s3.eu-west-2.wasabisys.com |
eu-west-3 |
London | s3.eu-west-3.wasabisys.com |
eu-south-1 |
Milan | s3.eu-south-1.wasabisys.com |
ap-northeast-1 |
Tokyo | s3.ap-northeast-1.wasabisys.com |
ap-northeast-2 |
Osaka | s3.ap-northeast-2.wasabisys.com |
ap-southeast-1 |
Singapore | s3.ap-southeast-1.wasabisys.com |
ap-southeast-2 |
Sydney | s3.ap-southeast-2.wasabisys.com |
The package also accepts new regions that follow the same naming pattern. See Wasabi's service URL list for the current list.
Important
Wasabi requires the region-specific endpoint. With the wrong region, GET requests may still work but PUT and
DELETE fail. Check your bucket's region in the Wasabi console under Buckets.
Wasabi account setup: policies & permissions
Most "it doesn't work" reports come down to account setup rather than code. Do these steps once per bucket.
1. Create the bucket
In the Wasabi console: Buckets → Create Bucket. Choose a globally unique name. If
you can, avoid dots in the name; otherwise set use_path_style_endpoint to true. Then pick the region and write it into
WASABI_DEFAULT_REGION.
Optional bucket settings and how they affect Laravel:
- Versioning:
delete()adds a delete marker and keeps the old versions, and those versions count toward billed storage. - Object Lock: locked objects can't be overwritten or deleted until the retention period ends, so Laravel's
put()/delete()on them fail. Enable it only for audit or backup buckets.
2. Create a dedicated sub-user (don't use root keys)
Go to Users → Create User, tick Programmatic (create API key), and skip the groups step. On the Policies step,
attach the policy from step 3. When you finish, the console shows the access key and secret once. Put them into
WASABI_ACCESS_KEY_ID and WASABI_SECRET_ACCESS_KEY.
3. Attach a policy that grants exactly what Laravel needs
Go to Policies → Create Policy, paste the JSON below, replace your-bucket-name, and attach the policy to the user
from step 2. Wasabi uses AWS IAM policy syntax, which is why the ARNs start with arn:aws:s3.
Read/write access to one bucket (recommended for apps):
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "LaravelBucketLevel",
"Effect": "Allow",
"Action": [
"s3:ListBucket",
"s3:GetBucketLocation",
"s3:ListBucketMultipartUploads"
],
"Resource": "arn:aws:s3:::your-bucket-name"
},
{
"Sid": "LaravelObjectLevel",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:GetObjectAcl",
"s3:PutObjectAcl",
"s3:AbortMultipartUpload",
"s3:ListMultipartUploadParts"
],
"Resource": "arn:aws:s3:::your-bucket-name/*"
}
]
}
Note
The bucket-level actions use the bucket ARN (arn:aws:s3:::bucket). The object-level actions use the objects ARN
(arn:aws:s3:::bucket/*). If you mix them up, you get AccessDenied even though the action is listed.
Limit the user to a prefix (matches root in the disk config, here production/):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": "arn:aws:s3:::your-bucket-name",
"Condition": { "StringLike": { "s3:prefix": ["production/*", "production"] } }
},
{
"Effect": "Allow",
"Action": ["s3:GetBucketLocation", "s3:ListBucketMultipartUploads"],
"Resource": "arn:aws:s3:::your-bucket-name"
},
{
"Effect": "Allow",
"Action": [
"s3:GetObject", "s3:PutObject", "s3:DeleteObject",
"s3:GetObjectAcl", "s3:PutObjectAcl",
"s3:AbortMultipartUpload", "s3:ListMultipartUploadParts"
],
"Resource": "arn:aws:s3:::your-bucket-name/production/*"
}
]
}
Read-only access (e.g. a reporting app): keep s3:ListBucket, s3:GetBucketLocation and s3:GetObject only.
Which Laravel call needs which permission
Laravel Storage method |
Required S3 action(s) |
|---|---|
files(), allFiles(), directories(), deleteDirectory() |
s3:ListBucket (+ s3:DeleteObject) |
exists(), size(), lastModified(), mimeType() |
s3:GetObject (HEAD request) |
get(), readStream(), download(), temporaryUrl() |
s3:GetObject |
put(), putFile(), writeStream(), store(), temporaryUploadUrl() |
s3:PutObject (+ s3:PutObjectAcl if visibility is set) |
copy(), move() |
s3:GetObject, s3:PutObject (+ s3:DeleteObject for move) |
delete() |
s3:DeleteObject |
getVisibility() / setVisibility() |
s3:GetObjectAcl / s3:PutObjectAcl |
| Large uploads (multipart) | s3:PutObject, s3:AbortMultipartUpload, s3:ListMultipartUploadParts, s3:ListBucketMultipartUploads |
A presigned URL (temporaryUrl / temporaryUploadUrl) has the permissions of the key that signed it. If the user
lacks s3:GetObject, the URL is generated fine but returns 403 when someone opens it.
4. Public files: public access is disabled by default
Wasabi buckets are private. Wasabi also restricts public access at the account level:
- Accounts created after 13 March 2023 and trial accounts can't enable public access themselves, either in the console or through the API. Wasabi Support has to enable it for the account or bucket.
- Older paid accounts can turn on public access per bucket under Bucket → Settings → Public Access.
See Wasabi's notes on the change in public access and the Public Access Override setting.
What this means in Laravel:
- Recommended: keep the disk
privateand hand out short-lived links withtemporaryUrl(). This works on every account with no extra setup. Storage::disk('wasabi')->url($path)builds the plain object URL. That URL only opens in a browser if the object is publicly readable, or ifurlpoints at a CDN or proxy that has access.visibility => 'public'orsetVisibility($path, 'public')sends apublic-readACL. If public access isn't enabled for the account, the request fails or anonymousGETs still return403.
If Wasabi has enabled public access for you, a bucket policy that allows anonymous reads of one prefix looks like this (Bucket → Settings → Policies):
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadForPublicPrefix",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::your-bucket-name/public/*"
}
]
}
5. CORS (browser uploads and fetches)
If the browser talks to Wasabi directly, the bucket needs a CORS configuration. That's the case for temporaryUploadUrl()
direct uploads, fetch() of temporaryUrl() links, and fonts or video served from the bucket. You can set it once
through the SDK client the disk exposes, for example from php artisan tinker:
Storage::disk('wasabi')->getClient()->putBucketCors([ 'Bucket' => config('filesystems.disks.wasabi.bucket'), 'CORSConfiguration' => [ 'CORSRules' => [[ 'AllowedOrigins' => ['https://your-app.com'], 'AllowedMethods' => ['GET', 'PUT', 'HEAD'], 'AllowedHeaders' => ['*'], 'ExposeHeaders' => ['ETag'], 'MaxAgeSeconds' => 3600, ]], ], ]);
The user that runs this needs s3:PutBucketCORS on the bucket ARN. Use an admin key for it rather than adding the
action to the app's policy. The AWS CLI works too:
aws s3api put-bucket-cors --bucket your-bucket-name \ --cors-configuration file://cors.json \ --endpoint-url https://s3.eu-central-1.wasabisys.com
6. Billing policies worth knowing
These aren't permissions, but they affect how you should use the disk:
- Minimum storage duration. Wasabi bills each object for a minimum period (90 days on pay-as-you-go at the time of
writing). Deleting or overwriting a file earlier is still billed for the full period. Avoid using Wasabi for
short-lived temp files; keep those on the
localdisk. - Egress and API requests are free under Wasabi's fair-use policy, which expects your monthly egress to stay at or below the amount of data you store.
Check Wasabi's pricing FAQ for the terms that apply to your account.
Verifying your setup: php artisan wasabi:check
The package ships a command that exercises the disk end to end and shows the real S3 error code for anything that fails:
php artisan wasabi:check # checks the "wasabi" disk php artisan wasabi:check backups # checks another disk php artisan wasabi:check --public # also tests public-read ACL + anonymous access
Endpoint ................................ https://s3.eu-central-1.wasabisys.com
Region .................................................... eu-central-1
Bucket ................................................. your-bucket-name
Root ............................................................... /
s3:ListBucket .......................................... 120ms DONE
s3:PutObject ........................................... 210ms DONE
s3:GetObject (head) ..................................... 95ms DONE
s3:GetObject (read) ..................................... 98ms DONE
Presigned URL (temporaryUrl) ........................... 130ms DONE
s3:DeleteObject ......................................... 90ms DONE
INFO Disk [wasabi] is fully working.
It writes and then deletes one small file under .wasabi-check/.
Usage
use Illuminate\Support\Facades\Storage; $disk = Storage::disk('wasabi'); // Write $disk->put('avatars/1.jpg', $contents); $disk->put('reports/q1.pdf', $contents, 'private'); $disk->writeStream('backups/db.sql.gz', fopen($localPath, 'r')); // large files: streams + multipart // Uploaded files $path = $request->file('avatar')->store('avatars', 'wasabi'); $path = $request->file('avatar')->storeAs('avatars', "{$user->id}.jpg", 'wasabi'); // Read $contents = $disk->get('avatars/1.jpg'); $stream = $disk->readStream('backups/db.sql.gz'); return $disk->download('reports/q1.pdf', 'Q1 report.pdf'); // Inspect $disk->exists('avatars/1.jpg'); $disk->size('avatars/1.jpg'); $disk->lastModified('avatars/1.jpg'); $disk->mimeType('avatars/1.jpg'); $disk->files('avatars'); $disk->allFiles(); // Manage $disk->copy('avatars/1.jpg', 'avatars/archive/1.jpg'); $disk->move('tmp/upload.jpg', 'avatars/2.jpg'); $disk->delete(['avatars/1.jpg', 'avatars/2.jpg']); $disk->deleteDirectory('tmp');
Private files: presigned URLs (recommended)
// Time-limited download link, works without public access. $url = Storage::disk('wasabi')->temporaryUrl('reports/q1.pdf', now()->addMinutes(15)); // Force a download filename / content type. $url = Storage::disk('wasabi')->temporaryUrl('reports/q1.pdf', now()->addMinutes(15), [ 'ResponseContentDisposition' => 'attachment; filename="Q1 report.pdf"', 'ResponseContentType' => 'application/pdf', ]); // Direct browser-to-Wasabi upload (needs CORS, see above). ['url' => $url, 'headers' => $headers] = Storage::disk('wasabi') ->temporaryUploadUrl('uploads/video.mp4', now()->addMinutes(10));
Public URLs
$url = Storage::disk('wasabi')->url('public/logo.png'); // https://your-bucket-name.s3.eu-central-1.wasabisys.com/public/logo.png // or https://cdn.your-app.com/public/logo.png when "url" is set
Multiple buckets / on-demand disks
// config/filesystems.php: as many disks as you need 'wasabi-backups' => [ 'driver' => 'wasabi', 'key' => env('WASABI_ACCESS_KEY_ID'), 'secret' => env('WASABI_SECRET_ACCESS_KEY'), 'region' => 'eu-central-2', 'bucket' => 'my-backups', ], // or build one at runtime (e.g. per tenant) $disk = Storage::build([ 'driver' => 'wasabi', 'key' => $tenant->wasabi_key, 'secret' => $tenant->wasabi_secret, 'region' => $tenant->wasabi_region, 'bucket' => $tenant->wasabi_bucket, ]);
Raw AWS SDK access
For things the Storage API doesn't cover (bucket lifecycle rules, object tags, CORS, ...), use the underlying
Aws\S3\S3Client, which is already configured for Wasabi:
$client = Storage::disk('wasabi')->getClient(); $client->putObjectTagging([ 'Bucket' => config('filesystems.disks.wasabi.bucket'), 'Key' => 'reports/q1.pdf', 'Tagging' => ['TagSet' => [['Key' => 'department', 'Value' => 'finance']]], ]);
Testing your app
Laravel's fake works with any disk name:
Storage::fake('wasabi'); $this->post('/avatar', ['avatar' => UploadedFile::fake()->image('me.jpg')]); Storage::disk('wasabi')->assertExists('avatars/me.jpg');
Frontend examples
The examples/ folder has complete, copy-paste implementations. All of them share one controller and keep
files private, showing them through temporaryUrl() links:
| Example | Files | Shows |
|---|---|---|
| Laravel backend | examples/laravel/WasabiFileController.php, routes.php |
Form upload, presigned direct upload (presign + confirm), list, download, delete, per-user key prefix |
| Blade | examples/blade/index.blade.php |
Upload form, direct upload with progress, file list with image previews |
| Twig | examples/twig/index.twig |
Same page for Laravel + TwigBridge |
| Vue 3 | examples/vue/WasabiUploader.vue |
Drag & drop, parallel uploads with progress, file list, delete |
| React | examples/react/WasabiUploader.jsx |
Same component for React 18/19 |
| JS helper | examples/js/wasabiUpload.js |
Dependency-free uploadToWasabi(file, { onProgress }) used by Vue and React |
See examples/README.md for the upload flow diagram and setup notes. Direct browser uploads need
the bucket CORS rule.
Troubleshooting
SignatureDoesNotMatch / AuthorizationHeaderMalformed
The key or secret is wrong, or region doesn't match the bucket's region. Copy the region from the bucket list in the
console.
AccessDenied (403)
The user's policy is missing an action, or bucket and object ARNs are mixed up (see the
permission table). php artisan wasabi:check shows which call fails.
url() returns 403 in the browser
The object isn't publicly readable, which is the default on Wasabi (see public access).
Use temporaryUrl() instead.
SSL error with a bucket name containing dots
Set 'use_path_style_endpoint' => true.
Checksum / 501 NotImplemented errors on upload
AWS SDK for PHP 3.337+ adds CRC32 checksums to uploads by default, and some S3-compatible services reject them. This
package sets request_checksum_calculation and response_checksum_validation to when_required, which sends checksums
only when an operation needs them. You can set either option to when_supported in the disk config if you want them
everywhere.
put() returns false with no error
Laravel disks swallow exceptions by default. Set 'throw' => true on the disk, or run php artisan wasabi:check, to
see the real error.
Testing
composer test # unit + feature tests (no network) composer test:integration # runs against a real bucket
The integration suite is skipped unless credentials are set:
WASABI_ACCESS_KEY_ID=... WASABI_SECRET_ACCESS_KEY=... \ WASABI_DEFAULT_REGION=eu-central-1 WASABI_BUCKET=... composer test:integration
It writes under a random flysystem-wasabi-tests/<uuid>/ prefix and removes everything it wrote afterwards.
Changelog
See CHANGELOG.md.
License
The MIT License (MIT). See LICENSE.md.