Step-by-step
-
1
Write the upload form
The form must have
enctype="multipart/form-data"— without it, the file binary is never sent to the server. Use the@csrfdirective and setmethod="POST".html<form action="{{ route('upload.store') }}" method="POST" enctype="multipart/form-data"> @csrf <input type="file" name="image" accept="image/*"> @error('image') <p class="error">{{ $message }}</p> @enderror <button type="submit">Upload</button> </form> -
2
Validate the uploaded file
Always validate before storing. Use the
filerule to confirm something was uploaded,mimesto restrict by extension, andmaxto cap the file size in kilobytes. Laravel checks both the file extension and the MIME type reported by the browser.php<?php namespace App\Http\Controllers; use Illuminate\Http\Request; class UploadController extends Controller { public function store(Request $request) { $request->validate([ 'image' => [ 'required', 'file', 'mimes:jpg,jpeg,png,webp,pdf', 'max:2048', // 2 MB ], ]); // proceed to store... } } -
3
Store the file
Call
$request->file('image')->store('uploads', 'public'). Laravel generates a unique filename automatically (UUID-based), moves the file tostorage/app/public/uploads/, and returns the relative path likeuploads/abc123.jpg. Save that path to the database.If you want to control the filename yourself, use
storeAs()instead.php// Auto-generated unique filename $path = $request->file('image')->store('uploads', 'public'); // returns: "uploads/3f7a...uuid...jpg" // Custom filename $filename = time() . '_' . $request->file('image')->getClientOriginalName(); $path = $request->file('image')->storeAs('uploads', $filename, 'public'); // Save the path to the database $post = Post::create([ 'title' => $request->title, 'image_path' => $path, ]); -
4
Create the public symlink
The
publicdisk stores files instorage/app/public/, which is not web-accessible by default. Runphp artisan storage:linkonce to create a symlink frompublic/storage→storage/app/public. Do this after deployment on every server.bashphp artisan storage:link # Creates: public/storage -> storage/app/public -
5
Display the stored file
Use the
Storagefacade'surl()method to generate the public URL from the stored path. It works for both thepublicdisk (returning a local URL) and S3 (returning the CDN or S3 URL).html{{-- In a Blade template --}} <img src="{{ Storage::disk('public')->url($post->image_path) }}" alt="Uploaded image"> {{-- Or use the global helper --}} <img src="{{ asset('storage/' . $post->image_path) }}" alt="Uploaded image"> {{-- In a controller or model --}} use Illuminate\Support\Facades\Storage; $url = Storage::disk('public')->url($post->image_path); // returns: https://yourdomain.com/storage/uploads/abc123.jpg -
6
Understand filesystem disks
Laravel's
config/filesystems.phpdefines named disks. The three you'll use most often are:local— stores files instorage/app/, not publicly accessible (good for private documents)public— stores instorage/app/public/, symlinked topublic/storage(good for user avatars, images)s3— stores on Amazon S3 (production-grade, scales infinitely)
You switch disks by changing the second argument to
store()or callingStorage::disk('s3'). Your controller code doesn't change. -
7
Configure the S3 disk
Install the Flysystem S3 adapter, then add your AWS credentials to
.env. The S3 disk configuration is already inconfig/filesystems.php— you just need to fill the env variables.bashcomposer require league/flysystem-aws-s3-v3 "^3.0" --with-all-dependencies -
8
Add S3 credentials to .env
Add the following to your
.envfile. TheAWS_URLis optional — use it if you have a CloudFront distribution in front of your bucket soStorage::url()returns your CDN URL instead of the raw S3 URL.bash# .env AWS_ACCESS_KEY_ID=AKIA... AWS_SECRET_ACCESS_KEY=your-secret-key AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET=your-bucket-name AWS_URL=https://cdn.yourdomain.com # optional, for CloudFront FILESYSTEM_DISK=s3 # make S3 the default disk -
9
Upload to S3 and generate a URL
Now swap the disk name in your
store()call. Everything else in the controller stays the same. Files land in your S3 bucket under theuploads/prefix.php// Store on S3 $path = $request->file('image')->store('uploads', 's3'); // Make the file publicly readable when uploading $path = $request->file('image')->storePublicly('uploads', 's3'); // Generate the public URL $url = Storage::disk('s3')->url($path); // returns: https://your-bucket.s3.amazonaws.com/uploads/abc123.jpg
Tips & gotchas
- Never trust the client-supplied filename — always let Laravel generate one, or sanitize it yourself before calling `storeAs()`. Original filenames can contain path traversal characters.
- Validate by MIME type server-side using the `mimetypes` rule (e.g., `mimetypes:image/jpeg,image/png`), not just by extension — extensions are trivially spoofed.
- For private files (contracts, invoices), store on the `local` disk and serve them through a controller that checks authorization — never use the `public` disk for sensitive documents.
- Delete old files when replacing them: `Storage::disk('public')->delete($post->image_path)` before saving the new path — otherwise your storage fills up silently.
- On S3, use presigned URLs (`Storage::temporaryUrl($path, now()->addMinutes(5))`) for private files instead of making the entire bucket public.
Wrapping up
You can now handle file uploads end-to-end in Laravel — from the validated form submission to storing on the local public disk or S3, and generating the correct URL for display. The natural next step is to add image processing (resize, crop, convert to WebP) using spatie/image or intervention/image before storing the file.