The core image upload API in Laravel — validating, storing, and referencing the file — has stayed essentially unchanged across Laravel 10 and 11; what's actually worth knowing is the storage setup and a couple of version-specific project structure details.
Creating the storage symlink (a required one-time step)
php artisan storage:link
This creates a symbolic link from public/storage to storage/app/public — without it, files stored via the public disk are saved correctly but aren't actually accessible through a public URL at all, a common point of confusion for a fresh Laravel install where this step is easy to forget.
The upload form and validation
public function store(Request $request)
{
$request->validate([
'image' => 'required|image|mimes:jpeg,png,jpg,webp|max:2048',
]);
$path = $request->file('image')->store('products', 'public');
Product::create([
'name' => $request->name,
'image' => $path,
]);
return redirect()->route('products.index')->with('success', 'Product created.');
}
Generating a custom filename instead of a random hash
$image = $request->file('image');
$filename = time().'_'.$image->getClientOriginalName();
$path = $image->storeAs('products', $filename, 'public');
storeAs(), rather than plain store(), allows specifying the exact filename — prefixing it with time() avoids overwriting an existing file with the same original name, since two different users uploading a file both named photo.jpg would otherwise collide.
Displaying the stored image
 }})
Laravel 11's slimmer default project structure
Laravel 11 significantly streamlined the default application skeleton (fewer default service provider files, a leaner bootstrap/app.php replacing much of the old Kernel.php configuration) — this affects where middleware and some configuration live by default, but the actual file storage and upload APIs (Storage, UploadedFile) are entirely unaffected by this structural change.
Using the Storage facade directly, as an alternative to the instance method
use Illuminate\Support\Facades\Storage;
$path = Storage::disk('public')->putFile('products', $request->file('image'));
Storage::putFile() is functionally equivalent to calling ->store() directly on the uploaded file instance — both ultimately call the same underlying filesystem logic, and the choice between them is mostly a matter of code style and consistency with the rest of a given codebase.
Deleting an image when its owning record is deleted
protected static function booted(): void
{
static::deleting(function (Product $product) {
if ($product->image) {
Storage::disk('public')->delete($product->image);
}
});
}
Adding this to the model's deleting event ensures the stored image file is cleaned up automatically whenever a product is deleted through any code path — more reliable than remembering to delete the image manually in every controller method that might delete a product.