Validasi server-side yang ketat hanya menyelesaikan separuh pekerjaan. Sisi lain yang sama pentingnya adalah pengalaman pengguna: ketika isian form ditolak, pengguna perlu tahu persis field mana yang salah, mengapa salah, dan nilai apa yang sudah mereka isi sebelumnya. Pada chapter ini kita membangun lapisan presentasi error di atas validasi yang sudah kita kuasai pada chapter Validasi Server-Side : pola error bag, ringkasan error, pesan error per field, nilai input lama (old input), sampai pengamanan output agar pesan error tidak menjadi pintu masuk XSS.

Pola Error Bag #

Error bag adalah konvensi menyimpan seluruh pesan error validasi dalam satu array asosiatif: kuncinya nama field, nilainya daftar pesan untuk field tersebut. Bentuk ini mudah diproses untuk ditampilkan sebagai ringkasan maupun per field, dan mudah dioper antara fungsi validasi dan fungsi render. Perhatikan contoh berikut:

php
<?php
function validasiRegistrasi(array $input): array
{
    $errors = [];

    $nama  = trim($input['nama'] ?? '');
    $email = trim($input['email'] ?? '');

    if ($nama === '') {
        $errors['nama'][] = 'Nama lengkap wajib diisi.';
    } elseif (mb_strlen($nama) > 100) {
        $errors['nama'][] = 'Nama lengkap maksimal 100 karakter.';
    }

    if ($email === '') {
        $errors['email'][] = 'Alamat email wajib diisi.';
    } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $errors['email'][] = 'Format alamat email tidak valid.';
    }

    return $errors;
}

// Simulasi $_POST yang dikirim pengguna
$post = ['nama' => '', 'email' => 'budi#example.com'];

foreach (validasiRegistrasi($post) as $field => $daftarError) {
    foreach ($daftarError as $error) {
        echo "{$field}: {$error}\n";
    }
}

Output yang Dihasilkan:

text
nama: Nama lengkap wajib diisi.
email: Format alamat email tidak valid.

Mari kita telaah struktur yang dikembalikan fungsi tersebut:

  1. Baris $errors = [];: array error selalu diinisialisasi kosong sehingga fungsi mengembalikan [] saat semua isian valid — kondisi ini yang kita periksa untuk memutuskan lanjut atau tidak.
  2. Baris $errors['nama'][] = ...;: setiap pesan ditambahkan ke daftar milik field-nya. Satu field bisa memiliki lebih dari satu masalah, misalnya kosong sekaligus terlalu panjang (walau di sini elseif membuat keduanya saling eksklusif).
  3. Baris trim($input['nama'] ?? ''): operator ?? menjaga kita dari warning undefined array key ketika field tidak ikut dikirim, perilaku yang sudah kita bahas pada chapter Membaca Form Input .
  4. filter_var(..., FILTER_VALIDATE_EMAIL): pemeriksaan format email mengikuti materi chapter Validasi Email dan URL .

Hasil $errors === [] berarti semua isian lolos; array yang tidak kosong menjadi bahan render pada bagian-bagian berikutnya.

Ringkasan Error di Bagian Atas Form #

Pola pertama menampilkan daftar seluruh masalah sekaligus di bagian atas form, sehingga pengguna langsung melihat gambaran lengkap sebelum memperbaiki isian. Fungsi berikut mengubah error bag menjadi HTML:

php
<?php
function e(string $nilai): string
{
    return htmlspecialchars($nilai, ENT_QUOTES, 'UTF-8');
}

function renderRingkasanError(array $errors): string
{
    if ($errors === []) {
        return '';
    }

    $html  = '<div class="error-summary" role="alert" aria-labelledby="judul-error">' . "\n";
    $html .= '  <h3 id="judul-error">Periksa kembali isian Anda</h3>' . "\n";
    $html .= "  <ul>\n";
    foreach ($errors as $daftarError) {
        foreach ($daftarError as $error) {
            $html .= '    <li>' . e($error) . "</li>\n";
        }
    }
    return $html . "  </ul>\n</div>";
}

$errors = [
    'nama'  => ['Nama lengkap wajib diisi.'],
    'email' => ['Format alamat email tidak valid.'],
];

echo renderRingkasanError($errors) . "\n";

Output yang Dihasilkan:

text
<div class="error-summary" role="alert" aria-labelledby="judul-error">
  <h3 id="judul-error">Periksa kembali isian Anda</h3>
  <ul>
    <li>Nama lengkap wajib diisi.</li>
    <li>Format alamat email tidak valid.</li>
  </ul>
</div>

Dua hal penting dari fungsi di atas:

  1. role="alert": atribut ARIA ini memberi tahu screen reader bahwa kontainer berisi pesan penting yang harus segera dibacakan ketika muncul. Pengguna tunanetra tidak perlu mencari-cari error di tengah form.
  2. e() di setiap pesan: helper htmlspecialchars() memastikan pesan error aman disisipkan ke HTML. Ini bukan formalitas — alasannya kita bedah pada bagian khusus di bawah.

Ringkasan bekerja baik untuk form pendek. Untuk form panjang dengan banyak field, ringkasan saja belum cukup karena pengguna harus meneung-geser naik-turun mencocokkan pesan dengan field-nya.

Error Tingkat Field dengan ARIA #

Pesan error paling bermanfaat ketika tampil tepat di samping field yang bermasalah. Kita menghubungkan keduanya lewat pasangan atribut aria-describedby dan id:

php
<?php
function e(string $nilai): string
{
    return htmlspecialchars($nilai, ENT_QUOTES, 'UTF-8');
}

function renderFieldEmail(string $nilaiLama, array $errorsEmail): string
{
    $html  = '<label for="email">Alamat Email</label>' . "\n";
    $html .= '<input type="email" id="email" name="email" value="' . e($nilaiLama) . '"';

    if ($errorsEmail !== []) {
        $html .= ' class="invalid" aria-invalid="true" aria-describedby="error-email"';
    }
    $html .= ">\n";

    if ($errorsEmail !== []) {
        $html .= '<p class="field-error" id="error-email">' . e($errorsEmail[0]) . '</p>';
    }

    return $html;
}

echo renderFieldEmail('budi#example.com', ['Format alamat email tidak valid.']) . "\n";

Output yang Dihasilkan:

text
<label for="email">Alamat Email</label>
<input type="email" id="email" name="email" value="budi#example.com" class="invalid" aria-invalid="true" aria-describedby="error-email">
<p class="field-error" id="error-email">Format alamat email tidak valid.</p>

Bedah atribut aksesibilitas pada output di atas:

  1. aria-describedby="error-email" pada <input> memberi tahu screen reader bahwa elemen id="error-email" adalah deskripsi untuk field ini; saat pengguna fokus ke field email, pesan error ikut dibacakan.
  2. aria-invalid="true" menandai field sebagai berisi nilai tidak valid, bukan sekadar salah gaya.
  3. class="invalid" murni untuk CSS — atribut presentasi dan atribut semantik dipisah agar keduanya bisa berkembang mandiri.

Digabungkan dengan ringkasan, hasil akhirnya tampak seperti markup berikut:

html
<form method="post" action="/registrasi" novalidate>
    <div class="error-summary" role="alert" aria-labelledby="judul-error">
        <h3 id="judul-error">Periksa kembali isian Anda</h3>
        <ul>
            <li>Format alamat email tidak valid.</li>
        </ul>
    </div>

    <label for="nama">Nama Lengkap</label>
    <input type="text" id="nama" name="nama" value="Budi Santoso">

    <label for="email">Alamat Email</label>
    <input type="email" id="email" name="email" value="budi#example.com"
           class="invalid" aria-invalid="true" aria-describedby="error-email">
    <p class="field-error" id="error-email">Format alamat email tidak valid.</p>

    <button type="submit">Daftar</button>
</form>

Mempertahankan Nilai Input Lama #

Form yang mengosongkan seluruh isian setelah gagal validasi adalah penyiksaan kecil bagi pengguna. Pola old input menyimpan nilai yang dikirim pengguna, lalu mengisikannya kembali ke atribut value saat form dirender ulang:

php
<?php
function old(string $field, array $inputLama, string $default = ''): string
{
    return htmlspecialchars(
        (string) ($inputLama[$field] ?? $default),
        ENT_QUOTES,
        'UTF-8',
    );
}

// Dalam aplikasi nyata, $inputLama diambil dari session setelah redirect PRG
$inputLama = ['nama' => 'Budi Santoso', 'email' => ''];

echo '<input name="nama" value="' . old('nama', $inputLama) . '">' . "\n";
echo '<input name="telepon" value="' . old('telepon', $inputLama, '0812') . '">' . "\n";

Output yang Dihasilkan:

text
<input name="nama" value="Budi Santoso">
<input name="telepon" value="0812">

Fungsi old() membaca nilai dari $inputLama, mengembalikan $default bila field tidak ada, dan mengembalikannya sudah di-escape. Nama old() sendiri adalah konvensi yang populer di ekosistem PHP — framework seperti Laravel memakai helper bernama persis seperti ini. Pada aplikasi nyata, $inputLama berasal dari session yang diisi sebelum redirect — alur lengkapnya kita bangun pada chapter Post/Redirect/Get , dan penyimpanannya dibahas di chapter Flash Messages .

Mengamankan Output Error dari XSS #

Mengapa kita begitu disiplin meng-escape pesan error dan nilai lama? Karena keduanya bisa mengandung masukan mentah pengguna. Demo berikut menunjukkan apa yang terjadi pada payload <script>:

php
<?php
// Nama yang dikirim penyerang melalui form
$namaDariForm = '<script>alert("Budi")</script>';

$aman = htmlspecialchars($namaDariForm, ENT_QUOTES, 'UTF-8');

echo "Tanpa escaping: {$namaDariForm}\n";
echo "Dengan escaping: {$aman}\n";

Output yang Dihasilkan:

text
Tanpa escaping: <script>alert("Budi")</script>
Dengan escaping: &lt;script&gt;alert(&quot;Budi&quot;)&lt;/script&gt;

Baris pertama mencetak tag <script> apa adanya — jika string ini disisipkan mentah ke halaman, browser mengeksekusinya sebagai JavaScript. Baris kedua mengubah <, >, dan " menjadi entitas HTML (&lt;, &gt;, &quot;), sehingga browser menampilkannya sebagai teks biasa, bukan kode. Pesan error seperti “Nama {$nama} sudah dipakai” sangat mudah secara tak sengaja memantulkan masukan mentah; kebiasaan meng-escape semua yang melewati batas PHP → HTML menutup celah ini. Pembahasan mendalam tentang konteks output ada di chapter Output Escaping .

Konvensi Penyematan #

Terakhir, beberapa konvensi visual yang sudah menjadi kebiasaan komunitas. Pesan error tampil dekat field, memakai warna merah yang kontras, dan tidak mengandalkan warna saja:

css
.error-summary {
    border: 1px solid #c0392b;
    background-color: #fdecea;
    padding: 12px 16px;
    border-radius: 6px;
}

.error-summary h3 {
    margin: 0 0 8px;
    color: #c0392b;
    font-size: 1rem;
}

.field-error {
    color: #c0392b;
    font-size: 0.875rem;
    margin: 4px 0 0;
}

input.invalid {
    border-color: #c0392b;
}

Prinsipnya sederhana: warna merah diperkuat teks eksplisit (“Periksa kembali…”) dan posisi (tepat di bawah field), sehingga informasi tetap sampai bagi pengguna dengan buta warna maupun pembaca layar. Kelas CSS dipicu oleh keberadaan error pada field, bukan oleh JavaScript yang menebak-nebak.

Ringkasan #

Error bag memberi struktur tunggal untuk membawa seluruh masalah validasi dari fungsi validasi ke lapisan presentasi. Ringkasan di atas form memberi gambaran umum, pesan per field dengan aria-describedby memberi arahan yang presisi, old input menyelamatkan pengguna dari mengisi ulang, dan htmlspecialchars() menjaga semua output tetap aman. Namun ada satu masalah yang belum kita tangani: render ulang form ini terjadi sebagai respons POST — dan itulah sumber masalah submit ganda yang kita selesaikan pada chapter Post/Redirect/Get .

Referensi #