Pada aplikasi Laravel tradisional, alur validasi server-side biasanya terasa jelas: request masuk, divalidasi, jika gagal pengguna di-redirect kembali dengan pesan error dan old input. Saat memakai Inertia.js, pola ini tetap berlaku, tetapi cara error tersebut sampai ke komponen frontend perlu dipahami dengan benar agar implementasinya rapi dan konsisten.

Artikel ini fokus pada alur validasi server-side antara Laravel dan Inertia.js, khususnya untuk kasus yang cukup sering muncul: satu halaman profil yang memiliki dua form sekaligus, misalnya form update biodata dan form ubah password. Di situ, named error bag menjadi sangat penting karena error dari satu form tidak boleh tampil di form lain.

Kita akan membahas bagaimana Laravel menangani validasi dengan FormRequest, bagaimana custom message disusun, bagaimana redirect back dan old input bekerja, serta bagaimana menampilkan error di komponen Inertia secara konsisten dan mudah dipelihara.

Memahami Alur Validasi Inertia.js dengan Laravel

Secara konsep, Inertia tidak menggantikan validasi Laravel. Inertia hanya menjadi jembatan antara backend dan frontend. Jadi alurnya tetap seperti ini:

  1. Pengguna mengirim form dari komponen Inertia.
  2. Request masuk ke controller Laravel atau FormRequest.
  3. Laravel menjalankan validasi.
  4. Jika validasi gagal, Laravel melakukan redirect back sambil membawa error dan old input ke session.
  5. Inertia membaca hasil redirect tersebut dan mengirimkan data error ke halaman yang dirender ulang.

Artinya, Anda tidak perlu memindahkan logika validasi utama ke frontend. Validasi frontend boleh ada untuk pengalaman pengguna, tetapi validasi server-side tetap sumber kebenaran utama.

Catatan penting: Pada Inertia, error validasi umumnya tersedia lewat page.props.errors atau lewat helper form seperti useForm. Karena itu, struktur penamaan field dan error harus konsisten antara backend dan komponen.

Menggunakan FormRequest untuk Validasi yang Rapi

Untuk kasus nyata, sebaiknya pisahkan validasi ke FormRequest daripada menulis rule langsung di controller. Keuntungannya:

  • Controller lebih bersih.
  • Rule validasi mudah diuji dan dipelihara.
  • Custom message dan otorisasi bisa ditempatkan di satu lokasi.

Misalnya kita punya dua request berbeda: satu untuk update profil, satu untuk ubah password.

FormRequest untuk update biodata

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class UpdateProfileRequest extends FormRequest
{
    public function authorize(): bool
    {
        return auth()->check();
    }

    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:100'],
            'email' => [
                'required',
                'email',
                Rule::unique('users', 'email')->ignore($this->user()->id),
            ],
            'bio' => ['nullable', 'string', 'max:500'],
        ];
    }

    public function messages(): array
    {
        return [
            'name.required' => 'Nama wajib diisi.',
            'email.required' => 'Email wajib diisi.',
            'email.email' => 'Format email tidak valid.',
            'email.unique' => 'Email tersebut sudah digunakan akun lain.',
            'bio.max' => 'Bio maksimal 500 karakter.',
        ];
    }
}

FormRequest untuk ubah password

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;

class UpdatePasswordRequest extends FormRequest
{
    public function authorize(): bool
    {
        return auth()->check();
    }

    public function rules(): array
    {
        return [
            'current_password' => ['required', 'current_password'],
            'password' => ['required', 'confirmed', Password::defaults()],
        ];
    }

    public function messages(): array
    {
        return [
            'current_password.required' => 'Password lama wajib diisi.',
            'current_password.current_password' => 'Password lama tidak sesuai.',
            'password.required' => 'Password baru wajib diisi.',
            'password.confirmed' => 'Konfirmasi password tidak cocok.',
        ];
    }
}

Dengan pola ini, setiap form punya aturan dan pesan error yang spesifik. Ini penting saat halaman memiliki lebih dari satu form dengan field yang serupa atau sama-sama sensitif.

Mengapa Named Error Bag Penting pada Beberapa Form di Satu Halaman

Masalah umum di halaman profil adalah adanya beberapa form dalam satu komponen. Misalnya:

  • Form update biodata: name, email, bio
  • Form ubah password: current_password, password, password_confirmation

Tanpa named error bag, semua error validasi akan masuk ke bag default. Pada kasus sederhana mungkin tidak terasa. Namun begitu halaman memiliki banyak form, ada beberapa risiko:

  • Komponen sulit membedakan error milik form mana.
  • Field dengan nama sama di dua form dapat saling mengganggu.
  • Tampilan error menjadi membingungkan pengguna.
  • Logika frontend menjadi penuh kondisi khusus.

Solusinya adalah memberikan bag terpisah, misalnya profile untuk biodata dan password untuk ubah password.

Controller dengan validateWithBag

Jika Anda memvalidasi langsung di controller, Laravel menyediakan validateWithBag(). Namun untuk contoh yang lebih rapi, kita tetap gunakan FormRequest dan ketika perlu menempatkan error ke bag tertentu, kita bisa melempar ValidationException dengan bag yang sesuai.

Salah satu cara praktis adalah memanfaatkan validator manual di controller untuk endpoint yang memang membutuhkan bag khusus.

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;

class ProfileController extends Controller
{
    public function edit(Request $request)
    {
        return Inertia::render('Profile/Edit', [
            'user' => $request->user(),
        ]);
    }

    public function updateProfile(Request $request)
    {
        $validated = Validator::make($request->all(), [
            'name' => ['required', 'string', 'max:100'],
            'email' => ['required', 'email', 'unique:users,email,' . $request->user()->id],
            'bio' => ['nullable', 'string', 'max:500'],
        ], [
            'name.required' => 'Nama wajib diisi.',
            'email.required' => 'Email wajib diisi.',
            'email.email' => 'Format email tidak valid.',
            'email.unique' => 'Email tersebut sudah digunakan akun lain.',
        ])->validateWithBag('profile');

        $request->user()->update($validated);

        return back()->with('success', 'Profil berhasil diperbarui.');
    }

    public function updatePassword(Request $request)
    {
        $validated = Validator::make($request->all(), [
            'current_password' => ['required', 'current_password'],
            'password' => ['required', 'confirmed', Password::defaults()],
        ], [
            'current_password.required' => 'Password lama wajib diisi.',
            'current_password.current_password' => 'Password lama tidak sesuai.',
            'password.confirmed' => 'Konfirmasi password tidak cocok.',
        ])->validateWithBag('password');

        $request->user()->update([
            'password' => Hash::make($validated['password']),
        ]);

        return back()->with('success', 'Password berhasil diperbarui.');
    }
}

Di contoh ini, masing-masing action mengembalikan error ke bag yang berbeda. Hasilnya, komponen Inertia dapat menampilkan error secara terpisah dan jauh lebih presisi.

Kapan tetap memakai FormRequest penuh?

Jika Anda ingin tetap memakai FormRequest sekaligus named bag, ada beberapa pendekatan lanjutan seperti menyesuaikan validator atau melempar exception dengan bag tertentu. Namun dalam praktik sehari-hari, banyak tim memilih kompromi yang sederhana:

  • FormRequest untuk endpoint tunggal atau halaman dengan satu form.
  • Validator::make()->validateWithBag() untuk halaman multi-form yang membutuhkan bag berbeda secara eksplisit.

Pilihan ini bukan soal paling elegan secara teori, tetapi soal keterbacaan dan kemudahan debugging.

Redirect Back, Old Input, dan Perilaku Khusus pada Password

Pada validasi gagal, Laravel biasanya menjalankan redirect back sambil membawa:

  • error validation
  • old input dari request sebelumnya

Inilah yang membuat form dapat terisi kembali setelah gagal validasi. Pada aplikasi Blade klasik, data ini biasanya diakses lewat helper old(). Di Inertia, konsepnya sedikit berbeda karena komponen frontend memegang state sendiri. Jika Anda memakai helper form dari Inertia, nilai input yang sudah diketik umumnya tetap ada di state komponen saat response validasi kembali.

Meski begitu, memahami konsep old input tetap penting karena sumber datanya tetap berasal dari mekanisme redirect Laravel. Untuk field umum seperti name, email, atau bio, mempertahankan nilai lama biasanya diinginkan.

Namun ada pengecualian penting: field password. Demi keamanan dan pengalaman yang lebih aman, jangan berasumsi password lama atau password baru akan dipertahankan seperti input biasa. Sebaiknya kosongkan field password setelah submit, terutama saat berhasil.

Praktik yang disarankan: pertahankan old input untuk form biodata, tetapi reset field sensitif seperti current_password, password, dan password_confirmation.

Menampilkan Error Secara Konsisten di Komponen Inertia

Konsistensi tampilan error adalah hal yang sering diabaikan. Padahal, saat proyek membesar, pola error yang berbeda-beda antar komponen akan menyulitkan pemeliharaan.

Gunakan komponen kecil untuk input dan error, misalnya komponen InputError yang hanya bertugas menerima pesan dan menampilkannya jika ada.

Contoh komponen halaman profil

<script setup>
import { useForm, usePage } from '@inertiajs/vue3'

const page = usePage()

const profileForm = useForm({
  name: page.props.user.name ?? '',
  email: page.props.user.email ?? '',
  bio: page.props.user.bio ?? '',
})

const passwordForm = useForm({
  current_password: '',
  password: '',
  password_confirmation: '',
})

const submitProfile = () => {
  profileForm.post(route('profile.update'), {
    preserveScroll: true,
  })
}

const submitPassword = () => {
  passwordForm.post(route('profile.password.update'), {
    preserveScroll: true,
    onSuccess: () => passwordForm.reset(),
  })
}
</script>

<template>
  <section>
    <h2>Biodata</h2>
    <form @submit.prevent="submitProfile">
      <input v-model="profileForm.name" type="text" />
      <div v-if="$page.props.errors.profile?.name" class="text-red-600">
        {{ $page.props.errors.profile.name }}
      </div>

      <input v-model="profileForm.email" type="email" />
      <div v-if="$page.props.errors.profile?.email" class="text-red-600">
        {{ $page.props.errors.profile.email }}
      </div>

      <textarea v-model="profileForm.bio" />
      <div v-if="$page.props.errors.profile?.bio" class="text-red-600">
        {{ $page.props.errors.profile.bio }}
      </div>

      <button :disabled="profileForm.processing">Simpan Biodata</button>
    </form>
  </section>

  <section>
    <h2>Ubah Password</h2>
    <form @submit.prevent="submitPassword">
      <input v-model="passwordForm.current_password" type="password" />
      <div v-if="$page.props.errors.password?.current_password" class="text-red-600">
        {{ $page.props.errors.password.current_password }}
      </div>

      <input v-model="passwordForm.password" type="password" />
      <div v-if="$page.props.errors.password?.password" class="text-red-600">
        {{ $page.props.errors.password.password }}
      </div>

      <input v-model="passwordForm.password_confirmation" type="password" />

      <button :disabled="passwordForm.processing">Ubah Password</button>
    </form>
  </section>
</template>

Poin penting dari contoh di atas:

  • Setiap form punya state terpisah.
  • Error diambil dari bag yang sesuai: errors.profile dan errors.password.
  • Field password di-reset saat sukses.
  • preserveScroll membantu agar posisi halaman tidak meloncat setelah submit.

Jika Anda tidak memakai named bag, struktur akses error akan lebih datar. Namun untuk halaman multi-form, pendekatan tersebut cepat menjadi rapuh.

Common Mistakes dan Tips Debugging

1. Semua error muncul di tempat yang salah

Penyebab paling umum adalah semua validasi memakai bag default. Solusinya: pastikan endpoint yang berbeda memakai named error bag yang berbeda juga.

2. Error tidak muncul di komponen

Periksa beberapa hal berikut:

  • Nama field di backend harus sama dengan yang dibinding di frontend.
  • Pastikan Anda membaca bag yang benar, misalnya errors.profile.name, bukan errors.name.
  • Pastikan response benar-benar berupa redirect validation, bukan JSON manual yang memotong alur Inertia.

3. Password tetap terisi setelah submit

Jangan mengandalkan perilaku default. Reset field password secara eksplisit setelah sukses, dan bila perlu juga setelah gagal tergantung kebijakan UI Anda.

4. FormRequest terasa sulit dipakai untuk multi-form

Itu wajar. Di halaman dengan beberapa form, kebutuhan named bag kadang membuat validator manual lebih langsung dan mudah dipahami. Jangan memaksakan satu pola untuk semua kasus.

5. Pesan error tidak konsisten

Standarkan gaya penulisan pesan, misalnya:

  • gunakan bahasa yang sama di seluruh aplikasi,
  • hindari campuran istilah teknis dan istilah bisnis tanpa alasan,
  • simpan custom message dekat dengan validasinya agar mudah dirawat.

Rekomendasi Implementasi untuk Proyek Nyata

Jika Anda membangun halaman Inertia yang memiliki lebih dari satu form, pola berikut biasanya paling aman:

  1. Pisahkan endpoint untuk setiap form.
  2. Gunakan validator atau FormRequest yang spesifik untuk setiap aksi.
  3. Gunakan named error bag untuk mencegah tabrakan error.
  4. Pisahkan state form di komponen frontend.
  5. Buat komponen error kecil agar tampilan konsisten.
  6. Reset field sensitif setelah operasi sukses.

Untuk halaman profil, contoh pembagian yang baik adalah:

  • POST /profile atau PATCH /profile untuk biodata dengan bag profile
  • PUT /profile/password untuk password dengan bag password

Pola ini jelas, mudah diuji, dan meminimalkan kebingungan saat tim lain membaca kode.

Penutup

Validasi server-side pada Inertia.js dengan Laravel pada dasarnya tetap mengikuti kekuatan utama Laravel: rule yang terpusat, redirect back, error dari session, dan old input. Yang membedakan adalah bagaimana data tersebut dikonsumsi oleh komponen frontend.

Untuk halaman sederhana, validasi default mungkin sudah cukup. Tetapi untuk halaman dengan beberapa form seperti profil pengguna, named error bag adalah alat yang sangat penting agar error tidak saling tercampur. Dikombinasikan dengan FormRequest atau validator yang tepat, custom message yang jelas, dan pola tampilan error yang konsisten, Anda akan mendapatkan alur validasi yang lebih mudah dipahami, lebih aman, dan lebih nyaman digunakan.

Jika harus diringkas dalam satu prinsip: biarkan Laravel tetap menjadi sumber kebenaran validasi, lalu tampilkan hasilnya di Inertia dengan struktur yang disiplin.