Contents
Every client I've taken over a project from has the same story at least once: files uploaded by users, stored on the server, then lost during a deployment or migration. Sometimes it's a few profile photos. Sometimes it's years of invoices. Either way, it's a painful conversation.
Laravel Storage with S3 solves this permanently. Cloud file storage from day one means your files live outside the server, scale without limits, and survive anything you do to your infrastructure.
Why Local File Storage Will Eventually Bite You
Storing uploads on your server works fine until it doesn't. The problems show up when you:
- Deploy and your deploy process wipes the storage directory
- Scale horizontally and uploads on server A aren't visible on server B
- Hit disk limits and start making expensive emergency decisions
- Need to migrate servers and discover moving gigabytes of files is slow and risky
None of these are edge cases. They're the normal lifecycle of a growing application. S3 sidesteps all of them.
Wiring Up Laravel Storage + S3
Install the AWS SDK:
composer require league/flysystem-aws-s3-v3
Add your credentials to .env:
AWS_ACCESS_KEY_ID=your-key
AWS_SECRET_ACCESS_KEY=your-secret
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=your-bucket-name
Set your default filesystem in config/filesystems.php or just use the s3 disk explicitly when you need it. Either way works — I usually set FILESYSTEM_DISK=s3 in production and leave local for development.
Uploading a File
Here's a basic file upload handler:
public function store(Request $request)
{
$request->validate(['file' => 'required|file|max:10240']);
$path = $request->file('file')->store('uploads', 's3');
Document::create([
'user_id' => auth()->id(),
'path' => $path,
]);
}
Laravel handles the rest — it talks to S3, uploads the file, and returns the path. That path is what you store in your database. The actual file lives in the cloud.
Retrieving Files Securely
Public files are easy — you can build URLs with Storage::url($path). But for anything sensitive, use temporary signed URLs:
$url = Storage::disk('s3')->temporaryUrl($document->path, now()->addMinutes(15));
This generates a URL that expires after 15 minutes. The file stays private in S3 — users can only access it through your application, which can enforce permissions before generating the URL. This pattern is solid for invoices, contracts, medical records, anything you don't want publicly guessable.
Handling Different Environments
One of the cleanest things about this setup is environment separation. In your .env.local you keep FILESYSTEM_DISK=local. In production, it flips to s3. Your code never changes. The disk abstraction handles everything.
You can even have a staging bucket that mirrors production structure without touching real data. This is the kind of thing that makes debugging file issues in staging actually useful.
What About Costs?
S3 is cheap. Genuinely cheap. For most small-to-medium apps, you're looking at a few dollars a month — sometimes less. The cost of lost files, emergency migrations, or disk-full incidents is orders of magnitude higher in both time and money.
This is one of those architectural decisions that costs almost nothing upfront and saves real pain later. It's not about premature optimization — it's about not painting yourself into a corner.
If you're starting a new project, start with S3. If you're on an existing app with local storage, the migration is a script plus an afternoon. Let's talk about it if you want help getting there cleanly.