To upload files to Amazon S3 in Laravel 13, install league/flysystem-aws-s3-v3, add your AWS credentials and bucket to .env, and store the uploaded file with $request->file('document')->store('documents', 's3'). Save the returned path in the database, and use Storage::disk('s3')->temporaryUrl() to give users time-limited access to private files.

Moving uploads off the web server is one of the first things I do when an app needs more than one server or a container-based deployment. Local disks do not survive redeploys and are not shared between instances; S3 solves both problems with almost no code change because Laravel's filesystem API stays the same.

Prerequisites

  • Laravel 13.x on PHP 8.3 or newer.
  • An AWS account, an S3 bucket, and an IAM user or role with permission to read and write that bucket.
  • Block Public Access left on for the bucket, unless you deliberately serve public files.

Step 1: Install the S3 driver

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

The s3 disk is already defined in config/filesystems.php; it only needs the adapter package and credentials.

Step 2: Configure credentials

Add the values to .env:

AWS_ACCESS_KEY_ID=your-key-id AWS_SECRET_ACCESS_KEY=your-secret AWS_DEFAULT_REGION=eu-central-1 AWS_BUCKET=my-app-uploads AWS_USE_PATH_STYLE_ENDPOINT=false 

The default disk config maps these variables to key, secret, region and bucket. When the app runs on AWS infrastructure with an IAM role (EC2, ECS, Lambda), you can leave the key and secret empty and the AWS SDK picks up role credentials automatically. If you want S3 to be the default disk for Storage:: calls, set FILESYSTEM_DISK=s3.

For S3-compatible services such as MinIO, Cloudflare R2 or DigitalOcean Spaces, set AWS_ENDPOINT to the provider's endpoint and, for MinIO, AWS_USE_PATH_STYLE_ENDPOINT=true.

Step 3: Validate and store the upload

<?php namespace App\Http\Controllers; use App\Models\Document; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; class DocumentController extends Controller { public function store(Request $request): RedirectResponse { $request->validate([ 'document' => ['required', 'file', 'mimes:pdf,jpg,png', 'max:10240'], // max 10 MB ]); $file = $request->file('document'); // Stored privately with a random, unique file name. $path = $file->store("documents/{$request->user()->id}", 's3'); Document::create([ 'user_id' => $request->user()->id, 'path' => $path, 'original_name' => $file->getClientOriginalName(), 'size' => $file->getSize(), ]); return back()->with('status', 'Document uploaded.'); } } 

store() generates a unique name, which avoids overwriting and keeps user-supplied names out of your storage keys. Keep the original name in the database for display and downloads. If you need a specific name, use storeAs($directory, $name, 's3').

The HTML form needs enctype="multipart/form-data" and @csrf:

<form method="POST" action="{{ route('documents.store') }}" enctype="multipart/form-data"> @csrf <input type="file" name="document" required> @error('document') <p>{{ $message }}</p> @enderror <button type="submit">Upload</button> </form> 

How do I show or download a private S3 file?

Private objects cannot be opened with a plain URL. Generate a temporary signed URL that expires after a few minutes:

use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Storage; public function show(Document $document) { Gate::authorize('view', $document); $url = Storage::disk('s3')->temporaryUrl( $document->path, now()->addMinutes(5), ['ResponseContentDisposition' => 'attachment; filename="'.$document->original_name.'"'], ); return redirect()->away($url); } 

The third argument passes S3 request parameters. Here it forces a download with the original file name. Authorization still happens in your app before the URL is generated.

Can I make an uploaded file public?

Pass the visibility when storing, for example $file->storePublicly('avatars', 's3'), and read the URL with Storage::disk('s3')->url($path). This only works if the bucket allows public ACLs or a bucket policy grants public read. For most apps, a private bucket plus temporary URLs, or a CDN in front of the bucket, is the safer design.

How do I upload large files directly from the browser?

For big videos or archives, sending the file through PHP wastes memory and request time. Storage::disk('s3')->temporaryUploadUrl($path, now()->addMinutes(5)) returns a pre-signed URL and headers that the browser can use to PUT the file straight to S3. Your app then only records the path once the client confirms the upload.

Common mistakes

  • Committing AWS keys. Keep them in .env or your platform's secret store, never in Git.
  • Too-broad IAM permissions. Grant s3:PutObject, s3:GetObject and s3:DeleteObject on the one bucket, not full S3 access.
  • Wrong region. A mismatch between AWS_DEFAULT_REGION and the bucket region causes signature or redirect errors.
  • Storing full URLs. Save the path, not the URL. URLs change with CDNs and expire when signed.
  • PHP upload limits. upload_max_filesize and post_max_size in php.ini must be larger than your validation limit.
  • Forgetting to delete. When a record is deleted, call Storage::disk('s3')->delete($document->path), or files pile up and cost money.

How to verify it works

Upload a file through the form and check the bucket in the AWS console, or run php artisan tinker and call Storage::disk('s3')->exists('documents/1/...'). In tests, never hit real S3. Storage::fake() swaps the disk for a temporary local one:

<?php use App\Models\User; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; it('stores an uploaded document on s3', function () { Storage::fake('s3'); $user = User::factory()->create(); $this->actingAs($user) ->post(route('documents.store'), [ 'document' => UploadedFile::fake()->create('contract.pdf', 200, 'application/pdf'), ]) ->assertRedirect(); $path = $user->documents()->first()->path; Storage::disk('s3')->assertExists($path); }); 

The documents() relationship on User is assumed here; adapt the lookup to your model.

FAQ

Do I need the AWS SDK separately?

No. The Flysystem S3 adapter requires the AWS SDK for PHP and Composer installs it automatically.

Can I keep using the local disk in development?

Yes. Use a disk name from config, for example config('filesystems.default'), and set FILESYSTEM_DISK=local locally and s3 in production.

How long can a temporary URL last?

S3 allows up to seven days for URLs signed with long-term IAM user credentials. URLs signed with temporary role credentials stop working when those credentials expire. Short lifetimes are safer.

References

Originally published on yasiramin.dev.