Add Sonic object storage to Laravel

Laravel’s S3 filesystem driver works with Sonic’s S3-compatible API out of the box. Below you’ll find two complete recipes:
• Standard upload via your Laravel app (Storage facade)
• Direct-to-Sonic uploads via presigned URLs
Heads-up & important notes
Virtual-hosted vs. path-style buckets: Sonic’s default is virtual-hosted style. Path-style is deprecated. Only use it if you explicitly created the bucket that way. Your .env and driver options must match the bucket style you chose.
Public reads, authenticated writes: Sonic applies one default mixed access policy: unauthenticated requests can download objects from the bucket URL / CDN hostname, while write/list/tag require authentication (similar to S3 with “Block Public Access” off).
Presigned uploads — SDK/CLI compatibility: Sonic currently isn’t compatible with the STREAMING-UNSIGNED-PAYLOAD-TRAILER signature type used by Boto3 ≥ 1.36.0 and AWS CLI ≥ 2.14.0. If you use the Python example on the presigned URL page, pin Boto3/Botocore to 1.35.*. (This does not affect the AWS SDK for PHP shown below.)
0) Prerequisites
Laravel 9+ (works with older versions; see note about
FILESYSTEM_DISKvsFILESYSTEM_DRIVERbelow).A Sonic Storage Zone (bucket id + S3 credentials + endpoint visible in Storage & Hostnames).



1) Install packages
Laravel’s S3 driver relies on Flysystem’s S3 adapter:
composer require league/flysystem-aws-s3-v3 "^3.0"For the presigned URL variant we’ll also use the AWS SDK for PHP. You can skip the SDK if you only use the classic Storage::disk('s3') upload:
composer require aws/aws-sdk-php:^32) Configure your .env
Set the default disk to S3:
• Laravel 9+:
FILESYSTEM_DISK=s3• Laravel ≤8:
FILESYSTEM_DRIVER=s3Add your Sonic credentials. Use one of the two blocks to match your bucket style:
A) Virtual-hosted style (Sonic default):
AWS_ACCESS_KEY_ID=your_key
AWS_SECRET_ACCESS_KEY=your_secret
AWS_BUCKET=your_bucket_id
AWS_DEFAULT_REGION=us-east-1
AWS_ENDPOINT=https://s3.eu-central.r-cdn.com
AWS_USE_PATH_STYLE_ENDPOINT=false
# Do NOT set AWS_URL here; Laravel will build URLs like:
# https://<bucket-id>.s3.eu-central.r-cdn.com/path/to/fileB) Path-style buckets (deprecated):
Here you must set AWS_URL to the CDN hostname from the Storage & Hostnames tab so Laravel generates correct public links.
AWS_ACCESS_KEY_ID=your_key
AWS_SECRET_ACCESS_KEY=your_secret
AWS_BUCKET=your_bucket_id
AWS_DEFAULT_REGION=us-east-1
AWS_ENDPOINT=https://s3.eu-central.r-cdn.com
AWS_USE_PATH_STYLE_ENDPOINT=true
AWS_URL=https://<your-unique-cdn-hostname>.r-cdn.com3) Configure config/filesystems.php
Your s3 disk should look like this (add the endpoint and path-style flags):
// config/filesystems.php
'disks' => [
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), // scopes SigV4 only
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'), // leave null for virtual-hosted default
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => true,
],
],
Example 1: Server-side upload with the Storage facade
Route routes/web.php:
use App\Http\Controllers\SonicUploadController;
Route::get('/upload', [SonicUploadController::class, 'form']);
Route::post('/upload', [SonicUploadController::class, 'store'])->name('upload.store');Controller app/Http/Controllers/SonicUploadController.php:
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class SonicUploadController extends Controller
{
public function form()
{
return view('upload');
}
public function store(Request $request)
{
$data = $request->validate([
'file' => ['required', 'file', 'max:10240'], // 10 MB
]);
$file = $data['file'];
// Use a unique key in a "uploads/" prefix
$key = 'uploads/'.Str::uuid()->toString().'__'.$file->getClientOriginalName();
// Explicitly pass ContentType so Sonic serves it with the right header
$path = Storage::disk('s3')->putFileAs(
dirname($key), $file, basename($key),
['ContentType' => $file->getMimeType()]
);
// Generate a public URL (Sonic’s default policy allows GET via bucket/CDN URL)
$publicUrl = Storage::disk('s3')->url($path);
return back()->with('ok', "Uploaded to: {$publicUrl}");
}
}
Blade view resources/views/upload.blade.php:
@if (session('ok'))
<p style="padding:.5rem;background:#e6ffed;border:1px solid #b2f5ea">
{{ session('ok') }}
</p>
@endif
<form method="POST" action="{{ route('upload.store') }}" enctype="multipart/form-data">
@csrf
<label>Choose a file (max 10 MB): <input type="file" name="file" required></label>
<button type="submit">Upload</button>
</form>That’s it. If your .env matches the bucket style you created, Storage::disk('s3') will upload to Sonic and Storage::url() will return a CDN-accelerated URL like:
Virtual-hosted (default):
https://<bucket-id>.s3.eu-central.r-cdn.com/...Path-style (deprecated):
https://cXXXzYYY.r-cdn.com/...(public URL you expose should be yourAWS_URLhostname).
Example 2: Direct browser upload using a presigned URL
This flow keeps large file traffic off your app server:
1. Your Laravel backend generates a presigned URL for a given key/mime type.
2. The browser uploads the file directly to Sonic using fetch/XHR to that URL.
3. Your app stores the key and later shows a public URL.
Controller app/Http/Controllers/SonicPresignController.php for issuing presigned URLs:
namespace App\Http\Controllers;
use Aws\S3\S3Client;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class SonicPresignController extends Controller
{
public function sign(Request $request)
{
$data = $request->validate([
'filename' => ['required', 'string'],
'content_type' => ['required', 'string'], // e.g. "image/png"
]);
$bucket = config('filesystems.disks.s3.bucket');
$endpoint = config('filesystems.disks.s3.endpoint');
$usePath = (bool) config('filesystems.disks.s3.use_path_style_endpoint');
// Generate a unique key for the object
$key = 'uploads/'.Str::uuid()->toString().'__'.$data['filename'];
$s3 = new S3Client([
'version' => 'latest',
'region' => config('filesystems.disks.s3.region', 'us-east-1'), // SigV4 scope only
'endpoint' => $endpoint,
'use_path_style_endpoint' => $usePath,
'credentials' => [
'key' => config('filesystems.disks.s3.key'),
'secret' => config('filesystems.disks.s3.secret'),
],
]);
// Build a PutObject command with the ContentType we expect the browser to send
$cmd = $s3->getCommand('PutObject', [
'Bucket' => $bucket,
'Key' => $key,
'ContentType' => $data['content_type'],
]);
// e.g. 60 minutes
$request = $s3->createPresignedRequest($cmd, '+60 minutes');
$url = (string) $request->getUri();
return response()->json([
'upload_url' => $url,
'key' => $key,
// Public URL your app can later show:
'public_url' => $this->publicUrl($key),
]);
}
protected function publicUrl(string $key): string
{
// If you set AWS_URL (path-style/deprecated or custom hostname), use it.
if ($url = config('filesystems.disks.s3.url')) {
return rtrim($url, '/').'/'.$key;
}
// Virtual-hosted default
$bucket = config('filesystems.disks.s3.bucket');
$endpoint = parse_url(config('filesystems.disks.s3.endpoint'), PHP_URL_HOST);
return "https://{$bucket}.{$endpoint}/{$key}";
}
}
Route routes/web.php:
use App\Http\Controllers\SonicPresignController;
Route::post('/presign', [SonicPresignController::class, 'sign']);Frontend:
<input id="file" type="file" />
<button id="go">Upload</button>
<script>
document.getElementById('go').addEventListener('click', async () => {
const file = document.getElementById('file').files[0];
if (!file) return alert('Pick a file');
// 1) Ask Laravel for a PUT presigned URL
const signRes = await fetch('/api/presign', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}'},
body: JSON.stringify({ filename: file.name, content_type: file.type })
});
const { upload_url, key, public_url } = await signRes.json();
// 2) Upload directly to Sonic (critical: Content-Type must match what was signed)
const putRes = await fetch(upload_url, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file
});
if (!putRes.ok) {
const text = await putRes.text();
return alert('Upload failed: ' + text);
}
// 3) Store/display the public URL
alert('Done! Public URL: ' + public_url);
});
</script>You can verify the object via either style:
Virtual-hosted (default):
https://<bucket-id>.s3.eu-central.r-cdn.com/<key>Path-style:
https://cXXXzYYY.r-cdn.com/<key>
Custom hostnames (optional)
You can brand your CDN hostname (bucket URL) to provide a more unified and professional look to your app. Learn how to do this here and then update the AWS_URL variable in your .env file with the value of the newly created hostname.