Laravel File Storage: Local, S3, and Beyond
Handling file uploads the right way. Learn Laravel's filesystem abstraction — store files locally in development, S3 in production, switch with one config change. No more hardcoded paths and manual file management.
The Old Way: Hardcoded Paths and Chaos
My early PHP file handling was embarrassing:
// The horror
$target_dir = "/var/www/html/uploads/";
$target_file = $target_dir . basename($_FILES["fileToUpload"]["name"]);
if (move_uploaded_file($_FILES["fileToUpload"]["tmp_name"], $target_file)) {
echo "File uploaded successfully";
}
Problems:
- Hardcoded server paths
- No validation
- Filename collisions
- Can’t switch to S3 without rewriting everything
- Public folder = security nightmare
Laravel’s filesystem abstraction fixed all of this.
The Storage Facade
Laravel abstracts file operations behind a clean API:
use Illuminate\Support\Facades\Storage;
// Store a file
Storage::put('file.txt', 'Contents');
// Get a file
$contents = Storage::get('file.txt');
// Check if file exists
if (Storage::exists('file.txt')) {
// ...
}
// Delete a file
Storage::delete('file.txt');
// Get file URL
$url = Storage::url('file.txt');
This works the same whether files are stored locally, on S3, or anywhere else.
Configuration: Disks
Storage “disks” are configured in config/filesystems.php:
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app'),
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
],
'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'),
],
],
'default' => env('FILESYSTEM_DRIVER', 'local'),
Switch between disks easily:
// Use default disk
Storage::put('file.txt', 'contents');
// Use specific disk
Storage::disk('s3')->put('file.txt', 'contents');
Storage::disk('local')->put('file.txt', 'contents');
Change FILESYSTEM_DRIVER=s3 in .env and your entire app uses S3. No code changes.
Handling File Uploads
Basic Upload
public function store(Request $request)
{
$request->validate([
'document' => 'required|file|mimes:pdf,doc,docx|max:10240', // 10MB max
]);
// Store with auto-generated name
$path = $request->file('document')->store('documents');
// Returns: "documents/randomstring.pdf"
// Store with custom name
$path = $request->file('document')->storeAs(
'documents',
'custom-name.pdf'
);
// Store on specific disk
$path = $request->file('document')->store('documents', 's3');
// Save path to database
$user->document_path = $path;
$user->save();
return redirect()->back()->with('success', 'Document uploaded!');
}
Image Uploads with Validation
public function updateAvatar(Request $request)
{
$request->validate([
'avatar' => 'required|image|mimes:jpeg,png,jpg,gif|max:2048|dimensions:min_width=100,min_height=100',
]);
$user = auth()->user();
// Delete old avatar if exists
if ($user->avatar) {
Storage::delete($user->avatar);
}
// Store new avatar
$path = $request->file('avatar')->store('avatars', 'public');
$user->avatar = $path;
$user->save();
return redirect()->back()->with('success', 'Avatar updated!');
}
Public vs Private Files
Private Files (Default)
Stored in storage/app/. Not accessible via URL. Good for sensitive documents.
// Store privately
$path = $request->file('contract')->store('contracts');
// Download via controller
public function download($id)
{
$contract = Contract::findOrFail($id);
// Check authorization
$this->authorize('view', $contract);
return Storage::download($contract->path);
}
Public Files
Stored in storage/app/public/. Accessible via URL.
First, create the symlink:
php artisan storage:link
This creates a symlink from public/storage to storage/app/public.
// Store publicly
$path = $request->file('avatar')->store('avatars', 'public');
// Get public URL
$url = Storage::url($path);
// Returns: "/storage/avatars/filename.jpg"
// In Blade
<img src="{{ Storage::url($user->avatar) }}" alt="Avatar">
// Or use asset helper
<img src="{{ asset('storage/' . $user->avatar) }}" alt="Avatar">
Working with S3
Install the S3 driver:
composer require league/flysystem-aws-s3-v3
Configure in .env:
FILESYSTEM_DRIVER=s3
AWS_ACCESS_KEY_ID=your-key
AWS_SECRET_ACCESS_KEY=your-secret
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=your-bucket-name
AWS_URL=https://your-bucket.s3.amazonaws.com
Now all Storage:: calls go to S3:
// Stores directly to S3
$path = $request->file('video')->store('videos');
// Get public URL from S3
$url = Storage::url($path);
// Returns: "https://your-bucket.s3.amazonaws.com/videos/filename.mp4"
Temporary URLs for Private S3 Files
// Generate URL that expires in 5 minutes
$url = Storage::temporaryUrl(
'private-documents/contract.pdf',
now()->addMinutes(5)
);
User can download for 5 minutes, then the link dies. Perfect for sensitive files.
Common File Operations
Reading Files
// Get contents
$contents = Storage::get('file.txt');
// Get as stream (for large files)
$stream = Storage::readStream('large-video.mp4');
// Return as response
return Storage::response('file.txt');
return Storage::download('file.txt', 'custom-name.txt');
Writing Files
// Put contents
Storage::put('file.txt', 'Contents');
// Put from stream
Storage::putStream('large-file.zip', $stream);
// Append to file
Storage::append('log.txt', 'New line');
// Prepend to file
Storage::prepend('log.txt', 'First line');
Copying and Moving
Storage::copy('old/file.txt', 'new/file.txt');
Storage::move('old/file.txt', 'new/file.txt');
File Information
// Size in bytes
$size = Storage::size('file.txt');
// Last modified timestamp
$time = Storage::lastModified('file.txt');
// Get MIME type
$mime = Storage::mimeType('file.txt');
Directories
// Get all files in directory
$files = Storage::files('documents');
// Get all files recursively
$files = Storage::allFiles('documents');
// Get all directories
$directories = Storage::directories('uploads');
// Create directory
Storage::makeDirectory('new-folder');
// Delete directory
Storage::deleteDirectory('old-folder');
File Visibility
Control who can access files:
// Store with specific visibility
Storage::put('file.txt', 'contents', 'public');
Storage::put('private.txt', 'contents', 'private');
// Change visibility
Storage::setVisibility('file.txt', 'public');
// Get visibility
$visibility = Storage::getVisibility('file.txt');
On S3, this sets the ACL. On local disk, it sets file permissions.
Image Manipulation
Laravel doesn’t include image manipulation, but Intervention Image works great:
composer require intervention/image
use Intervention\Image\Facades\Image;
public function uploadAvatar(Request $request)
{
$file = $request->file('avatar');
// Resize and save
$image = Image::make($file)
->fit(200, 200)
->encode('jpg', 80); // 80% quality
$path = 'avatars/' . Str::random(40) . '.jpg';
Storage::put($path, $image);
// Create thumbnail
$thumb = Image::make($file)
->fit(50, 50)
->encode('jpg', 70);
$thumbPath = 'avatars/thumbs/' . Str::random(40) . '.jpg';
Storage::put($thumbPath, $thumb);
return [
'avatar' => $path,
'thumbnail' => $thumbPath,
];
}
Organizing Uploads
Structure matters as your app grows:
storage/app/
├── public/
│ ├── avatars/
│ │ ├── 2019/
│ │ │ ├── 05/
│ │ │ │ └── user-123-abc123.jpg
│ │ │ └── 06/
│ │ └── thumbs/
│ ├── products/
│ └── posts/
├── documents/
│ ├── contracts/
│ └── invoices/
└── temp/
// Organize by date
$path = $request->file('photo')->store(
'photos/' . date('Y/m'),
'public'
);
// Result: "photos/2019/05/randomstring.jpg"
// Organize by user
$path = $request->file('document')->store(
'documents/user-' . auth()->id()
);
Cleaning Up Old Files
// In a scheduled command
public function handle()
{
// Delete temp files older than 24 hours
$files = Storage::files('temp');
foreach ($files as $file) {
if (Storage::lastModified($file) < now()->subDay()->timestamp) {
Storage::delete($file);
}
}
}
Testing File Uploads
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
public function test_avatar_can_be_uploaded()
{
Storage::fake('public');
$file = UploadedFile::fake()->image('avatar.jpg', 200, 200);
$response = $this->actingAs($this->user)
->post('/profile/avatar', ['avatar' => $file]);
$response->assertStatus(200);
// Assert file was stored
Storage::disk('public')->assertExists('avatars/' . $file->hashName());
}
Storage::fake() creates an in-memory filesystem. No actual files written.
What I Wish I’d Known Earlier
-
Run
storage:linkearly. Forget once, debug for hours wondering why images don’t show. -
Use the
publicdisk for user-facing files. Don’t fight the conventions. -
Store paths, not URLs. Save
avatars/abc123.jpg, nothttps://bucket.s3.amazonaws.com/avatars/abc123.jpg. Generate URLs dynamically. -
Validate strictly. File type, size, dimensions. Users will upload anything.
-
Delete old files when updating. Otherwise you accumulate orphaned files forever.
The Journey Continues
File storage was now clean and portable. Development used local disk, production used S3, and the code was identical.
But my app was starting to slow down. Database queries were taking too long. Time to learn about caching.
P.S. — The first time I deployed to production and switched from local storage to S3 by changing ONE environment variable — with zero code changes — I realized the power of good abstraction. Laravel’s filesystem is a perfect example of hiding complexity behind a simple, consistent interface.
Saurav Sitaula
Software Architect • Nepal