Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Laravel File Uploads with S3 R2 and Local Storage

By Kokil Thapa | Last reviewed: September 2026

Every production Laravel application eventually outgrows storing uploads on the web server. Whether you ship legal documents on a client portal, product images on an eCommerce site, or PDF invoices from a booking system, Laravel file uploads with S3, R2, and local storage all flow through the same Filesystem API—you pick a disk in config/filesystems.php and swap it with one .env variable. I've maintained this pattern across Laravel web applications in Nepal and abroad, from local-only dev setups to Cloudflare R2 on shared EC2 infrastructure. This guide walks through configuration, validation, signed URLs, and the production gotchas that actually break deployments.

How do you configure Laravel file uploads with S3, R2, and local storage?

Laravel's filesystem layer wraps Flysystem 3 adapters behind a unified API. You never call AWS or Cloudflare SDKs directly in controllers unless you have a specific reason—Storage::disk('s3') handles the rest.

Step 1: Install the S3 adapter

For S3 and R2 you need the AWS SDK package. Local storage works out of the box.

composer require league/flysystem-aws-s3-v3 "^3.0"

On Laravel 13 with PHP 8.3+, this is the standard path. Laravel 12 on PHP 8.2 uses the same package version.

Step 2: Define disks in config/filesystems.php

A typical production setup defines three disks: local for dev, s3 for AWS, and r2 for Cloudflare. Here is a copy-paste starting point:

// config/filesystems.php (disks array excerpt)

'local' => [
    'driver' => 'local',
    'root' => storage_path('app/private'),
    'serve' => true,
    'throw' => false,
    'report' => false,
],

'public' => [
    'driver' => 'local',
    'root' => storage_path('app/public'),
    'url' => env('APP_URL').'/storage',
    'visibility' => 'public',
    'throw' => false,
    'report' => false,
],

's3' => [
    'driver' => 's3',
    'key' => env('AWS_ACCESS_KEY_ID'),
    'secret' => env('AWS_SECRET_ACCESS_KEY'),
    'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
    'bucket' => env('AWS_BUCKET'),
    'url' => env('AWS_URL'),
    'endpoint' => env('AWS_ENDPOINT'),
    'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
    'throw' => true,
],

'r2' => [
    'driver' => 's3',
    'key' => env('CLOUDFLARE_R2_ACCESS_KEY_ID'),
    'secret' => env('CLOUDFLARE_R2_SECRET_ACCESS_KEY'),
    'region' => 'auto',
    'bucket' => env('CLOUDFLARE_R2_BUCKET'),
    'url' => env('CLOUDFLARE_R2_URL'),
    'endpoint' => env('CLOUDFLARE_R2_ENDPOINT'),
    'use_path_style_endpoint' => true,
    'throw' => true,
],

Step 3: Set environment variables

For local development, keep it simple:

FILESYSTEM_DISK=local

For AWS S3:

FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=your-key
AWS_SECRET_ACCESS_KEY=your-secret
AWS_DEFAULT_REGION=ap-south-1
AWS_BUCKET=your-app-uploads
AWS_URL=https://your-app-uploads.s3.ap-south-1.amazonaws.com

For Cloudflare R2, the endpoint follows the pattern documented in Cloudflare's S3 API guide:

FILESYSTEM_DISK=r2
CLOUDFLARE_R2_ACCESS_KEY_ID=your-r2-key
CLOUDFLARE_R2_SECRET_ACCESS_KEY=your-r2-secret
CLOUDFLARE_R2_BUCKET=your-bucket
CLOUDFLARE_R2_ENDPOINT=https://<account_id>.r2.cloudflarestorage.com
CLOUDFLARE_R2_URL=https://cdn.yourdomain.com

Map a custom domain to the R2 bucket through Cloudflare's dashboard so public URLs do not expose the raw r2.cloudflarestorage.com hostname. That matters for SEO-friendly asset URLs on content-heavy sites.

Laravel File Uploads with S3 R2 and Local StorageController / Job$file->store('docs')Storage Facadeconfig/filesystems.phpLocal Diskstorage/app/AWS S3Flysystem adapterCloudflare R2S3-compatible APISame code path — swap FILESYSTEM_DISK in .env
Laravel file uploads with S3, R2, and local storage share one Storage facade; only the disk configuration changes.

Step 4: Upload from a controller

The upload code stays identical regardless of backend:

use Illuminate\Support\Facades\Storage;

public function store(StoreDocumentRequest $request)
{
    $disk = config('filesystems.default'); // or hardcode 'r2' for mixed setups

    $path = $request->file('document')->store(
        'client-documents/'.$request->user()->id,
        $disk
    );

    $document = Document::create([
        'user_id' => $request->user()->id,
        'path'    => $path,
        'disk'    => $disk,
        'original_name' => $request->file('document')->getClientOriginalName(),
    ]);

    return redirect()->back()->with('success', 'Document uploaded.');
}

Store the disk name in your database. On a legal-tech portal with document sharing, you need to know whether a file lives on local, s3, or r2 when generating download links months later.

What is the difference between local storage, S3, and Cloudflare R2 in Laravel?

All three use the same Laravel API, but operational characteristics differ sharply. Pick based on traffic, budget, and compliance—not hype.

CriteriaLocal / Public DiskAWS S3Cloudflare R2
Best forDev, tiny apps, temp processingAWS-native stacks, enterprise complianceCost-sensitive production, CDN-heavy sites
Egress feesIncluded in server bandwidthPer-GB download charges add upNo egress fees to Cloudflare CDN
Laravel configBuilt-in, zero dependenciesdriver => s3, standard AWS env varsdriver => s3, custom endpoint + path-style
Public URLsphp artisan storage:linkBucket policy or CloudFrontCustom domain via Cloudflare
Backup storyManual rsync / server snapshotsS3 versioning, cross-region replicationObject versioning, lifecycle rules
Typical cost (100 GB stored)Disk on VPS (~Rs 500/mo, ~USD 4)~USD 2.30 storage + egress extra~USD 1.50 storage, zero CDN egress

For a florist eCommerce site serving hundreds of product images daily, R2's zero egress to Cloudflare's CDN is hard to beat. For an enterprise client already on AWS with RDS and EC2, keeping uploads on S3 in the same region reduces latency and simplifies IAM. Local storage is fine for local Laravel development with Sail but a liability on a single VPS with no redundancy.

I've used Amazon S3 on AWS-hosted Laravel apps and R2 on budget-conscious Nepal deployments where every rupee of bandwidth matters. Both work reliably when configured correctly—the failure mode is almost always wrong credentials or a missing use_path_style_endpoint flag on R2.

Which Storage Disk Should You Use?New file upload neededProduction?NoLocal DiskDev / CI onlyYesOn AWS?YesAWS S3Same region as appNoHigh traffic?Cloudflare R2
Decision flow for Laravel file uploads: local for development, S3 for AWS-native stacks, R2 for cost-sensitive production with CDN delivery.

How do you validate and secure file uploads in Laravel?

Storage driver choice does not reduce your obligation to validate uploads on the server. A malicious PDF uploaded to R2 is still a malicious PDF.

Form Request validation

Always validate through a dedicated Form Request—not inline in the controller:

// app/Http/Requests/StoreDocumentRequest.php

public function rules(): array
{
    return [
        'document' => [
            'required',
            'file',
            'max:10240', // 10 MB in kilobytes
            'mimes:pdf,jpg,jpeg,png,doc,docx',
        ],
    ];
}

Additional hardening steps

  1. Never trust client MIME types. Use mimes: or mimetypes: rules, not file extensions alone.
  2. Generate your own filenames. Laravel's store() generates a random hash by default—keep that behaviour. Do not use getClientOriginalName() as the stored filename.
  3. Block public visibility for sensitive files. Set 'visibility' => 'private' on the disk or per upload. Legal documents on portals like client document-sharing systems must never be world-readable.
  4. Serve private files through signed URLs or controller proxies. Do not expose direct bucket paths.
  5. Scan if the use case demands it. For attestation or identity document uploads, integrate a virus scanner in a queued job after upload.

Generating temporary signed URLs

For private files on S3 or R2, generate time-limited URLs instead of streaming through your app:

$url = Storage::disk($document->disk)->temporaryUrl(
    $document->path,
    now()->addMinutes(15)
);

This offloads bandwidth to the object store. On a portal where clients download 5 MB PDFs repeatedly, signed URLs keep your PHP-FPM workers free for application logic. Verify your R2 bucket CORS settings if browsers fetch files directly via JavaScript.

Using Spatie Media Library

On projects with multiple file collections per model—avatars, galleries, attachments—I reach for Spatie Media Library. It integrates with Laravel disks natively:

// In your model
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;

class Product extends Model implements HasMedia
{
    use InteractsWithMedia;
}

// In your controller
$product->addMediaFromRequest('photo')
    ->toMediaCollection('images', config('filesystems.default'));

Switch the disk globally via FILESYSTEM_DISK or per-collection in registerMediaCollections(). I've used this on eCommerce builds like digital product platforms where gift card PDFs and product images share one model but different visibility rules.

Secure Upload and Download FlowBrowsermultipart/formForm Requestmimes, max sizeStorage::store()private visibilityS3 / R2private bucketDatabase: path + disk + original_name + user_idtemporaryUrl() — 15 min signed linkNo direct bucket path exposedAuth policy checks ownerAudit log on download
Secure Laravel file upload pipeline: validate server-side, store privately, serve via signed URLs after authorization checks.

How do you switch storage drivers without rewriting upload code?

The whole point of Laravel's filesystem abstraction is environment portability. Your controller should not contain if (app()->environment('production')) blocks for storage—configuration handles that.

Pattern 1: Default disk from environment

// .env.local
FILESYSTEM_DISK=local

// .env.production
FILESYSTEM_DISK=r2
// Controller — no disk argument, uses default
$path = $request->file('avatar')->store('avatars');

Pattern 2: Explicit disk per use case

Some files belong on different backends. Temporary processing files might stay local while user uploads go to R2:

$tempPath = $request->file('csv')->store('imports', 'local');

ProcessCsvImport::dispatch($tempPath)->onQueue('imports');
// Inside the job, after processing, push results to cloud
Storage::disk('r2')->put(
    'reports/'.$this->reportName,
    $generatedPdf,
    'private'
);

Storage::disk('local')->delete($this->tempPath);

Pattern 3: Migration from local to cloud

When moving an existing app from local disk to S3 or R2, write an Artisan command rather than manual rsync:

// app/Console/Commands/MigrateUploadsToCloud.php

public function handle(): int
{
    Document::where('disk', 'public')->chunkById(100, function ($documents) {
        foreach ($documents as $document) {
            if (! Storage::disk('public')->exists($document->path)) {
                $this->warn("Missing: {$document->path}");
                continue;
            }

            $contents = Storage::disk('public')->get($document->path);

            Storage::disk('r2')->put($document->path, $contents, 'private');

            $document->update(['disk' => 'r2']);

            Storage::disk('public')->delete($document->path);
        }
    });

    return self::SUCCESS;
}

Run this during a maintenance window. Update Nginx/Apache to stop serving /storage for migrated paths. A website migration project I handled followed exactly this pattern—chunked migration, verify, then cut over DNS and env vars together.

Testing with fake storage

Laravel's Storage::fake() lets you test uploads without touching real disks:

public function test_user_can_upload_document(): void
{
    Storage::fake('r2');

    $file = UploadedFile::fake()->create('contract.pdf', 500, 'application/pdf');

    $response = $this->actingAs($user)->post('/documents', [
        'document' => $file,
    ]);

    $response->assertRedirect();
    Storage::disk('r2')->assertExists('client-documents/'.$user->id.'/'./* hash */);
}

Use the same disk name in tests that production uses. If your test fakes local but production writes to r2, you miss configuration bugs in the R2 disk definition. For debugging API payloads during integration work, a JSON formatter helps inspect webhook responses from storage-related services.

What are common production mistakes with Laravel cloud storage?

These are the failures I've debugged on live deployments—not theoretical edge cases.

Wrong permissions after deploy

Local disk uploads fail silently or throw 500 errors when storage/app is not writable by the PHP-FPM user. Cloud storage removes this particular headache, but queue workers processing uploads still need local temp space. See Ubuntu file permissions for the ownership model on Apache + PHP-FPM servers.

Missing CORS on R2/S3 buckets

Direct browser uploads—whether through presigned POST URLs or JavaScript fetch to signed URLs—fail with opaque CORS errors when the bucket policy is too restrictive. Configure CORS on the bucket to allow your app domain and methods (GET, PUT, POST).

Forgetting visibility defaults

S3 buckets created after April 2023 block public access by default—a good thing. But developers expect Storage::url() to return a working link and get confused when it 403s. Either make specific prefixes public via bucket policy, or use signed URLs exclusively.

Storing absolute URLs instead of paths

Save client-documents/9/x7k2m.pdf in the database, not https://cdn.example.com/client-documents/9/x7k2m.pdf. Domain changes, CDN migrations, and bucket renames break absolute URLs. Generate URLs at read time:

public function getUrlAttribute(): string
{
    return Storage::disk($this->disk)->url($this->path);
}

Not configuring queue workers for large files

Upload the file synchronously in the request, then queue processing (resize, watermark, virus scan). Do not queue the raw upload bytes unless you use a presigned direct-to-S3 upload flow. PHP's upload_max_filesize and post_max_size still apply to traditional form uploads.

Direct-to-cloud uploads for large files

For files over 50 MB—video uploads, bulk PDFs—generate a presigned PUT URL and let the browser upload directly to R2/S3:

// Controller returns presigned URL
$url = Storage::disk('r2')->temporaryUploadUrl(
    'videos/'.Str::uuid().'.mp4',
    now()->addMinutes(10)
);

return response()->json(['upload_url' => $url]);

The file never touches your server RAM. Your app records the path after the client confirms upload. This pattern matters on shared VPS hosting common in Nepal where RAM is limited.

Local VPS vs Cloud R2 StorageBefore: Local DiskAfter: R2 + CDNUploads fill VPS diskNo redundancy, backup manualObject store scalesCDN serves static assetsPHP streams every downloadFPM workers blockedSigned URLs offload trafficZero egress via CloudflareDeploy breaks symlinksstorage:link forgottenSame code, new .env diskDeployer symlink unchangedMigrateTypical outcome: lower server load, predictable storage costs
Migrating Laravel file uploads from local VPS storage to Cloudflare R2 reduces disk pressure and offloads download traffic to the CDN.

Deployment checklist for cloud storage

  • Confirm AWS_ACCESS_KEY_ID or R2 credentials exist in production .env (stored outside the release directory in Deployer shared files).
  • Verify bucket name matches across staging and production—or use separate buckets per environment.
  • Run php artisan config:cache after env changes so the disk config is not stale.
  • Test one upload and one signed URL download after every deploy.
  • Monitor bucket size and set lifecycle rules to purge temp uploads after 30 days.

Sister sites on my shared Linux administration pipeline use Deployer 7 with GitLab CI—the .env lives in a shared directory persisting across releases, which is where R2 credentials must live. Never commit credentials to Git. For encoding file metadata in API responses, base64 utilities occasionally help during debugging, though production APIs should return plain JSON with signed URL fields.

If you are building on AWS end-to-end, read the companion guide on hosting Laravel on AWS EC2 with RDS and S3. For local S3-compatible testing without cloud costs, MinIO self-hosted S3 storage mirrors production R2 behaviour on your laptop.

Key Takeaways

  • Configure S3 and R2 as driver => s3 disks in config/filesystems.php; swap backends with FILESYSTEM_DISK in .env without changing controller code.
  • Store the disk name and relative path in your database—never absolute CDN URLs—so domain and bucket changes do not break old records.
  • Validate every upload through Form Requests with mimes and max rules; set private visibility for sensitive documents.
  • Serve private files via temporaryUrl() after authorization checks, not direct bucket links or PHP streaming.
  • Use R2 when egress costs and CDN integration matter; use S3 when the app already lives on AWS; keep local disk for development only.
  • Write an Artisan migration command to move existing local files to cloud storage in chunks, updating the database disk column as you go.

People Also Ask

Does Cloudflare R2 work with Laravel's S3 driver?

Yes. R2 exposes an S3-compatible API. Define a disk with driver => s3, set endpoint to your https://<account_id>.r2.cloudflarestorage.com URL, enable use_path_style_endpoint => true, and set region to auto. The same league/flysystem-aws-s3-v3 adapter handles both AWS and R2.

How do I make Laravel storage files publicly accessible?

For local storage, run php artisan storage:link to symlink public/storage to storage/app/public. For S3 or R2, either set bucket policy for public read on a prefix, configure 'visibility' => 'public' on the disk, or map a custom domain through Cloudflare. Public product images on eCommerce sites can use public visibility; private client documents should not.

Can I use multiple storage disks in the same Laravel app?

Absolutely. Pass the disk name as the second argument to store(), or call Storage::disk('r2') explicitly. A common pattern keeps temporary imports on local and permanent user uploads on r2. Spatie Media Library supports per-collection disk configuration for the same model.

What PHP extensions are required for S3 uploads in Laravel?

You need the curl and simplexml PHP extensions, which are enabled by default on most PHP 8.3+ installations. The AWS SDK also benefits from openssl for HTTPS. No special fileinfo requirement beyond what Laravel already needs for upload validation.

Ship file uploads that survive production traffic

Laravel file uploads with S3, R2, and local storage are not three different systems—they are one Filesystem API with swappable backends. Configure disks once, validate on the server, store paths not URLs, and serve sensitive files through signed links. That architecture scales from a Kathmandu law firm's document portal to a multi-currency florist shop serving images globally.

If you want help wiring cloud storage into an existing Laravel app—or migrating off a full VPS disk—enterprise Laravel development and ongoing maintenance are where I spend most of my time. Browse the project portfolio for document portals and eCommerce builds that run this pattern daily, or get in touch to discuss your storage setup.

Frequently Asked Questions

They all use Laravel’s Storage facade and Flysystem 3 adapters. You define disks in config/filesystems.php, set FILESYSTEM_DISK in .env, and call store() on uploaded files—the backend changes via config only.

Run composer require league/flysystem-aws-s3-v3 ^3.0. In config/filesystems.php, define s3 with standard AWS env vars and r2 with driver s3, region auto, your Cloudflare endpoint, and use_path_style_endpoint true. Set FILESYSTEM_DISK to s3 or r2 in .env along with AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION, AWS_BUCKET for S3, or CLOUDFLARE_R2_ACCESS_KEY_ID, CLOUDFLARE_R2_SECRET_ACCESS_KEY, CLOUDFLARE_R2_BUCKET, CLOUDFLARE_R2_ENDPOINT, and CLOUDFLARE_R2_URL for R2. Map a custom domain through Cloudflare so public URLs do not expose the raw r2.cloudflarestorage.com hostname.

On Laravel 13 with PHP 8.3 or higher, league/flysystem-aws-s3-v3 ^3.0 is the standard path. Laravel 12 on PHP 8.2 uses the same package version. Local storage works out of the box with no extra adapter. The filesystem layer wraps Flysystem 3—you normally never call AWS or Cloudflare SDKs directly in controllers unless you have a specific reason.

At roughly 100 GB stored, R2 costs about USD 1.50 with zero egress to Cloudflare CDN. S3 runs about USD 2.30 plus per-GB download charges. Local VPS disk for the same volume is roughly Rs 500 per month, about USD 4.

All three use the same Laravel Storage API but differ operationally. Local suits dev, tiny apps, and temp processing with zero dependencies and php artisan storage:link for public URLs. S3 fits AWS-native stacks needing enterprise compliance, versioning, and cross-region replication. R2 targets cost-sensitive production and CDN-heavy sites because egress to Cloudflare CDN is free. Pick based on traffic, budget, and compliance. On real deployments, both cloud options fail most often from wrong credentials or a missing use_path_style_endpoint flag on R2.

Use local for development and Sail setups. Choose S3 when the stack is already AWS-native with EC2 and RDS in the same region. Pick R2 for cost-sensitive production serving many images through Cloudflare CDN, especially when egress fees matter.

Call store on the uploaded file with an optional disk argument, for example storing under a user-specific folder on whichever disk config/filesystems.default resolves to. Save both the returned path and the disk name in your database so you can generate correct download links months later. On legal-tech portals with document sharing, knowing whether a file lives on local, s3, or r2 is essential. Avoid environment if-blocks in controllers—let FILESYSTEM_DISK handle dev versus production.

Always validate through a dedicated Form Request, not inline in the controller. Use required, file, max size in kilobytes, and mimes rules—never trust client MIME types or extensions alone. Keep Laravel’s random store() filenames; do not use getClientOriginalName() as the stored filename. Set visibility to private for sensitive files such as legal documents on client portals. Serve private files through temporaryUrl signed URLs or authorized controller proxies, never direct world-readable bucket paths. For attestation or identity documents, queue a virus scan after upload.

Call temporaryUrl on Storage::disk with the document’s saved disk and path, passing an expiry such as fifteen minutes from now. This offloads download bandwidth to the object store instead of streaming through PHP-FPM workers—important on portals where clients download PDFs repeatedly. If browsers fetch files directly via JavaScript, verify your R2 or S3 bucket CORS settings allow your app domain and the required GET method.

Set FILESYSTEM_DISK=local in development and FILESYSTEM_DISK=r2 in production, then call store without a disk argument to use the default. For mixed setups, keep temporary CSV imports on local, dispatch a queued job to process them, push results to r2 with private visibility, and delete the local temp file. When migrating existing apps, write an Artisan command that chunks database records, copies files from the public disk to r2, updates the disk column, deletes local copies, and run it during a maintenance window alongside env and CDN cutover.

Set FILESYSTEM_DISK=r2, CLOUDFLARE_R2_ACCESS_KEY_ID, CLOUDFLARE_R2_SECRET_ACCESS_KEY, CLOUDFLARE_R2_BUCKET, CLOUDFLARE_R2_ENDPOINT following the account_id.r2.cloudflarestorage.com pattern from Cloudflare’s S3 API guide, and CLOUDFLARE_R2_URL pointing at your custom CDN domain. The r2 disk uses driver s3, region auto, and use_path_style_endpoint true. Mapping a custom domain through Cloudflare keeps public asset URLs SEO-friendly instead of exposing the raw storage hostname.

Local disk uploads fail when storage/app is not writable by the PHP-FPM user after deploy. Missing bucket CORS causes opaque browser errors on presigned or direct uploads. S3 buckets block public access by default since April 2023, so Storage::url() may 403 unless you adjust bucket policy or use signed URLs exclusively. Storing absolute CDN URLs in the database breaks on domain or bucket changes—save paths instead. Do not queue raw upload bytes unless using presigned direct-to-cloud flow; upload synchronously, then queue resize, watermark, or scan jobs.

Store the relative path and disk name, not a full URL like https://cdn.example.com/client-documents/9/file.pdf. Domain changes, CDN migrations, and bucket renames break absolute URLs saved in columns. Generate URLs at read time with a model accessor calling Storage::disk($this->disk)->url($this->path). This pattern survives backend switches from local to r2 without rewriting stored records.

Use Storage::fake with the same disk name production uses, create an UploadedFile::fake with a realistic mime type, post through your authenticated route, and assertExists on the expected path. If tests fake local but production writes to r2, you miss configuration bugs such as a wrong endpoint or missing path-style setting. Storage::fake exercises the full upload pipeline without credentials or network calls.

Generate a presigned PUT URL with temporaryUploadUrl, return it as JSON, and let the browser upload directly to cloud storage. The file never touches your server RAM—critical on shared VPS hosting common in Nepal where memory is limited. PHP upload_max_filesize and post_max_size still apply to traditional form uploads. After the client confirms success, record the path in your database. Configure bucket CORS to allow PUT from your application domain.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: