A Laravel file upload to S3 takes four things: the league/flysystem-aws-s3-v3 package, an s3 disk in config/filesystems.php, a validated UploadedFile, and one call to $request->file('document')->store('documents', 's3'). That is the whole happy path. Everything else in this guide exists because the happy path is not what breaks in production.
Most tutorials stop at that one line. Then a user uploads a 4 MB PNG renamed to invoice.pdf, your validation waves it through because you only checked the extension, and now an untrusted file sits in a public bucket with a guessable URL. Or the upload works fine locally and times out on a 200 MB video in staging.
You already know how to write a controller. What you probably have not tightened is the layer between the browser and the bucket, where MIME spoofing, path traversal, public ACLs, and memory limits live. This guide walks the full path: configuring the S3 disk, writing validation rules that check real file contents, keeping private files private with temporary URLs, processing images off the request cycle, and testing all of it without touching AWS.
Key Takeaways
How Laravel file upload to S3 actually works
Laravel does not talk to S3 directly. It talks to Flysystem, which talks to the AWS SDK, which talks to S3. Your code only ever sees the Storage facade, so swapping the local disk for S3 is a config change, not a rewrite.
Install the S3 driver first. On Laravel 9 through 12 it is a separate package:
composer require league/flysystem-aws-s3-v3 "^3.0"Then add your credentials to .env. Never commit these, and never hardcode them in config/filesystems.php.
AWS_ACCESS_KEY_ID=your-key-id
AWS_SECRET_ACCESS_KEY=your-secret
AWS_DEFAULT_REGION=ap-south-1
AWS_BUCKET=your-bucket-name
AWS_USE_PATH_STYLE_ENDPOINT=falseThe s3 disk already ships in config/filesystems.php. The part worth reading closely is what you do not set:
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'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,
],Set 'throw' => true. Without it, Flysystem swallows failures and returns false, so a bucket policy error looks identical to a successful upload from your controller's point of view. With it, you get an exception you can log and alert on.
Where to use S3 versus the local disk
Use the local disk for temporary work: a CSV you are about to parse, a zip you are about to expand. Use S3 for anything a user will retrieve later, and for anything at all if you run more than one web server. Two servers behind a load balancer with local storage means half your users get a 404 on the avatar they just uploaded.
Concern | Local disk | S3 |
|---|---|---|
Multi-server safe | No | Yes |
Survives redeploys | Depends on volumes | Yes |
Serving cost | Your bandwidth | S3 plus optional CDN |
Best for | Temp files, caches | User uploads, documents, media |
Validation rules that actually stop bad Laravel file uploads
This is where most guides get thin, and it is the part that matters. A validation rule that checks the extension is checking a string the user controls.
Put the rules in a FormRequest, not the controller. It keeps the controller readable and gives you one place to audit.
php artisan make:request StoreDocumentRequest<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreDocumentRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Document::class);
}
public function rules(): array
{
return [
'document' => [
'required',
'file',
'mimetypes:application/pdf,image/jpeg,image/png',
'max:10240',
],
'title' => ['required', 'string', 'max:120'],
];
}
public function messages(): array
{
return [
'document.mimetypes' => 'Upload a PDF, JPG, or PNG file.',
'document.max' => 'The file must be 10 MB or smaller.',
];
}
}Three details in there earn their keep.
Use mimetypes, not just mimes
mimes:pdf,jpg maps extensions to expected MIME types. mimetypes:application/pdf compares against the MIME type PHP guesses by reading the file's contents. The second is the stronger check, and the Laravel validation documentation spells out the difference. Use mimetypes as your gate, and add mimes alongside it when you also want to reject a valid PDF that arrives named report.txt.
For images specifically, dimensions is worth adding. It rejects a 20000 by 20000 pixel PNG that would otherwise detonate your image library's memory before any of your code runs:
'avatar' => [
'required',
'image',
'mimetypes:image/jpeg,image/png,image/webp',
'dimensions:min_width=200,min_height=200,max_width=4000,max_height=4000',
'max:5120',
],Remember that max is in kilobytes
max:10240 is 10 MB, not 10 KB and not 10240 bytes. This trips people up constantly. And your rule is only the second gate. PHP's upload_max_filesize and post_max_size come first, and if the request exceeds post_max_size, PHP discards the body before Laravel sees it, so your validator returns a confusing empty-field error instead of a size error. Set the PHP limits above your application limit and let the validator produce the message.
Validate the file, then trust nothing about its name
Even a genuine PDF can carry a filename like ../../.env. Never interpolate getClientOriginalName() into a storage path. More on that in the next section.
Curious how much of this boilerplate you can skip? You can generate a Laravel app from a prompt with the FormRequest, controller, and Pest tests already wired together, and own the resulting code.
Build your Laravel app with AI
Skip the boilerplate. StackMivo turns a plain-English prompt into production-ready Laravel apps, with models, migrations, controllers, CRUD, auth, and tests.
Get started freeStoring the file safely on the S3 disk
The controller stays short because the request already did the hard work:
public function store(StoreDocumentRequest $request)
{
$path = $request->file('document')->store('documents', 's3');
$document = $request->user()->documents()->create([
'title' => $request->validated('title'),
'path' => $path,
'original_name' => $request->file('document')->getClientOriginalName(),
'mime_type' => $request->file('document')->getMimeType(),
'size' => $request->file('document')->getSize(),
]);
return response()->json($document, 201);
}store() generates a unique hashed filename and returns the full path, something like documents/9aX2kQ1vBn7pLm3.pdf. Store the original name in a database column if you need it for downloads. Keep it out of the storage key entirely.
When you do need a predictable key, build it yourself and keep every segment under your control:
$path = $request->file('document')->storeAs(
"users/{$request->user()->id}/documents",
Str::uuid().'.'.$request->file('document')->extension(),
's3'
);Note extension(), not clientExtension(). The first is derived from the file's contents; the second is the user's string. That single method choice closes the door on evil.php.jpg tricks.
Do not set visibility to public out of habit
Plenty of tutorials pass 'public' as the third argument. That makes the object world-readable at a stable URL forever, which is fine for a marketing image and wrong for an invoice, a passport scan, or a contract. Default to private, then hand out short-lived links. Most modern buckets have Block Public Access enabled anyway, and a public-visibility write will simply fail against them.
A mini story about the bucket nobody audited
A four-person Laravel team at a logistics startup shipped a customer document portal on a Friday. Uploads used store('docs', 's3') with 'public' visibility because that is what the tutorial they copied did, and the bucket had Block Public Access turned off from an earlier static-site experiment. Six weeks later a customer support ticket included a screenshot with a bucket URL in it. The team pasted the URL, stripped the filename, and found that while listing was denied, every document path already shared over email was still publicly readable to anyone who had ever received one. The fix took two hours: flip the bucket to private, run a one-off Artisan command to reset object ACLs, and replace every stored URL with a signed one generated at request time. The lesson was not that S3 is dangerous. It was that the default in a code sample is not a security decision.
Serving private files with temporary URLs
Presigned URLs are the answer for private files. Laravel generates them in one call, and the AWS presigned URL documentation explains what the signature covers.
use Illuminate\Support\Facades\Storage;
public function download(Document $document)
{
$this->authorize('view', $document);
return redirect(
Storage::disk('s3')->temporaryUrl(
$document->path,
now()->addMinutes(5)
)
);
}Two things make this safe. The Policy check runs before any URL exists, so authorization is yours and not S3's. And the link expires, so a URL pasted into a Slack channel stops working before it spreads.
Keep expiry short. Five minutes covers a click-to-download. An hour covers a video someone might scrub through. Twenty-four hours is a permanent link with extra steps.
If you need the file's bytes in PHP rather than a redirect, read the stream instead of loading it whole:
return Storage::disk('s3')->download($document->path, $document->original_name);When to skip S3 in the request cycle entirely
For large files, do not proxy bytes through your app at all. Generate a presigned PUT URL, let the browser upload straight to S3, and have your app record the resulting key. Your PHP process stops being the bottleneck, and max_execution_time stops being a factor. This is the standard pattern behind direct-upload libraries in the Laravel ecosystem, and it is the right default for video or large archives.
Processing Laravel image uploads without blocking the request
A Laravel image upload usually needs more than storage. You want a thumbnail, maybe a WebP variant, maybe EXIF stripped. None of that belongs in the request cycle.
Store the original, dispatch a job, return a response:
public function store(StoreAvatarRequest $request)
{
$path = $request->file('avatar')->store('avatars/originals', 's3');
$request->user()->update(['avatar_path' => $path]);
GenerateAvatarVariants::dispatch($request->user());
return back()->with('status', 'Avatar uploaded. Thumbnails are processing.');
}The job pulls from S3, resizes, and writes the variants back:
class GenerateAvatarVariants implements ShouldQueue
{
use Queueable;
public function __construct(public User $user) {}
public function handle(ImageManager $images): void
{
$source = Storage::disk('s3')->get($this->user->avatar_path);
foreach ([64, 128, 256] as $size) {
$resized = $images->read($source)
->cover($size, $size)
->toWebp(quality: 82);
Storage::disk('s3')->put(
"avatars/{$size}/{$this->user->id}.webp",
(string) $resized
);
}
}
}Two upsides beyond speed. Failed resizes retry on their own instead of showing the user a 500, and a corrupt file that crashes your image library takes down a queue job rather than a web request.
Cap the memory a job can use, and set a sensible $timeout on the job class. An image library reading a 30 MB TIFF will happily consume more RAM than your worker has.
Deleting files when the model goes away
Orphaned S3 objects are a slow, boring bill. Clean up in a model event or an observer:
protected static function booted(): void
{
static::deleting(function (Document $document) {
Storage::disk('s3')->delete($document->path);
});
}If the model uses soft deletes, hook forceDeleted instead. Deleting the S3 object while the row is still restorable leaves you with a record pointing at nothing.
Testing Laravel S3 uploads without touching AWS
You do not need a bucket to test uploads, and you should not use one. Storage::fake() swaps the disk for an in-memory local disk while keeping the same assertions available.
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
it('stores a valid pdf on the s3 disk', function () {
Storage::fake('s3');
$user = User::factory()->create();
$file = UploadedFile::fake()->create('contract.pdf', 512, 'application/pdf');
$this->actingAs($user)
->post('/documents', ['title' => 'Contract', 'document' => $file])
->assertCreated();
$document = $user->documents()->sole();
Storage::disk('s3')->assertExists($document->path);
expect($document->path)->not->toContain('contract.pdf');
});
it('rejects a file that lies about its type', function () {
Storage::fake('s3');
$file = UploadedFile::fake()->create('invoice.pdf', 128, 'image/png');
$this->actingAs(User::factory()->create())
->post('/documents', ['title' => 'Fake', 'document' => $file])
->assertInvalid('document');
Storage::disk('s3')->assertDirectoryEmpty('documents');
});
it('rejects files over the size limit', function () {
Storage::fake('s3');
$file = UploadedFile::fake()->create('huge.pdf', 20480, 'application/pdf');
$this->actingAs(User::factory()->create())
->post('/documents', ['title' => 'Huge', 'document' => $file])
->assertInvalid('document');
});UploadedFile::fake()->image('avatar.jpg', 800, 600) creates a real image with real dimensions, so dimensions rules get tested properly. The Laravel file upload testing docs cover the full set of fake helpers.
Write the negative tests. The passing upload will get exercised by every manual click. The rejected-spoofed-MIME case only ever runs in CI, and it is the one protecting you.
A checklist before you ship
Run through this on any endpoint that accepts a file:
Rules live in a FormRequest with
mimetypes,max, anddimensionswhere relevant.No user-supplied string reaches the storage key. Use
store(),storeAs()with a UUID, orextension().Bucket is private with Block Public Access on, and access flows through
temporaryUrl()after a Policy check.PHP limits (
upload_max_filesize,post_max_size) sit above your validator'smaxso users see a real error message.Heavy work is queued, with a job timeout and a memory ceiling.
Deletes cascade to S3 via a model event so you stop paying for orphans.
Tests cover rejection, not just the happy path, using
Storage::fake('s3').Credentials come from the environment, and IAM permissions are scoped to the one bucket and prefix the app needs.
The last one gets skipped most often. An IAM user with s3:* across every bucket turns one leaked key into a whole-account problem. Scope it to GetObject, PutObject, and DeleteObject on arn:aws:s3:::your-bucket/* and nothing more, following the AWS IAM best practices.
Wrapping up
A Laravel file upload to S3 is one method call surrounded by decisions. Get the disk configured with 'throw' => true so failures are loud. Validate on mimetypes rather than the extension a user typed, and cap size in kilobytes with PHP's own limits set higher. Let store() name the file so no request string ever becomes a storage path. Keep the bucket private and serve through short-lived presigned URLs behind a Policy check. Push resizing and scanning into queued jobs. Then prove the whole thing with Storage::fake('s3'), including the cases you expect to fail.
Pick one existing upload endpoint in your codebase this week and walk it through the checklist above. Most teams find at least two of those eight items missing, and the fixes are small when you take them one at a time. Once this pattern is in your muscle memory, file handling stops being the part of the app you quietly hope nobody probes.
Build your Laravel app with AI
Skip the boilerplate. StackMivo turns a plain-English prompt into production-ready Laravel apps, with models, migrations, controllers, CRUD, auth, and tests.
Get started free