API yang menerima data dari luar tidak boleh percaya begitu saja: string kosong, status yang tidak dikenal, atau field yang hilang harus ditolak dengan pesan yang jelas, bukan disimpan begitu saja atau membuat query database gagal. Pada Bagian 4 kita membangun dua kelas yang melengkapi lapisan pertahanan — Validator untuk memeriksa input dan ErrorHandler untuk membentuk seluruh response error dalam satu kerangka JSON. Setelah itu handler POST /api/tugas dan GET /api/tugas/{id} dirangkai penuh di atas Router (Bagian 2) dan TaskRepository (Bagian 3), sehingga API benar-benar menyimpan data dan menolak data sampah dengan sopan.

Aturan Validasi di Kelas Validator #

Validator bekerja secara deklaratif: aturan didaftarkan berantai per kolom, lalu validate() menjalankan semuanya terhadap array data. Hasil kembalian berupa peta kolom ke daftar pesan error — array kosong berarti data lolos:

php
<?php

require __DIR__ . '/src/Validator.php';

use App\Validator;

$validator = (new Validator())
    ->required('judul')
    ->min('judul', 3)
    ->max('judul', 100)
    ->in('status', ['belum', 'proses', 'selesai']);

$valid = $validator->validate(['judul' => 'Belajar validasi', 'status' => 'belum']);
echo 'Data lengkap  : ', $valid === [] ? 'lolos validasi' : 'ditolak', "\n";

$errors = $validator->validate(['judul' => 'a', 'status' => 'batal']);
echo "Data bermasalah:\n";
foreach ($errors as $kolom => $daftar) {
    foreach ($daftar as $pesan) {
        echo "  [$kolom] $pesan\n";
    }
}

Output yang Dihasilkan:

text
Data lengkap  : lolos validasi
Data bermasalah:
  [judul] Kolom 'judul' minimal 3 karakter.
  [status] Kolom 'status' harus salah satu dari: belum, proses, selesai.

Empat aturan di atas menutup kebutuhan proyek ini. required gagal bila kolom tidak ada, bernilai null, atau berupa string kosong setelah di-trim. min dan max memeriksa panjang teks dengan mb_strlen() sehingga teks non-ASCII dihitung per karakter, bukan per byte. in membatasi nilai ke daftar yang diizinkan — persis tiga nilai yang juga dijaga constraint CHECK pada Bagian 3. Satu keputusan desain penting: aturan selain required mengabaikan nilai kosong, jadi kolom opsional tetap boleh tidak dikirim; keharusan mengisi dinyatakan eksplisit lewat required. Karena peta error menyimpan daftar pesan per kolom, satu kolom bisa melaporkan beberapa pelanggaran sekaligus — dan urutannya mengikuti urutan deklarasi aturan, sehingga output selalu stabil.

Bentuk Error yang Konsisten di ErrorHandler #

Dari dua bagian sebelumnya kita sudah mengenal bentuk {"error": {"code": ..., "message": ...}}. ErrorHandler sekarang memusatkan pembentukannya, plus menangani tiga sumber error yang berbeda. Satu detail penting untuk produksi: detail teknis exception hanya boleh tampil di lingkungan pengembangan:

php
<?php

require __DIR__ . '/src/ErrorHandler.php';

use App\ErrorHandler;

// 1) Peta error Validator -> VALIDATION_ERROR dengan details.
$errors = ['judul' => ["Kolom 'judul' wajib diisi."]];
echo ErrorHandler::toJson($errors, 422), "\n\n";

// 2) Spesifikasi error siap pakai -> misalnya 404.
$tidakAda = ['code' => 'NOT_FOUND', 'message' => 'Tugas dengan id 99 tidak ditemukan.'];
echo ErrorHandler::toJson($tidakAda, 404), "\n\n";

// 3) Exception: mode dev menampilkan detail, mode produksi merahasiakannya.
try {
    throw new RuntimeException('Gagal membuka berkas cache.');
} catch (RuntimeException $e) {
    echo 'dev  : ', ErrorHandler::toJson($e, 500, true), "\n";
    echo 'prod : ', ErrorHandler::toJson($e, 500, false), "\n";
}

Output yang Dihasilkan:

text
{"error":{"code":"VALIDATION_ERROR","message":"Data yang dikirim tidak valid.","details":{"judul":["Kolom 'judul' wajib diisi."]}}}

{"error":{"code":"NOT_FOUND","message":"Tugas dengan id 99 tidak ditemukan."}}

dev  : {"error":{"code":"INTERNAL_ERROR","message":"Gagal membuka berkas cache.","details":{"exception":"RuntimeException","berkas":".verify_tmp.php","baris":17}}}
prod : {"error":{"code":"INTERNAL_ERROR","message":"Terjadi kesalahan internal. Silakan coba lagi nanti."}}

Tiga bentuk di atas menegaskan kontrak error API: kunci details hanya muncul bila ada informasi tambahan, VALIDATION_ERROR membawa peta pesan per kolom, dan INTERNAL_ERROR merahasiakan jejak internal saat mode produksi. Mode dev menampilkan pesan exception, nama kelasnya, berkas, dan baris kejadian — nilai berkas pada contoh di atas adalah .verify_tmp.php, nama berkas sementara yang dipakai alat verifikasi untuk mengeksekusi contoh; saat Anda menjalankannya dari berkas sendiri, nama itu mengikuti berkas Anda. Dua method tersedia: payload() mengembalikan array (nyaman dipasangkan dengan Response::json()), sedangkan toJson() mengembalikan string JSON siap kirim (dipakai catch block front controller).

Merangkai Handler Create dan Read #

Sekarang seluruh bagian sebelumnya dirangkai. Contoh berikut memuat kelas-kelas proyek lalu mendaftarkan dua endpoint sungguhan: POST /api/tugas yang memvalidasi sebelum menyimpan, dan GET /api/tugas/{id} yang menjawab 404 untuk tugas yang tidak ada:

php
<?php

// Muat seluruh kelas proyek. Pada proyek nyata semua berkas src/ berada
// dalam satu direktori; pada bundle halaman ini kelas-kelas Bagian 2 dan
// Bagian 3 dimuat dari folder bagian sebelumnya.
require __DIR__ . '/../bagian-2-routing-dan-request/src/Request.php';
require __DIR__ . '/../bagian-2-routing-dan-request/src/Response.php';
require __DIR__ . '/../bagian-2-routing-dan-request/src/Router.php';
require __DIR__ . '/../bagian-3-database-dan-repository/src/Database.php';
require __DIR__ . '/../bagian-3-database-dan-repository/src/TaskRepository.php';
require __DIR__ . '/src/Validator.php';
require __DIR__ . '/src/ErrorHandler.php';

use App\Database;
use App\ErrorHandler;
use App\Request;
use App\Response;
use App\Router;
use App\TaskRepository;
use App\Validator;

$pdo = Database::buat();
Database::migrasi($pdo, __DIR__ . '/../bagian-3-database-dan-repository/database/schema.sql');
$repo = new TaskRepository($pdo);

$router = new Router();

$router->add('POST', '/api/tugas', static function (Request $request) use ($repo): Response {
    $errors = (new Validator())
        ->required('judul')
        ->min('judul', 3)
        ->max('judul', 100)
        ->in('status', ['belum', 'proses', 'selesai'])
        ->validate($request->body());

    if ($errors !== []) {
        return Response::json(ErrorHandler::payload($errors), 422);
    }

    $tugas = $repo->buat($request->body());

    return Response::json(
        ['id' => $tugas['id'], 'judul' => $tugas['judul'], 'status' => $tugas['status']],
        201,
    );
});

$router->add('GET', '/api/tugas/{id}', static function (Request $request, array $params) use ($repo): Response {
    $tugas = $repo->cariId((int) $params['id']);

    if ($tugas === null) {
        return Response::json(ErrorHandler::payload([
            'code' => 'NOT_FOUND',
            'message' => sprintf('Tugas dengan id %s tidak ditemukan.', $params['id']),
        ]), 404);
    }

    return Response::json($tugas);
});

function tampilkan(Router $router, array $data): void
{
    $response = $router->dispatch(Request::fromArray($data));
    printf('%s %s -> %d', $data['method'], $data['path'], $response->status());
    if ($response->body() !== '') {
        echo ' ', $response->body();
    }
    echo "\n";
}

tampilkan($router, ['method' => 'POST', 'path' => '/api/tugas', 'body' => ['judul' => 'Belajar SQLite', 'status' => 'belum']]);
tampilkan($router, ['method' => 'POST', 'path' => '/api/tugas', 'body' => ['judul' => 'a', 'status' => 'batal']]);
tampilkan($router, ['method' => 'POST', 'path' => '/api/tugas', 'body' => []]);
tampilkan($router, ['method' => 'GET', 'path' => '/api/tugas/1']);
tampilkan($router, ['method' => 'GET', 'path' => '/api/tugas/99']);

Output yang Dihasilkan:

text
POST /api/tugas -> 201 {"id":4,"judul":"Belajar SQLite","status":"belum"}
POST /api/tugas -> 422 {"error":{"code":"VALIDATION_ERROR","message":"Data yang dikirim tidak valid.","details":{"judul":["Kolom 'judul' minimal 3 karakter."],"status":["Kolom 'status' harus salah satu dari: belum, proses, selesai."]}}}
POST /api/tugas -> 422 {"error":{"code":"VALIDATION_ERROR","message":"Data yang dikirim tidak valid.","details":{"judul":["Kolom 'judul' wajib diisi."]}}}
GET /api/tugas/1 -> 200 {"id":1,"judul":"Belajar PHP dasar","status":"selesai","dibuat_pada":"2026-01-05 08:00:00"}
GET /api/tugas/99 -> 404 {"error":{"code":"NOT_FOUND","message":"Tugas dengan id 99 tidak ditemukan."}}

Alur lima baris hasil tersebut adalah cerita utama Bagian 4:

  1. POST dengan data lengkap lolos validasi, disimpan repository, dan dijawab 201 Created berisi tugas baru (id 4 karena tiga seed data menempati id 1-3).
  2. POST dengan judul terlalu pendek dan status asing ditolak 422 — keduanya dilaporkan sekaligus dalam details, bukan satu per satu.
  3. POST dengan body kosong hanya melanggar required judul; kolom status tidak ikut dituduh karena boleh tidak dikirim (repository mengisinya 'belum').
  4. GET tugas yang ada mengembalikan baris repository apa adanya — kolom-kolomnya memang dirancang JSON-ready.
  5. GET tugas yang tidak ada menjawab 404 dengan bentuk error yang sama seperti error routing — klien cukup memprogram satu parser.

Front Controller dengan Penanganan Error #

Front controller kini memasang repository, mendaftarkan seluruh endpoint CRUD, dan membungkus dispatch dalam try/catch. Endpoint /api/meledak sengaja dilempar sebagai peraga bug; bendera $dev dibaca dari variabel lingkungan APP_ENV:

php
<?php
declare(strict_types=1);

$projectRoot = dirname(__DIR__);

if (is_file($projectRoot . '/vendor/autoload.php')) {
    require $projectRoot . '/vendor/autoload.php';
} else {
    spl_autoload_register(static function (string $class): void {
        $prefix = 'App\\';

        if (!str_starts_with($class, $prefix)) {
            return;
        }

        $relative = str_replace('\\', '/', substr($class, strlen($prefix)));
        $file = dirname(__DIR__) . '/src/' . $relative . '.php';

        if (is_file($file)) {
            require $file;
        }
    });
}

use App\Database;
use App\ErrorHandler;
use App\Request;
use App\Response;
use App\Router;
use App\TaskRepository;
use App\Validator;

if (PHP_SAPI === 'cli' && !isset($_SERVER['REQUEST_METHOD'])) {
    echo "REST API Manajemen Tugas\n";
    echo "Jalankan: TASK_API_DB=/tmp/tasks.sqlite php -S 127.0.0.1:8090 -t public\n";
    exit(0);
}

$dev = (getenv('APP_ENV') ?: 'development') === 'development';

$pdo = Database::buat();
$adaTabel = (int) $pdo->query(
    "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'tasks'",
)->fetchColumn();
if ($adaTabel === 0) {
    Database::migrasi($pdo, $projectRoot . '/database/schema.sql');
}
$repo = new TaskRepository($pdo);

$router = new Router();

$router->add('GET', '/api/kesehatan', static fn (Request $r): Response => Response::json(['status' => 'ok']));

$router->add('GET', '/api/tugas', static function (Request $r) use ($repo): Response {
    return Response::json($repo->semua());
});

$router->add('GET', '/api/tugas/{id}', static function (Request $r, array $params) use ($repo): Response {
    $tugas = $repo->cariId((int) $params['id']);
    if ($tugas === null) {
        return Response::json(ErrorHandler::payload([
            'code' => 'NOT_FOUND',
            'message' => sprintf('Tugas dengan id %s tidak ditemukan.', $params['id']),
        ]), 404);
    }

    return Response::json($tugas);
});

$router->add('POST', '/api/tugas', static function (Request $r) use ($repo): Response {
    $errors = (new Validator())
        ->required('judul')
        ->min('judul', 3)
        ->max('judul', 100)
        ->in('status', ['belum', 'proses', 'selesai'])
        ->validate($r->body());

    if ($errors !== []) {
        return Response::json(ErrorHandler::payload($errors), 422);
    }

    $tugas = $repo->buat($r->body());

    return Response::json($tugas, 201);
});

$router->add('PUT', '/api/tugas/{id}', static function (Request $r, array $params) use ($repo): Response {
    $errors = (new Validator())
        ->min('judul', 3)
        ->max('judul', 100)
        ->in('status', ['belum', 'proses', 'selesai'])
        ->validate($r->body());

    if ($errors !== []) {
        return Response::json(ErrorHandler::payload($errors), 422);
    }

    $tugas = $repo->perbarui((int) $params['id'], $r->body());
    if ($tugas === null) {
        return Response::json(ErrorHandler::payload([
            'code' => 'NOT_FOUND',
            'message' => sprintf('Tugas dengan id %s tidak ditemukan.', $params['id']),
        ]), 404);
    }

    return Response::json($tugas);
});

$router->add('DELETE', '/api/tugas/{id}', static function (Request $r, array $params) use ($repo): Response {
    if (!$repo->hapus((int) $params['id'])) {
        return Response::json(ErrorHandler::payload([
            'code' => 'NOT_FOUND',
            'message' => sprintf('Tugas dengan id %s tidak ditemukan.', $params['id']),
        ]), 404);
    }

    return Response::noContent();
});

// Endpoint peraga bug untuk melihat penanganan exception (hapus di produksi).
$router->add('GET', '/api/meledak', static function (): Response {
    throw new RuntimeException('Bug demo: pembagian dengan nol di modul laporan.');
});

try {
    $router->dispatch(Request::fromGlobals())->send();
} catch (Throwable $e) {
    http_response_code(500);
    header('Content-Type: application/json; charset=utf-8');
    echo ErrorHandler::toJson($e, 500, $dev);
}

Tiga hal baru dibandingkan Bagian 2 patut digarisbawahi. Pertama, koneksi kini memakai database berkas lewat TASK_API_DB — tanpa variabel tersebut Database::buat() jatuh ke in-memory yang kehilangan seluruh datanya antar request, sebagaimana dibuktikan di Bagian 3. Kedua, migrasi dijalankan otomatis hanya bila tabel tasks belum ada (diperiksa lewat sqlite_master), jadi request pertama pada database kosong langsung menyiapkan skema dan seed data. Ketiga, catch (Throwable $e) menjadi jaring terakhir: exception apa pun yang lolos dari handler dijawab 500 berbentuk JSON, bukan halaman error HTML mentah.

Menguji Error Response dengan php -S #

Karena API kini menyimpan data, pengujian memakai database berkas. Jalankan server dengan TASK_API_DB menunjuk ke berkas (baru dihapus dulu bila perlu), lalu biarkan migrasi otomatis bekerja pada request pertama:

bash
# terminal 1 — dari direktori root proyek
$ rm -f /tmp/tasks.sqlite
$ TASK_API_DB=/tmp/tasks.sqlite php -S 127.0.0.1:8094 -t public

# terminal 2 — data valid lolos dan tersimpan
$ curl -sS -i -X POST -H 'Content-Type: application/json' \
    -d '{"judul":"Belajar SQLite","status":"belum"}' http://127.0.0.1:8094/api/tugas
text
HTTP/1.1 201 Created
Host: 127.0.0.1:8094
Date: Tue, 13 Oct 2026 21:08:43 GMT
Connection: close
X-Powered-By: PHP/8.4.23
Content-Type: application/json; charset=utf-8

{"id":4,"judul":"Belajar SQLite","status":"belum","dibuat_pada":"2026-10-13 21:08:43"}

Ganti isinya dengan data bermasalah, dan validator menolak dengan 422 beserta rincian per kolom:

bash
$ curl -sS -i -X POST -H 'Content-Type: application/json' \
    -d '{"judul":"a","status":"batal"}' http://127.0.0.1:8094/api/tugas
text
HTTP/1.1 422 Unknown Status Code
Host: 127.0.0.1:8094
Date: Tue, 13 Oct 2026 21:08:43 GMT
Connection: close
X-Powered-By: PHP/8.4.23
Content-Type: application/json; charset=utf-8

{"error":{"code":"VALIDATION_ERROR","message":"Data yang dikirim tidak valid.","details":{"judul":["Kolom 'judul' minimal 3 karakter."],"status":["Kolom 'status' harus salah satu dari: belum, proses, selesai."]}}}

Tugas yang tidak ditemukan dijawab 404 dengan bentuk error yang sama:

bash
$ curl -sS -i http://127.0.0.1:8094/api/tugas/99
text
HTTP/1.1 404 Not Found
Host: 127.0.0.1:8094
Date: Tue, 13 Oct 2026 21:08:43 GMT
Connection: close
X-Powered-By: PHP/8.4.23
Content-Type: application/json; charset=utf-8

{"error":{"code":"NOT_FOUND","message":"Tugas dengan id 99 tidak ditemukan."}}

Terakhir, dua versi response 500 dari endpoint peraga /api/meledak. Mode dev menampilkan detail teknis; mode produksi (server dijalankan ulang dengan APP_ENV=production) merahasiakannya:

bash
$ curl -sS http://127.0.0.1:8094/api/meledak    # TASK_API_DB=... APP_ENV=development
text
{"error":{"code":"INTERNAL_ERROR","message":"Bug demo: pembagian dengan nol di modul laporan.","details":{"exception":"RuntimeException","berkas":"index.php","baris":122}}}
bash
$ curl -sS http://127.0.0.1:8094/api/meledak    # server dijalankan ulang: APP_ENV=production
text
{"error":{"code":"INTERNAL_ERROR","message":"Terjadi kesalahan internal. Silakan coba lagi nanti."}}

Baris Date serta kolom dibuat_pada pada contoh di atas mengikuti waktu saat Anda mencobanya; angka baris merujuk baris throw pada listing front controller di halaman ini.

Apa yang Berubah #

Dua kelas baru dan front controller naik versi pada tahap ini:

  • src/Validator.php — aturan required, min, max, dan in yang didaftarkan berantai; validate(array) mengembalikan peta kolom ke daftar pesan, array kosong berarti lolos.
  • src/ErrorHandler.php — payload() dan toJson() yang memusatkan bentuk {"error": {"code", "message", "details"?}} untuk tiga sumber: peta error validator (422), spesifikasi error seperti NOT_FOUND (404), dan Throwable (500) dengan mode dev/produksi.
  • public/index.php — kini memasang repository, seluruh endpoint CRUD tugas (list, detail, buat, ubah, hapus), migrasi otomatis, serta try/catch penutup yang mengubah exception menjadi 500 berbentuk JSON.

API sudah fungsional, tetapi masih terbuka untuk siapa saja. Bagian 5 menambahkan authentication berbasis token dan authorization sederhana sehingga hanya klien yang sah yang boleh membuat atau mengubah tugas.

Cara Menjalankan #

Skenario lengkap dari CLI dan dari web server:

bash
# 1. Handler flow dari CLI — salin blok "Merangkai Handler Create dan Read"
#    ke berkas contoh-handler.php di dalam folder bagian ini, lalu jalankan
php contoh-handler.php

# 2. Web server dengan database berkas (data bertahan antar request)
rm -f /tmp/tasks.sqlite
TASK_API_DB=/tmp/tasks.sqlite php -S 127.0.0.1:8094 -t public
curl -sS http://127.0.0.1:8094/api/tugas

# 3. Mode produksi — detail internal dirahasiakan
TASK_API_DB=/tmp/tasks.sqlite APP_ENV=production php -S 127.0.0.1:8094 -t public

# 4. Pemeriksaan sintaks kelas baru
php -l src/Validator.php && php -l src/ErrorHandler.php

Dua berkas pendukung halaman ini — src/Validator.php dan src/ErrorHandler.php — tersedia di bagian bawah halaman. Untuk pendalaman, baca dokumentasi resmi tentang Throwable dan hierarki error PHP , kelas Exception , serta json_encode() . Fondasi keamanan input sudah berdiri; kita lanjut mengunci pintunya dengan token pada Bagian 5.

Berkas Sumber

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

src/ErrorHandler.php

Unduh berkas
php
<?php
declare(strict_types=1);

namespace App;

use Throwable;

/**
 * Pembangun response error dengan bentuk yang konsisten.
 *
 * Seluruh error API ini memakai satu kerangka JSON:
 *
 *     {"error": {"code": "...", "message": "...", "details": ...}}
 *
 * Kunci details hanya muncul bila ada informasi tambahan. Ada tiga sumber
 * error yang dikenali:
 *
 *  1. Throwable                  -> error internal (500); detail teknis
 *                                    hanya disertakan pada mode dev agar
 *                                    production tidak membocorkan jejak
 *                                    berkas dan pesan exception.
 *  2. array hasil Validator      -> VALIDATION_ERROR (422) dengan details
 *                                    berupa peta kolom ke daftar pesan.
 *  3. array spesifikasi error    -> pasangan 'code' + 'message' (+details)
 *                                    siap pakai, mis. NOT_FOUND (404).
 */
final class ErrorHandler
{
    /**
     * Bangun payload error sebagai array PHP.
     *
     * @param Throwable|array<string, mixed> $sumber exception, peta error
     *        Validator, atau array spesifikasi ['code' => ..., 'message' => ...].
     */
    public static function payload(Throwable|array $sumber, bool $dev = true): array
    {
        if ($sumber instanceof Throwable) {
            $error = ['code' => 'INTERNAL_ERROR'];

            if ($dev) {
                $error['message'] = $sumber->getMessage();
                $error['details'] = [
                    'exception' => $sumber::class,
                    'berkas' => basename($sumber->getFile()),
                    'baris' => $sumber->getLine(),
                ];
            } else {
                $error['message'] = 'Terjadi kesalahan internal. Silakan coba lagi nanti.';
            }

            return ['error' => $error];
        }

        if (isset($sumber['code'], $sumber['message'])
            && is_string($sumber['code'])
            && is_string($sumber['message'])) {
            // Spesifikasi error siap pakai, mis. dari handler 404.
            $error = ['code' => $sumber['code'], 'message' => $sumber['message']];

            if (isset($sumber['details'])) {
                $error['details'] = $sumber['details'];
            }

            return ['error' => $error];
        }

        // Peta error hasil Validator -> validation error dengan details.
        return [
            'error' => [
                'code' => 'VALIDATION_ERROR',
                'message' => 'Data yang dikirim tidak valid.',
                'details' => $sumber,
            ],
        ];
    }

    /**
     * Bangun body JSON error siap kirim.
     *
     * @param Throwable|array<string, mixed> $sumber
     */
    public static function toJson(Throwable|array $sumber, int $status = 500, bool $dev = true): string
    {
        return (string) json_encode(
            self::payload($sumber, $dev),
            JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
        );
    }
}

src/Validator.php

Unduh berkas
php
<?php
declare(strict_types=1);

namespace App;

/**
 * Validator deklaratif untuk input body JSON.
 *
 * Aturan didaftarkan secara berantai (fluent) lalu dijalankan terhadap
 * array data dengan validate(). Hasilnya berupa peta error per kolom:
 *
 *     $validator = (new Validator())
 *         ->required('judul')
 *         ->max('judul', 100)
 *         ->in('status', ['belum', 'proses', 'selesai']);
 *
 *     $errors = $validator->validate($data);   // [] berarti lolos
 *
 * Aturan required gagal bila kolom tidak ada, null, atau string kosong.
 * Aturan lain hanya memeriksa nilai yang ada isinya, sehingga kolom
 * opsional tetap boleh dikosongkan.
 */
final class Validator
{
    /**
     * Aturan per kolom, terurut sesuai urutan pendeklarasian.
     *
     * @var array<string, list<array{aturan: string, param: mixed}>>
     */
    private array $rules = [];

    /**
     * Kolom wajib diisi.
     */
    public function required(string $kolom): self
    {
        return $this->tambah($kolom, 'required', null);
    }

    /**
     * Panjang teks minimal N karakter (menghitung multibyte).
     */
    public function min(string $kolom, int $min): self
    {
        return $this->tambah($kolom, 'min', $min);
    }

    /**
     * Panjang teks maksimal N karakter (menghitung multibyte).
     */
    public function max(string $kolom, int $max): self
    {
        return $this->tambah($kolom, 'max', $max);
    }

    /**
     * Nilai harus salah satu dari daftar yang diizinkan.
     *
     * @param list<string> $diizinkan
     */
    public function in(string $kolom, array $diizinkan): self
    {
        return $this->tambah($kolom, 'in', $diizinkan);
    }

    /**
     * Jalankan semua aturan terhadap data.
     *
     * @param array<string, mixed> $data
     *
     * @return array<string, list<string>> peta kolom ke daftar pesan error;
     *                                      array kosong berarti data valid.
     */
    public function validate(array $data): array
    {
        $errors = [];

        foreach ($this->rules as $kolom => $daftarAturan) {
            $nilai = $data[$kolom] ?? null;
            $kosong = $nilai === null || (is_string($nilai) && trim($nilai) === '');

            foreach ($daftarAturan as $aturan) {
                $pesan = $this->periksa($kolom, $aturan['aturan'], $aturan['param'], $nilai, $kosong);
                if ($pesan !== null) {
                    $errors[$kolom][] = $pesan;
                }
            }
        }

        return $errors;
    }

    /**
     * @param mixed $param
     */
    private function tambah(string $kolom, string $aturan, mixed $param): self
    {
        $this->rules[$kolom][] = ['aturan' => $aturan, 'param' => $param];

        return $this;
    }

    /**
     * Periksa satu aturan; kembalikan pesan error atau null bila lolos.
     *
     * @param mixed $param
     * @param mixed $nilai
     */
    private function periksa(string $kolom, string $aturan, mixed $param, mixed $nilai, bool $kosong): ?string
    {
        if ($aturan === 'required') {
            if ($kosong) {
                return sprintf("Kolom '%s' wajib diisi.", $kolom);
            }

            return null;
        }

        // Aturan selain required mengabaikan nilai yang kosong.
        if ($kosong) {
            return null;
        }

        $teks = is_string($nilai) ? $nilai : (string) $nilai;

        switch ($aturan) {
            case 'min':
                if (mb_strlen($teks) < $param) {
                    return sprintf("Kolom '%s' minimal %d karakter.", $kolom, $param);
                }
                break;
            case 'max':
                if (mb_strlen($teks) > $param) {
                    return sprintf("Kolom '%s' maksimal %d karakter.", $kolom, $param);
                }
                break;
            case 'in':
                if (!in_array($teks, $param, true)) {
                    return sprintf(
                        "Kolom '%s' harus salah satu dari: %s.",
                        $kolom,
                        implode(', ', $param),
                    );
                }
                break;
        }

        return null;
    }
}