Add Sonic object storage to Laravel

Laravel logo

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_DISK vs FILESYSTEM_DRIVER below).

  • 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:^3

2) Configure your .env

Set the default disk to S3:

• Laravel 9+:

FILESYSTEM_DISK=s3

Laravel ≤8:

FILESYSTEM_DRIVER=s3

Add 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/file

B) 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.com

3) 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 your AWS_URL hostname).

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.