Ilustrasi form validasi dan upload file aman di Laravel

Episode #74 mengajarkan alur request, episode #75 mengajarkan menyimpan data. Tapi ada yang hilang di tengah: bagaimana data masuk dari browser ke aplikasimu? Jawabannya form HTML. Dan begitu kamu membuka pintu input untuk pengunjung, kamu membuka pintu untuk data ngawur, data jahat, dan file berbahaya. Episode ini tentang cara menerima input dengan aman: form, validasi, dan upload file.

Posisinya penting: hampir semua aplikasi interaktif butuh form. Komentar blog, formulir pendaftaran, upload foto profil, semuanya lewat sini. Validasi bukan fitur tambahan yang "nanti saja", melainkan bagian inti dari setiap form yang kamu bikin mulai hari ini.

Persepsi yang perlu diluruskan dulu

Salah kaprah 1: "Validasi cukup di browser pakai required atau JavaScript." Atribut required di HTML dan cek JavaScript memang bikin UX lebih enak, tapi keduanya jalan di komputer pengunjung. Siapa pun bisa mematikan JavaScript, mengedit HTML lewat dev tools, atau mengirim request langsung pakai curl tanpa lewat form sama sekali. Validasi yang benar-benar melindungi aplikasimu hanya yang jalan di server. Validasi browser itu pemanis, validasi server itu pengaman.

Salah kaprah 2: "Upload file tinggal simpan, kan Laravel yang ngurus." Laravel menyediakan tools-nya, tapi keputusan keamanannya tetap di tanganmu. File yang diupload bisa jadi script PHP berkedok gambar, ukurannya bisa 2 GB dan memenuhi disk server, atau namanya menimpa file penting. Upload tanpa validasi dan penyimpanan yang benar adalah salah satu celah paling klasik di aplikasi web.

Form Blade dan @csrf: surat dengan stempel

Form HTML biasa di Blade terlihat seperti ini:

<form action="{{ route('artikel.store') }}" method="POST">
    @csrf
    <input type="text" name="judul">
    <textarea name="isi"></textarea>
    <button type="submit">Simpan</button>
</form>

Yang paling penting di sini adalah @csrf. Direktif ini menghasilkan input hidden berisi token rahasia yang hanya diketahui aplikasimu dan browser pengunjung yang sah.

Analoginya seperti surat dengan stempel khusus. Bayangkan seseorang bisa memalsukan surat perintah transfer atas namamu. Stempel rahasia memastikan surat itu benar-benar berasal dari mejamu, bukan dari orang luar yang meniru kop suratmu. Tanpa token ini, penyerang bisa membuat halaman jebakan yang diam-diam mengirim form ke aplikasimu lewat browser korban yang sedang login. Itu serangan CSRF, dan @csrf menutupnya.

Berhenti dan tebak dulu! Kamu lupa menulis @csrf di form, lalu menekan submit. Apa yang terjadi dan kenapa?

<form action="{{ route('artikel.store') }}" method="POST">
    <!-- @csrf ketinggalan -->
    <input type="text" name="judul">
</form>
Jawaban: Laravel menolak dengan error 419 Page Expired. Kenapa: middleware VerifyCsrfToken memeriksa setiap request POST. Tanpa token yang cocok, Laravel menganggap request itu tidak berasal dari form milikmu sendiri dan menolaknya. Error 419 memang membingungkan tulisannya, tapi maksudnya sederhana: token keamanan tidak ada atau tidak cocok.

Validasi dengan $request->validate()

Di controller, validasi paling sederhana ditulis begini:

public function store(Request $request)
{
    $validated = $request->validate([
        'judul' => 'required|string|max:255',
        'isi' => 'required|min:10',
    ]);

    Artikel::create($validated);
    return redirect()->route('artikel.index');
}

Alur berpikirnya: $request->validate() memeriksa input sesuai aturan. Kalau lolos, ia mengembalikan hanya data yang valid (lebih aman daripada memakai $request->all()). Kalau gagal, Laravel otomatis mengembalikan pengunjung ke form sebelumnya sambil membawa pesan error dan input lama. Kamu tidak perlu menulis if gagal manual.

Aturan validasi dibaca seperti kalimat: judul wajib diisi, harus string, maksimal 255 karakter. isi wajib diisi, minimal 10 karakter. Laravel punya puluhan aturan siap pakai: email, numeric, date, unique, confirmed, dan banyak lagi.

Menampilkan error di Blade juga sudah disiapkan:

<input type="text" name="judul" value="{{ old('judul') }}">
@error('judul')
    <p style="color:red;">{{ $message }}</p>
@enderror

old('judul') mengisi kembali input terakhir supaya pengunjung tidak mengetik ulang semuanya gara-gara satu field salah. Detail kecil seperti ini yang membedakan form yang nyaman dari yang menyebalkan.

Form Request: validasi pindah rumah

Saat aturan validasi mulai panjang, controller jadi penuh sesak. Solusinya: pindahkan ke kelas Form Request.

php artisan make:request StoreArtikelRequest
class StoreArtikelRequest extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        return [
            'judul' => 'required|string|max:255',
            'isi' => 'required|min:10',
        ];
    }
}

Lalu controller cukup type-hint kelas ini:

public function store(StoreArtikelRequest $request)
{
    Artikel::create($request->validated());
    return redirect()->route('artikel.index');
}

Kenapa ini lebih baik? Prinsipnya sama seperti controller di episode #74: setiap hal punya rumahnya sendiri. Aturan validasi adalah satu "urusan" yang utuh, jadi ia pantas punya file sendiri. Controller kembali ramping dan fokus pada alur: terima data valid, simpan, redirect. Bonusnya, Form Request yang sama bisa dipakai ulang di method update tanpa duplikasi aturan.

Kuis cepat: Method authorize() di Form Request bawaan artisan mengembalikan false. Apa akibatnya?

Upload file dengan aman

Form upload butuh satu atribut tambahan: enctype="multipart/form-data". Tanpa ini, file tidak ikut terkirim dan $request->file() akan kosong.

<form action="{{ route('foto.store') }}" method="POST" enctype="multipart/form-data">
    @csrf
    <input type="file" name="foto">
    <button type="submit">Upload</button>
</form>

Di controller, validasi file harus memeriksa isi aslinya, bukan cuma namanya:

$validated = $request->validate([
    'foto' => 'required|image|mimes:jpg,jpeg,png,webp|max:2048',
]);

$path = $request->file('foto')->store('foto-profil', 'public');

Bedah aturannya: image memastikan PHP bisa membaca file itu sebagai gambar beneran (file teks yang diganti ekstensinya jadi .jpg akan gagal di sini). mimes memeriksa tipe MIME dari isi byte file, bukan dari nama file yang bisa dipalsukan. max:2048 membatasi 2048 kilobyte alias 2 MB.

Berhenti dan tebak dulu! Aturan max:2048 pada validasi file artinya maksimal berapa? Dan kenapa satuannya penting dipahami?

'foto' => 'required|image|max:2048',
Jawaban: 2048 kilobyte, alias 2 megabyte. Kenapa penting: banyak pemula mengira max:2048 berarti 2048 MB dan kaget saat file 5 MB ditolak. Laravel memakai kilobyte untuk file. Kalau kamu mau batas 10 MB, tulis max:10240. Salah paham satuan sekecil ini bisa bikin fitur upload ditolak user tanpa kamu sadar penyebabnya.

Method store('foto-profil', 'public') menyimpan file ke disk public dengan nama acak yang aman. Nama acak ini penting: kalau memakai nama asli dari pengunjung, dua file bernama sama akan saling menimpa, dan nama file jahat seperti ../../../.env bisa dieksploitasi. Laravel mengurusnya dengan menghasilkan nama unik, kamu tinggal menyimpan $path ke database.

Satu langkah yang sering kelupaan: jalankan php artisan storage:link sekali. Perintah ini membuat symlink dari public/storage ke storage/app/public supaya file yang disimpan bisa diakses browser. Tanpa ini, upload sukses tapi gambarnya 404 saat ditampilkan.

Ringkasan cepat

Lima hal untuk diingat: pertama, setiap form POST wajib punya @csrf sebagai bukti form itu berasal dari aplikasimu sendiri. Kedua, validasi yang melindungi aplikasi hanya yang jalan di server, validasi browser cuma pemanis UX. Ketiga, $request->validate() mengembalikan hanya data valid dan otomatis mengembalikan user ke form dengan pesan error. Keempat, pindahkan aturan panjang ke Form Request supaya controller tetap ramping dan aturan bisa dipakai ulang. Kelima, upload file butuh enctype di form, validasi image/mimes/max (dalam kilobyte), penyimpanan dengan nama acak via store(), dan php artisan storage:link supaya bisa diakses browser.

Latihan dan langkah berikutnya

Latihan (jangan cuma dibaca):

  1. Bikin form tambah buku (judul, penulis, tahun). Tambahkan validasi: tahun harus numeric dan antara 1900 sampai tahun sekarang. Tampilkan error di bawah tiap input dengan @error.
  2. Sengaja kirim form kosong dan perhatikan pesan error bawaan Laravel. Lalu ubah pesannya jadi Bahasa Indonesia lewat parameter ketiga atau file lang. Rasakan bedanya dari sisi pengguna.
  3. Tambahkan field upload sampul buku (image, maks 2 MB). Coba upload file teks yang diganti ekstensi .jpg dan lihat validasi mana yang menolaknya. Ini bukti kenapa aturan image memeriksa isi, bukan nama.
  4. Refactor: pindahkan aturan validasimu ke Form Request dan sederhanakan controller sampai tinggal 3 baris.

Episode #77 memakai semua yang kamu pelajari hari ini untuk fitur yang hampir wajib di setiap aplikasi: login dan register. Form-nya, validasinya, keamanannya, semuanya naik satu level karena sekarang yang dipertaruhkan adalah akun pengguna.