Sejauh ini aplikasi kontak percaya sepenuhnya pada pemanggilnya: siapa pun yang memanggil simpan() bisa mengirim nama satu huruf, email tanpa tanda @, atau nomor telepon berisi tanda hubung. Database memang akan menolak sebagian pelanggaran, tetapi pesannya berbahasa Inggris teknis dan berhenti pada pelanggaran pertama. Pada bagian ini kita membangun lapisan validasi bernama ContactValidator yang memeriksa data sebelum menyentuh database, mengumpulkan seluruh galat dalam satu kantong (error bag), dan mendeteksi email duplikat lewat repository — sambil tetap menjadikan constraint UNIQUE sebagai jaring pengaman terakhir yang tak tertembus.

Mengapa Validasi Tidak Diserahkan ke Database #

Database adalah penjaga yang kuat tetapi buruk tata krama. NOT NULL constraint failed: contacts.nama akurat bagi developer, namun bukan kalimat yang layak ditampilkan kepada pengguna; lebih jauh, SQLite berhenti pada pelanggaran pertama, sehingga pengguna memperbaiki satu kolom, mengirim ulang, lalu disambut galat kedua — pengalaman yang melelahkan. Lapisan validasi di sisi aplikasi mengambil alih tugas itu dengan tiga keunggulan: pesan bisa ditulis manusiawi dalam bahasa kita, seluruh kolom diperiksa sekaligus sehingga satu putaran umpan balik cukup, dan aturan yang tidak mudah diekspresikan di schema — seperti panjang minimum nama atau format telepon — tetap bisa ditegakkan.

Konsekuensi arsitekturnya jelas: validasi menjadi gerbang pertama, constraint database menjadi gerbang terakhir. Keduanya tidak saling menggantikan. Validasi bisa dilewati oleh kode yang lupa memanggilnya; constraint tidak bisa ditipu oleh siapa pun. Sebaliknya, constraint tidak bisa memberi pesan ramah maupun memeriksa format — di situlah validator berdiri.

Class ContactValidator #

Validator tidak berdiri sendiri: untuk mendeteksi email duplikat, class ini butuh bertanya ke data yang sudah tersimpan, dan pertanyaan itu dijawab lewat cariEmail() pada ContactRepository versi Bagian 3. Berikut isi lengkap ContactValidator.php pada berkas sumber di bagian bawah halaman:

php
<?php

declare(strict_types=1);

/**
 * Validasi data kontak sebelum disimpan ke database.
 *
 * Class ini memeriksa format tiap kolom dan mendeteksi email duplikat
 * lewat ContactRepository. Hasilnya berupa kantong galat (error bag):
 * array asosiatif kolom => pesan. Array kosong berarti data valid dan
 * siap diteruskan ke method simpan() atau perbarui().
 */
final class ContactValidator
{
    public function __construct(private ContactRepository $repository)
    {
    }

    /**
     * Validasi satu rangkaian data kontak.
     *
     * @param array<string, mixed> $data elemen nama, email, dan telepon
     * @param int|null $abaikanId id kontak yang sedang diperbarui;
     *        email milik kontak tersebut tidak dihitung duplikat
     * @return array<string, string> daftar pesan galat per kolom
     */
    public function validate(array $data, ?int $abaikanId = null): array
    {
        $galat = [];

        $nama = trim((string) ($data['nama'] ?? ''));
        $email = trim((string) ($data['email'] ?? ''));
        $telepon = trim((string) ($data['telepon'] ?? ''));

        if ($nama === '') {
            $galat['nama'] = 'Nama wajib diisi.';
        } elseif (mb_strlen($nama) < 3) {
            $galat['nama'] = 'Nama minimal 3 karakter.';
        }

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

        if ($telepon === '') {
            $galat['telepon'] = 'Nomor telepon wajib diisi.';
        } elseif (!preg_match('/^\+?[0-9]{9,15}$/', $telepon)) {
            $galat['telepon'] = 'Nomor telepon harus 9-15 digit, boleh diawali +.';
        }

        // Cek duplikat hanya bila format email sudah lolos.
        if (!isset($galat['email'])) {
            $existing = $this->repository->cariEmail($email);
            if ($existing !== null && (int) $existing['id'] !== $abaikanId) {
                $galat['email'] = "Email $email sudah terdaftar untuk kontak lain.";
            }
        }

        return $galat;
    }
}

Beberapa keputusan desain pada class tersebut perlu dibedah:

  1. Constructor menyuntikkan ContactRepository — validator tidak membuka koneksi sendiri dan tidak tahu cara query; class hanya memakai pintu data yang sudah ada, sehingga bisa divalidasi dan diganti implementasinya sebagaimana repository-nya.
  2. trim() + cast (string) di awal membuat masukan berupa spasi kosong, null, maupun kolom yang hilang sama-sama berakhir sebagai string kosong — satu jalur pemeriksaan untuk semua wujud data kotor.
  3. Setiap kolom memeriksa dua tingkat: kosong dulu, baru format. Pesan untuk “wajib diisi” berbeda dengan “format salah” karena perbaikannya pun berbeda.
  4. mb_strlen() dipakai, bukan strlen(), agar nama dengan karakter di luar ASCII dihitung per karakter, bukan per byte.
  5. Cek duplikat hanya berjalan bila format email lolos — tidak ada gunanya menanyakan budi#example.com ke database ketika formatnya sudah jelas salah.
  6. Parameter $abaikanId menjawab kasus pembaruan: kontak yang sedang disunting berhak mempertahankan emailnya sendiri tanpa dianggap duplikat.

Seluruh aturan tersebut dirangkum dalam tabel berikut:

KolomAturanPesan galat
namawajib diisi; minimal 3 karakterNama wajib diisi. / Nama minimal 3 karakter.
emailwajib; format valid; belum dipakai kontak lainFormat email tidak valid. / Email ... sudah terdaftar untuk kontak lain.
teleponwajib; 9-15 digit, boleh diawali +Nomor telepon harus 9-15 digit, boleh diawali +.

Aliran Data yang Valid dan Kantong Galat #

Mari kita jalankan kedua sisi validasi pada satu skrip. Data yang benar lolos dan langsung disimpan; data yang buruk ditolak dengan seluruh galatnya tercetak sekaligus:

php
<?php
require __DIR__ . '/../bagian-3-pencarian-dan-filtering/ContactRepository.php';
require __DIR__ . '/ContactValidator.php';

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-desain-data/schema.sql'));

$repo = new ContactRepository($pdo);
$validator = new ContactValidator($repo);

echo "== Data valid ==\n";
$dataBaru = [
    'nama'    => 'Eko Prasetyo',
    'email'   => '[email protected]',
    'telepon' => '081399998888',
];
$galat = $validator->validate($dataBaru);
echo $galat === [] ? "Semua kolom valid\n" : "Masih ada galat\n";

$idBaru = $repo->simpan($dataBaru['nama'], $dataBaru['email'], $dataBaru['telepon']);
echo "Tersimpan sebagai kontak #$idBaru\n";

echo "\n== Tiga kolom bermasalah sekaligus ==\n";
$galat = $validator->validate([
    'nama'    => 'Bu',
    'email'   => 'budi#example.com',
    'telepon' => '08-123',
]);
foreach ($galat as $kolom => $pesan) {
    echo "$kolom: $pesan\n";
}

Output yang Dihasilkan:

text
== Data valid ==
Semua kolom valid
Tersimpan sebagai kontak #7

== Tiga kolom bermasalah sekaligus ==
nama: Nama minimal 3 karakter.
email: Format email tidak valid.
telepon: Nomor telepon harus 9-15 digit, boleh diawali +.

Pada blok pertama, array kosong dari validate() menjadi tanda lunas: pemanggil memeriksa $galat === [] dan hanya meneruskan data ke simpan() bila bersih. Blok kedua memperlihatkan kekuatan pola error bag — ketiga kolom bermasalah, dan ketiganya dilaporkan dalam satu panggilan. filter_var() dengan FILTER_VALIDATE_EMAIL menolak budi#example.com, regex /^\+?[0-9]{9,15}$/ menolak 08-123 karena tanda hubung, dan mb_strlen('Bu') bernilai 2. Pengguna memperbaiki ketiganya dalam satu kali menyunting formulir, bukan tiga kali bolak-balik.

Cek Duplikat dan Pembaruan Kontak #

Duplikat email adalah aturan yang hanya database yang tahu jawabannya, jadi validator bertanya ke repository. Contoh berikut menguji dua sisanya: email milik kontak lain ditolak, sedangkan kontak yang sedang diperbarui boleh mempertahankan emailnya sendiri:

php
<?php
require __DIR__ . '/../bagian-3-pencarian-dan-filtering/ContactRepository.php';
require __DIR__ . '/ContactValidator.php';

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-desain-data/schema.sql'));

$repo = new ContactRepository($pdo);
$validator = new ContactValidator($repo);

echo "== Email yang sudah dipakai kontak lain ==\n";
$galat = $validator->validate([
    'nama'    => 'Budi Kedua',
    'email'   => '[email protected]',
    'telepon' => '081111122222',
]);
foreach ($galat as $kolom => $pesan) {
    echo "$kolom: $pesan\n";
}

echo "\n== Memperbarui kontak #2 dengan emailnya sendiri ==\n";
$siti = $repo->cariId(2);
$galat = $validator->validate([
    'nama'    => 'Siti Nurhaliza Putri',
    'email'   => $siti['email'],
    'telepon' => $siti['telepon'],
], 2);
echo $galat === [] ? "Pembaruan kontak #2 valid\n" : "Pembaruan ditolak\n";

Output yang Dihasilkan:

text
== Email yang sudah dipakai kontak lain ==
email: Email [email protected] sudah terdaftar untuk kontak lain.

== Memperbarui kontak #2 dengan emailnya sendiri ==
Pembaruan kontak #2 valid

Pada kasus pertama, cariEmail('[email protected]') menemukan Budi, dan karena id Budi (1) berbeda dari $abaikanId yang null, validasi gagal dengan pesan yang menyebut alamatnya secara eksplisit. Kasus kedua memperlihatkan parameter $abaikanId bekerja: email Siti memang sudah terdaftar, tetapi terdaftar pada kontak #2 — kontak yang sedang diperbarui — sehingga bukan duplikat dan pembaruan sah. Tanpa parameter ini, pengguna tidak akan pernah bisa menyimpan profilnya sendiri tanpa berganti email setiap kali menyunting nama.

Constraint sebagai Baris Pertahanan Terakhir #

Validator dan constraint kini berdiri berlapis, dan lapisan terakhir itu bukan formalitas. Celah klasik bernama check-then-act: dua proses memvalidasi email yang sama nyaris bersamaan, keduanya lolos karena data lawannya belum tersimpan, lalu keduanya mencoba menyimpan. Simulasi berikut memperlihatkan bagaimana database menutup celah itu, dan bagaimana kita menerjemahkan penolakannya menjadi pesan ramah:

php
<?php
require __DIR__ . '/../bagian-3-pencarian-dan-filtering/ContactRepository.php';
require __DIR__ . '/ContactValidator.php';

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-desain-data/schema.sql'));

$repo = new ContactRepository($pdo);
$validator = new ContactValidator($repo);

// Dua proses menyimpan email yang sama dan sama-sama memvalidasi
// lebih dulu - sebelum salah satunya sempat tersimpan.
$dataA = ['nama' => 'Budi Kedua', 'email' => '[email protected]', 'telepon' => '081111122222'];
$dataB = ['nama' => 'Budi Ketiga', 'email' => '[email protected]', 'telepon' => '081222233333'];

foreach ([$dataA, $dataB] as $data) {
    $galat = $validator->validate($data);
    echo $galat === []
        ? "Validasi lolos: {$data['nama']}\n"
        : "Validasi gagal: {$data['nama']}\n";
}

echo "\nBaru setelah itu keduanya mencoba menyimpan:\n";
foreach ([$dataA, $dataB] as $data) {
    try {
        $id = $repo->simpan($data['nama'], $data['email'], $data['telepon']);
        echo "Tersimpan: {$data['nama']} sebagai kontak #$id\n";
    } catch (PDOException $e) {
        if ($e->getCode() === '23000') {
            echo "Ditolak database: {$data['nama']} - email sudah dipakai kontak lain\n";
        } else {
            throw $e;
        }
    }
}

Output yang Dihasilkan:

text
Validasi lolos: Budi Kedua
Validasi lolos: Budi Ketiga

Baru setelah itu keduanya mencoba menyimpan:
Tersimpan: Budi Kedua sebagai kontak #7
Ditolak database: Budi Ketiga - email sudah dipakai kontak lain

Validasi kedua data sama-sama lolos karena email [email protected] memang belum ada saat pemeriksaan berlangsung — persis kondisi lomba antar-proses di server sungguhan. Penyimpanan pertama sukses; penyimpanan kedua ditampar constraint UNIQUE dengan SQLSTATE 23000, dan blok catch menerjemahkannya menjadi satu baris pesan manusiawi alih-alih meluapkan pesan mentah ke pengguna. Perhatikan pula cabang else { throw $e; }: hanya pelanggaran integritas yang diterjemahkan; galat lain tetap dilempar agar tidak menelan masalah serius seperti kegagalan koneksi.

Cara Menjalankan Contoh #

Validator membutuhkan dua berkas dari bagian sebelumnya, jadi struktur foldernya kini tiga tingkat:

text
aplikasi-kontak/
├── bagian-1-desain-data/
│   └── schema.sql
├── bagian-3-pencarian-dan-filtering/
│   └── ContactRepository.php
└── bagian-4-validasi-dan-integritas/
    ├── ContactValidator.php
    └── uji-validasi.php

Bundle bagian ini memang hanya berisi ContactValidator.php; class repository dimuat lewat path relatif ../bagian-3-pencarian-dan-filtering/ContactRepository.php karena kedua bagian adalah folder saudara di bawah satu proyek. Simpan salah satu snippet sebagai uji-validasi.php, lalu jalankan dari folder bagian-4-validasi-dan-integritas:

bash
php uji-validasi.php

Apa yang Berubah #

Lapisan logika pertama proyek ini lahir di atas lapisan data:

  • Berkas ContactValidator.php baru berisi class validator dengan satu method publik validate() yang mengembalikan kantong galat per kolom.
  • Repository tidak berubah sama sekali — validator memakai ContactRepository versi Bagian 3 apa adanya, bukti bahwa arah ketergantungan yang bersih membuat lapisan baru murah ditambahkan.
  • Alur penyimpanan resmi proyek kini tiga langkah: validate() di depan, simpan() di tengah, dan catch kode 23000 sebagai penerjemah di belakang.
  • Parameter $abaikanId membuat pembaruan kontak sah tanpa melepas email miliknya sendiri.

Ringkasan #

Validasi memindahkan pengalaman galat dari pesan mentah database menjadi kantong pesan yang manusiawi dan lengkap dalam satu putaran. ContactValidator menyuntikkan repository untuk cek duplikat, menjaga aturan format per kolom, dan menerima $abaikanId agar pembaruan tidak salah menuduh emailnya sendiri. Di belakangnya, constraint UNIQUE tetap berdiri sebagai satu-satunya penjaga yang tidak bisa dilewati — dan kode catch 23000 menerjemahkan penolakan terakhir itu menjadi kalimat yang pantas dibaca manusia. Aplikasi kini lengkap dan tangguh; tinggal satu pertanyaan: bagaimana memastikan semua ini tetap benar setiap kali kode berubah? Bagian 5 menjawabnya dengan test PHPUnit di atas database yang terisolasi.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

ContactValidator.php

Unduh berkas
php
<?php

declare(strict_types=1);

/**
 * Validasi data kontak sebelum disimpan ke database.
 *
 * Class ini memeriksa format tiap kolom dan mendeteksi email duplikat
 * lewat ContactRepository. Hasilnya berupa kantong galat (error bag):
 * array asosiatif kolom => pesan. Array kosong berarti data valid dan
 * siap diteruskan ke method simpan() atau perbarui().
 */
final class ContactValidator
{
    public function __construct(private ContactRepository $repository)
    {
    }

    /**
     * Validasi satu rangkaian data kontak.
     *
     * @param array<string, mixed> $data elemen nama, email, dan telepon
     * @param int|null $abaikanId id kontak yang sedang diperbarui;
     *        email milik kontak tersebut tidak dihitung duplikat
     * @return array<string, string> daftar pesan galat per kolom
     */
    public function validate(array $data, ?int $abaikanId = null): array
    {
        $galat = [];

        $nama = trim((string) ($data['nama'] ?? ''));
        $email = trim((string) ($data['email'] ?? ''));
        $telepon = trim((string) ($data['telepon'] ?? ''));

        if ($nama === '') {
            $galat['nama'] = 'Nama wajib diisi.';
        } elseif (mb_strlen($nama) < 3) {
            $galat['nama'] = 'Nama minimal 3 karakter.';
        }

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

        if ($telepon === '') {
            $galat['telepon'] = 'Nomor telepon wajib diisi.';
        } elseif (!preg_match('/^\+?[0-9]{9,15}$/', $telepon)) {
            $galat['telepon'] = 'Nomor telepon harus 9-15 digit, boleh diawali +.';
        }

        // Cek duplikat hanya bila format email sudah lolos.
        if (!isset($galat['email'])) {
            $existing = $this->repository->cariEmail($email);
            if ($existing !== null && (int) $existing['id'] !== $abaikanId) {
                $galat['email'] = "Email $email sudah terdaftar untuk kontak lain.";
            }
        }

        return $galat;
    }
}