File upload and image processing with Laravel
A walkthrough of the end-to-end flow for file uploading, validation, and image resizing in Laravel.
File uploading looks simple on the surface, but once you get into the details it forces you to answer several questions at once: Did the file actually upload? What should the size and type limits be? What happens if a file with the same name already exists? If the uploaded image is a profile photo, it needs to be resized to a standard dimension.
In this post I walk through how I handle all of that in Laravel, using a simple profile photo upload flow as the example.
The form side
To upload a file, the HTML form needs to be defined with enctype="multipart/form-data":
<form action="/profil/fotograf" method="POST" enctype="multipart/form-data">
{{ csrf_field() }}
<input type="file" name="fotograf" accept="image/*">
<button type="submit">Yükle</button>
</form>
Forgetting the enctype attribute is the most common mistake with this feature. The rest of the form appears to work fine, but $request->file('fotograf') always returns null. When debugging, checking this attribute should be the first step.
Validation
The first thing to do in the controller is always validation. Laravel’s image rule checks whether the file is genuinely an image; the max rule sets a size limit in kilobytes:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class ProfilController extends Controller
{
public function fotografGuncelle(Request $request)
{
$this->validate($request, [
'fotograf' => 'required|image|mimes:jpeg,png,gif|max:2048',
]);
// From here we know the file is valid
$dosya = $request->file('fotograf');
// ...
}
}
The mimes:jpeg,png,gif rule checks the MIME type — it does not just look at the extension, it also inspects the file’s contents. max:2048 enforces a 2 MB limit.
Here is why MIME type checking matters: a user could upload a file with a .jpg extension whose content is <?php echo shell_exec($_GET['cmd']); ?>. Checking only the extension would let it through. Laravel’s mimes rule reads the first few bytes of the file to verify it is actually a JPEG. This check is essential for secure file handling.
Saving the file
Saving with Laravel’s filesystem API is clean and straightforward:
// Generate a unique filename and save to the public disk
$dosyaAdi = uniqid() . '.' . $dosya->getClientOriginalExtension();
$yol = $dosya->storeAs('profil-fotograflari', $dosyaAdi, 'public');
// $yol: something like "profil-fotograflari/abc123.jpg"
The storeAs method saves the file to the specified folder under the given name. The public disk is defined in config/filesystems.php and typically writes to storage/app/public.
Thanks to the symbolic link created by the storage:link command, these files are accessible via public/storage.
For a stronger unique name than uniqid(), you can use Str::uuid() or Str::random(40). uniqid() operates at microsecond precision, so collisions on concurrent uploads are unlikely but theoretically possible. For large-scale applications, a UUID or a random string is the safer choice.
Resizing the image
For profile photos I usually want a standard size. I use the Intervention Image library for this:
composer require intervention/image
I resize the image before saving it:
use Intervention\Image\ImageManagerStatic as Image;
public function fotografGuncelle(Request $request)
{
$this->validate($request, [
'fotograf' => 'required|image|mimes:jpeg,png,gif|max:2048',
]);
$dosya = $request->file('fotograf');
$dosyaAdi = uniqid() . '.jpg';
$hedefYol = storage_path('app/public/profil-fotograflari/' . $dosyaAdi);
// Read the image, resize it, save as JPEG
Image::make($dosya->getRealPath())
->fit(200, 200) // 200x200 square, cropped from center
->save($hedefYol, 80); // JPEG at quality 80
// Update the user record
auth()->user()->update([
'fotograf' => 'profil-fotograflari/' . $dosyaAdi,
]);
return back()->with('basarili', 'Profil fotoğrafınız güncellendi.');
}
fit(200, 200) scales the image to 200x200 without distortion — ideal for square profile photos. save($yol, 80) saves it as a JPEG at quality 80, which is a reasonable trade-off between file size and visual quality.
There is one more reason to convert all uploaded images to JPEG: if a PNG with a transparent background is uploaded as a profile photo, it may be saved with a black background (JPEG does not support transparency). This edge case, if not caught early, tends to come back as user complaints about color mismatches. For images with transparency, you need to either save as PNG or fill the background with white before converting.
Deleting the old photo
When updating, deleting the old file is good practice:
use Illuminate\Support\Facades\Storage;
$eskiFotograf = auth()->user()->fotograf;
if ($eskiFotograf && Storage::disk('public')->exists($eskiFotograf)) {
Storage::disk('public')->delete($eskiFotograf);
}
// Save the new photo...
Not deleting old files leads to storage bloat. If a user updates their photo every month, you end up with dozens of unnecessary files by end of year. It seems trivial at first, but as the user base grows, disk usage balloons at an unpredictable rate.
Thinking about error cases
If validate() fails, Laravel automatically redirects back to the form and populates the $errors variable with the error messages. If something goes wrong during image processing, Intervention Image throws an exception, which Laravel’s global error handler catches.
In a production application it is better to catch these exceptions more carefully and display a meaningful error message to the user. But for a project still in development, this level of handling is sufficient for now.
One more gotcha: PHP’s upload_max_filesize and post_max_size values in php.ini kick in before Laravel’s max validation rule. You can tell Laravel max:2048, but if php.ini has upload_max_filesize=1M, a 1.5 MB file gets rejected before it ever reaches Laravel — and instead of a Laravel error message, you may see PHP’s raw error page. Checking these values in your development environment makes it much easier to track down unexpected behaviour.
File uploading might seem like it does not need to be this involved, but skipping each of these steps creates problems down the line. Skipping validation opens the door to malicious uploads; skipping resizing leads to storage problems; not deleting old files leads to accumulation. Setting these steps up from the start is easier than fixing them later.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.