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
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:
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
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:
{"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
// 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:
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:
POSTdengan data lengkap lolos validasi, disimpan repository, dan dijawab201 Createdberisi tugas baru (id4karena tiga seed data menempati id 1-3).POSTdengan judul terlalu pendek dan status asing ditolak422— keduanya dilaporkan sekaligus dalamdetails, bukan satu per satu.POSTdengan body kosong hanya melanggarrequiredjudul; kolomstatustidak ikut dituduh karena boleh tidak dikirim (repository mengisinya'belum').GETtugas yang ada mengembalikan baris repository apa adanya — kolom-kolomnya memang dirancang JSON-ready.GETtugas yang tidak ada menjawab404dengan 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
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:
# 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/tugasHTTP/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:
$ curl -sS -i -X POST -H 'Content-Type: application/json' \
-d '{"judul":"a","status":"batal"}' http://127.0.0.1:8094/api/tugasHTTP/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:
$ curl -sS -i http://127.0.0.1:8094/api/tugas/99HTTP/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:
$ curl -sS http://127.0.0.1:8094/api/meledak # TASK_API_DB=... APP_ENV=development{"error":{"code":"INTERNAL_ERROR","message":"Bug demo: pembagian dengan nol di modul laporan.","details":{"exception":"RuntimeException","berkas":"index.php","baris":122}}}$ curl -sS http://127.0.0.1:8094/api/meledak # server dijalankan ulang: APP_ENV=production{"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— aturanrequired,min,max, daninyang didaftarkan berantai;validate(array)mengembalikan peta kolom ke daftar pesan, array kosong berarti lolos.src/ErrorHandler.php—payload()dantoJson()yang memusatkan bentuk{"error": {"code", "message", "details"?}}untuk tiga sumber: peta error validator (422), spesifikasi error sepertiNOT_FOUND(404), danThrowable(500) dengan mode dev/produksi.public/index.php— kini memasang repository, seluruh endpoint CRUD tugas (list, detail, buat, ubah, hapus), migrasi otomatis, sertatry/catchpenutup 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:
# 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.phpDua 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
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
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;
}
}