Introduction
Students have faces. Products have photos. Every real app eventually asks: upload your picture. File upload looks like one input on a form — and hides four separate dangers: the form forgetting how to carry files, a stranger uploading something evil, two visitors' files overwriting each other with the same name, and orphaned files piling up when records change.
Laravel's upload system answers each danger with one habit: the form declares enctype="multipart/form-data", validation checks the file like any other field (image, mimes, max), the framework stores it under a random name on a chosen disk, the database remembers only the path, and storage:link makes the public folder able to show it. This lesson wires the full story onto the Students app — upload, display, replace, delete — with cleanup at every step.
(Roman Urdu: File upload aik mehman-nawazi hai jisme hifazat pehle aati hai — jaanch, naya naam, mehfooz jagah, aur purani safai.)
Key points
- The form must declare its cargo:
<form method="POST" enctype="multipart/form-data">— without that one attribute the file never leaves the browser and$request->file('photo')arrives empty. The input itself is plain:<input type="file" name="photo">. - Files are fields too: the uploaded file rides inside the same
$request, validated by the samevalidate()—'photo' => 'required|image|mimes:jpg,jpeg,png|max:2048'(image only, those types, at most 2048 KB = 2 MB). Never skip file validation because "the input only accepts images" — the browser's hint is not a guard (LV.5's law). - Never keep the visitor's file name: two
photo.jpguploads would collide, and a crafted name can be worse.$request->file('photo')->store('students', 'public')saves the file under a long random name insidestorage/app/public/students/and returns its path. - The database stores the path, not the file: one string column (
photo) holdingstudents/xK9...f3.jpg. Files live on disks; databases hold addresses.(Roman Urdu: File almari mein, pata register mein — database sirf rasta yaad rakhta hai, samaan nahi.) php artisan storage:linkbuilds the bridge: it creates a shortcutpublic/storage→storage/app/public, so the browser can reach the file atasset('storage/' . $student->photo). Without the link, every photo 404s while existing happily on disk.- Replace and delete must clean up: a new photo deletes the old file; a deleted student deletes his photo. Skipped cleanup is how disks fill with ghosts of records long gone.
Storage::download()hands files back properly: for certificates, reports and receipts, it serves the file as a download with the right headers instead of a fragile direct link.
Code examples
Example 1 — The form and the gate: input + validation
{{-- students/create.blade.php — the LV.5 form grows one field --}}
<form method="POST" action="{{ route('students.store') }}" enctype="multipart/form-data">
@csrf
<label>Photo</label>
<input type="file" name="photo">
@error('photo')
<p class="error">{{ $message }}</p>
@enderror
{{-- name, city, marks fields as before --}}
</form>// StudentController@store — the file passes the same gate as text
$validated = $request->validate([
'name' => 'required|string|max:50',
'city' => 'required|string|max:50',
'marks' => 'required|integer|min:0|max:100',
'photo' => 'required|image|mimes:jpg,jpeg,png|max:2048',
]);What it does: Two changes carry the whole feature: the form announces it carries files, and the photo joins the validation rules. A .exe renamed to .jpg, a 9 MB scan, a missing photo — each bounces with its own message under the photo field, beside LV.5's familiar red lines.
What you will see: submit a text file renamed photo.jpg and the form bounces: The photo field must be an image. Submit a real photo and the cycle proceeds to Example 2.
Example 2 — Store under a random name, save the path, show the face
public function store(Request $request)
{
$validated = $request->validate([ /* rules from Example 1 */ ]);
$path = $request->file('photo')->store('students', 'public');
Student::create([
'name' => $validated['name'],
'city' => $validated['city'],
'marks' => $validated['marks'],
'photo' => $path,
]);
return redirect()->route('students.index')
->with('success', 'Student added with photo!');
}php artisan storage:link{{-- students/index.blade.php — the face in the table --}}
<img src="{{ asset('storage/' . $student->photo) }}" alt="{{ $student->name }}" width="48">What it does: store() does three jobs in one breath: invents a collision-proof random name, writes the file to the public disk under students/, and returns the path — which is all the database keeps. (The students migration gains a nullable photo string column; photo joins the model's $fillable so create() may carry it.) storage:link, run once per project (and once per server, LV.8), bridges the storage folder into the public one so asset() can serve the picture.
What you will see: the terminal confirms The [public/storage] link has been connected to [storage/app/public]; the students list now shows a small round face beside each name; and the table's photo cell for Ali holds a path like students/9fK2...a1.jpg — a name no visitor chose and no collision can touch.
Example 3 — Replace + delete: no ghosts left behind
use Illuminate\Support\Facades\Storage;
public function update(Request $request, Student $student)
{
$validated = $request->validate([
'name' => 'required|string|max:50',
'marks' => 'required|integer|min:0|max:100',
'photo' => 'nullable|image|mimes:jpg,jpeg,png|max:2048',
]);
if ($request->hasFile('photo')) {
Storage::disk('public')->delete($student->photo);
$student->photo = $request->file('photo')->store('students', 'public');
}
$student->name = $validated['name'];
$student->marks = $validated['marks'];
$student->save();
return redirect()->route('students.index')
->with('success', 'Student updated!');
}
public function destroy(Student $student)
{
Storage::disk('public')->delete($student->photo);
$student->delete();
return redirect()->route('students.index')
->with('success', 'Student deleted!');
}What it does: The update's photo rule is nullable — keep the old face unless a new one arrives. When one does arrive, the OLD file is deleted from the disk before the new path is stored. destroy() deletes the file first, then the row. The disk and the database now tell the same story, forever.
What you will see: replace Hira's photo and count the files in storage/app/public/students/ before and after — the count does not grow; the old file is simply gone. Delete a practice student and his photo vanishes with his row.
Quick recap
- Upload needs three habits:
enctypeon the form, the file validated like a field, and storage under a random name. - The database holds the path; the disk holds the file;
storage:linkbridges storage into public view. - Replace deletes the old file; destroy deletes the file with the row — no ghosts.
Storage::download()serves files back as proper downloads.
Common mistakes
- Forgetting
enctype="multipart/form-data"— the silent classic: the form submits,$request->file('photo')is null, and no error explains why. Fix: the attribute goes on EVERY form that carries a file, the moment the file input is added. - Keeping the visitor's original file name — collisions overwrite strangers' photos, and crafted names attack the server. Fix:
store()and its random names, always; the original name is metadata, never the address. - Skipping
storage:link— uploads succeed, paths save, and every image 404s. Fix: run it once per project (and once on the server at deploy time) before blaming the code. - Never deleting replaced/deleted files — the disk becomes a museum of removed records. Fix: cleanup lives inside
update()anddestroy()themselves, so no path through the app can orphan a file.
Practice
- Make the photo optional at create time too (
nullable+ a default avatar path shown whenphotois empty). Register one student with a photo and one without; the list must show both faces correctly. - Add a "Download class list" button that builds a
students.txtsummary on the fly, saves it to storage, and returns it withStorage::download(). Open the downloaded file and check the five names are inside.