Laravel file upload is one of those topics that fits into a few lines when done right, but breeds security holes, broken images and a messy storage folder when done wrong. In this guide we build an image upload flow end to end: validating the file that arrives from a form, writing it to disk with the Storage facade, exposing it through the public symlink, and finally confirming it is a genuinely valid image. Everything described here is a real, production approach — no invented helper packages or commands.
The disk model: local, public and storage
Laravel abstracts file operations through the concept of a disk. The configuration lives in config/filesystems.php and ships with two local disks by default:
- local — under
storage/app/private, for private files that should not be reachable directly from the web. - public — under
storage/app/public, for files that are meant to be public (avatars, cover images, attachments).
The crucial distinction is this: the storage/app/public folder lives outside your web root (public/), so the browser cannot reach it directly. A symlink builds the bridge. You only need to run it once:
php artisan storage:link
This command creates a symbolic link from public/storage to storage/app/public. Now the file storage/app/public/avatars/foo.png becomes reachable at the URL /storage/avatars/foo.png. On some shared hosting environments PHP's symlink() function is disabled; in that case create the link from the shell with ln -sfn storage/app/public public/storage.
Validating the upload
No file should ever be written to disk without being validated. When handling the form request in a controller you can pass the rules straight to validate():
public function store(Request $request)
{
$request->validate([
'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
]);
// ...
}
Each rule here earns its place:
image— requires the file to be an image (jpeg, png, bmp, gif, svg or webp).mimes— explicitly limits the accepted extensions. Leaving SVG out is usually wise, since scripts can be embedded inside one.max:2048— the size limit is in kilobytes, so 2 MB here.
For more complex forms, moving the rules into a Form Request class generated with php artisan make:request StoreAvatarRequest keeps the controller clean.
Writing to disk with the Storage facade
Once validation passes, saving the file is a single line. The uploaded file object (Illuminate\Http\UploadedFile) works directly with the disk methods:
use Illuminate\Support\Facades\Storage;
$path = $request->file('avatar')->store('avatars', 'public');
// $path = "avatars/9aXk2...png"
store() generates a unique, unguessable name for the file and returns the relative path. Save that path (for example avatars/9aXk2...png) in the database — not the full URL. That way your data does not break if your domain or disk configuration changes.
If you want to set a specific name, use storeAs():
$path = $request->file('avatar')->storeAs(
'avatars', $user->id.'.'.$request->file('avatar')->extension(), 'public'
);
Later, to display the file, turn the relative path into a public URL:
$url = Storage::disk('public')->url($path);
// /storage/avatars/9aXk2...png
The same logic applies in Blade:
<img src="{{ Storage::url($user->avatar) }}" alt="Avatar">
Is it really an image? Beyond validation
The image and mimes rules check the MIME type and extension, but they do not guarantee a readable, undamaged image. To constrain the image dimensions there is the dimensions rule:
$request->validate([
'avatar' => [
'required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048',
'dimensions:min_width=200,min_height=200,max_width=4000,max_height=4000',
],
]);
If you also want to resize, crop or optimize the image, the Intervention Image library is a common and reliable choice. It installs with Composer:
composer require intervention/image
A typical use is to read the uploaded file into memory, scale it to a given width and write it back to disk:
use Intervention\Image\ImageManager;
use Intervention\Image\Drivers\Gd\Driver;
$manager = new ImageManager(new Driver());
$image = $manager->read($request->file('avatar'));
$image->scaleDown(width: 800);
Storage::disk('public')->put($path, (string) $image->encode());
This step has a hidden security benefit too: re-encoding the image largely strips out any malicious data embedded in the file.
Cleaning up and deleting old files
When a user changes their avatar, the old one lingers as junk. Deleting the previous file before saving the new one is a good habit:
if ($user->avatar) {
Storage::disk('public')->delete($user->avatar);
}
$user->avatar = $request->file('avatar')->store('avatars', 'public');
$user->save();
To check whether a file exists, use Storage::disk('public')->exists($path). For operations like bulk deletion or folder listing there are also the files(), allFiles() and deleteDirectory() methods.
Private files and secure downloads
Not every file needs to be public. Invoices, contracts or personal documents should stay on the local disk (storage/app/private) and be served only to a user who passes an authorization check. For this you route through a controller rather than a symlink:
public function download(Document $document)
{
$this->authorize('view', $document);
return Storage::disk('local')->download($document->path);
}
Here the file is never reachable by a direct URL; every request is checked through a policy. A symlink for public images, an authorized download for sensitive documents — keeping that distinction clear is the foundation of good architecture.
Frequently Asked Questions
I ran storage:link but the images still don't show — why?
The most common causes are that the symlink was not created on the server (public/storage was not copied during deploy, or PHP's symlink() function is disabled), or that the wrong relative path — or a full URL — is stored in the database. Create the symlink from the shell with ln -sfn and always build the URL with Storage::url().
What is the safest size and type limit for uploaded files?
There is no single correct number; set it to your needs. The general rule: whitelist only the types you expect with mimes, leave SVG out, set a sensible max limit, and where possible re-encode the image with Intervention Image.
Will moving files to Amazon S3 change my code?
Almost not at all. Thanks to the disk abstraction, saying Storage::disk('s3') or switching the default disk is enough; your store(), url() and delete() calls stay the same. You just need to add the league/flysystem-aws-s3-v3 package.
Want to harden your file upload flow? Whether you are building a Laravel app from scratch or securing an existing storage setup, we can work together — get in touch with me.