File Uploads in Laravel: Storage, Validation and Serving
Uploads look like a solved problem until you deploy to shared hosting without symlinks, or someone uploads a PHP file named avatar.png.
File uploads combine three things that each go wrong independently: validation, storage location, and how the file gets served back. Here is how I handle each.
Validate Properly
$request->validate([
'cover_image' => 'required|image|mimes:png,jpg,jpeg,webp|max:3072',
]);
The image rule checks the actual file contents, not the extension. That matters: shell.php renamed to avatar.png passes an extension check and fails a real MIME check.
Store Outside the Web Root
$path = $request->file('cover_image')->store('blog', 'public');
// storage/app/public/blog/xxxx.jpg
Laravel generates a random filename, which quietly solves two problems: collisions, and a user-controlled name ending up in a URL.
When storage:link Is Not Available
Plenty of shared hosts do not allow symlinks. Rather than storing uploads in public/ — which loses you the safety above — serve them through a controller:
Route::get('/jfile/{path}', function (string $path) {
if (str_contains($path, '..')) abort(403);
$full = storage_path('app/public/' . ltrim($path, '/'));
abort_unless(file_exists($full) && !is_dir($full), 404);
$mime = mime_content_type($full);
abort_unless(in_array($mime, ['image/jpeg','image/png','image/webp','application/pdf']), 403);
return response()->file($full, ['Cache-Control' => 'public, max-age=31536000']);
})->where('path', '.*');
Note the two guards: the traversal check, and the MIME allow-list. Without the second one, this route will happily serve any file that ends up in that directory.
Delete the Old File on Replace
if ($request->hasFile('cover_image')) {
if ($post->cover_image) Storage::disk('public')->delete($post->cover_image);
$data['cover_image'] = $request->file('cover_image')->store('blog', 'public');
}
Skip this and your disk fills with orphaned files nobody can identify a year later.
Watch Your PHP Limits
A Laravel rule of max:10240 means nothing if upload_max_filesize is 2M — PHP rejects the request before Laravel sees it, and the user gets a confusing empty error. Check upload_max_filesize, post_max_size and max_execution_time together.