Blog kita kini bisa menyimpan artikel, tetapi satu-satunya cara menulis masih menyunting SQL dengan tangan — jauh dari layak untuk pengguna non-teknis. Pada Bagian 4 ini kita menutup lingkaran CRUD (Create, Read, Update, Delete) dengan panel admin: layar daftar artikel, form tulis, form ubah, dan penghapusan. Dua keterampilan baru masuk bersamaan: pemrosesan form HTML (membaca $_POST, memvalidasi, mengisi ulang nilai lama saat validasi gagal) dan pengelolaan request yang mengubah data lewat pola Post/Redirect/Get. Panel ini sengaja belum memiliki authentication dan authorization — siapa pun yang bisa mengakses /admin bisa mengubah blog — karena keduanya adalah pokok bahasan tersendiri pada Bagian 5.
Rancangan Panel Admin #
Layar admin kita petakan menjadi lima rute. Prinsipnya satu: setiap operasi yang mengubah data hanya boleh dipicu lewat POST, bukan tautan GET biasa — request GET diasumsikan aman untuk diulang, dibagikan, dan di-crawl oleh robot.
| Method | Path | Aksi |
|---|---|---|
| GET | /admin | daftar seluruh artikel + tombol ubah/hapus |
| GET | /admin/tulis | form kosong untuk artikel baru |
| POST | /admin/tulis | validasi lalu simpan artikel baru |
| GET | /admin/edit/{id} | form terisi data lama |
| POST | /admin/edit/{id} | validasi lalu simpan perubahan |
| POST | /admin/hapus | hapus artikel berdasarkan id dari form |
Alur simpan yang sehat mengikuti pola Post/Redirect/Get (PRG): POST yang berhasil tidak merender halaman apa pun, melainkan menjawab 303 See Other berisi Location. Browser lalu mengikuti redirect itu dengan GET — dan karena GET tidak mengubah apa pun, pengguna yang menekan tombol refresh tidak mengirim ulang datanya. Bandingkan dengan merender daftar langsung dari POST: menekan refresh akan memunculkan dialog “kirim ulang form?” dan berpotensi membuat artikel kembar.
Controller Murni: Request dan Response sebagai Nilai #
Seluruh rute di atas ditangani satu class, AdminController. Kontraknya berbeda dari handler Bagian 1-3: request datang sebagai array bentukan, dan setiap method mengembalikan array [status, headers, body] — bukan mencetak, bukan memanggil header(). Berikut kepala class beserta dispatcher-nya:
<?php
declare(strict_types=1);
namespace App;
use PDOException;
/**
* AdminController — menangani seluruh rute panel admin (/admin...).
*
* Setiap request diwakili array bentukan:
*
* ['method' => 'GET'|'POST', 'path' => '/admin/...', 'query' => [...], 'post' => [...]]
*
* dan setiap method mengembalikan respons terstruktur:
*
* [status, headers, body]
*
* Struktur ini membuat controller murni: tidak ada echo, tidak ada
* header() yang dikirim dari dalam — front controller yang menerapkan
* ketiganya. Karena itu seluruh method bisa diuji dari skrip CLI
* dengan request tiruan, tanpa web server.
*
* CATATAN: panel ini sengaja BELUM memiliki authentication dan
* authorization — keduanya dibangun pada Bagian 5.
*/
final class AdminController
{
public function __construct(private readonly PostRepository $posts)
{
}
/**
* Dispatcher: petakan [method, path] ke method yang tepat.
*
* @param array<string,mixed> $request
*
* @return array{0: int, 1: array<string,string>, 2: string}
*/
public function jalankan(array $request): array
{
$path = (string) ($request['path'] ?? '/admin');
$method = strtoupper((string) ($request['method'] ?? 'GET'));
if ($path === '/admin' && $method === 'GET') {
return $this->index($request);
}
if ($path === '/admin/tulis' && $method === 'GET') {
return $this->tulis($request);
}
if ($path === '/admin/tulis' && $method === 'POST') {
return $this->simpan($request);
}
if (preg_match('#^/admin/edit/(?<id>\d+)$#', $path, $cocok) === 1) {
$request['id'] = (int) $cocok['id'];
return $this->edit($request);
}
if ($path === '/admin/hapus' && $method === 'POST') {
return $this->hapus($request);
}
return $this->tidakDitemukan($path);
}
}Dispatcher ini kebetulan mirip dispatch() front controller Bagian 1 — tabel keputusan berbentuk rangkaian kondisi — hanya saja kini method (GET/POST) ikut menentukan cabang. Pola #^/admin/edit/(?<id>\d+)$# menangkap id numerik dari path, dan id yang tersangkut disuntikkan ke $request['id'] supaya method edit() menerima satu bentuk data yang seragam. Constructor-nya menerima PostRepository lewat parameter — controller tidak membuka koneksi database sendiri, ia hanya memakai method semua(), cariId(), simpan(), dan hapus() yang sudah teruji pada Bagian 3.
Method index(), tulis(), dan edit() lalu hanya merakit data lalu meneruskannya ke template — misalnya index() merender posts.php dengan 'posts' => $this->posts->semua() dan 'flash' => $request['query']['flash'] ?? ''. Jantung pemrosesan form ada di method privat bersama yang dipakai jalur tulis maupun ubah:
<?php
declare(strict_types=1);
namespace App;
use PDOException;
final class AdminController
{
// ... constructor, jalankan(), index(), tulis(), edit() ...
/**
* Alur simpan bersama untuk artikel baru ($id null) dan perubahan
* artikel lama: validasi -> simpan -> redirect, atau form ulang.
*
* @param array<string,mixed> $data isi $_POST
*
* @return array{0: int, 1: array<string,string>, 2: string}
*/
private function simpanAtauForm(array $data, ?int $id): array
{
[$post, $galat] = $this->validasi($data);
$aksi = $id === null ? '/admin/tulis' : '/admin/edit/' . $id;
$mode = $id === null ? 'tulis' : 'ubah';
if ($galat === []) {
try {
$idTersimpan = $this->posts->simpan(
$id === null ? $post : $post + ['id' => $id]
);
$jenis = $id === null ? 'simpan' : 'ubah';
return $this->redirect("/admin?flash={$jenis}&id={$idTersimpan}");
} catch (PDOException $galatDb) {
$galat['slug'] = str_contains($galatDb->getMessage(), 'posts.slug')
? 'Slug sudah dipakai artikel lain.'
: 'Penyimpanan gagal: ' . $galatDb->getMessage();
}
}
return $this->halaman(
$this->renderAdmin('post-form.php', [
'post' => $post,
'galat' => $galat,
'aksi' => $aksi,
'mode' => $mode,
]),
422
);
}
/**
* Validasi input form.
*
* @param array<string,mixed> $data
*
* @return array{0: array<string,string>, 1: array<string,string>} [post bersih, galat]
*/
private function validasi(array $data): array
{
$post = [
'slug' => trim((string) ($data['slug'] ?? '')),
'judul' => trim((string) ($data['judul'] ?? '')),
'konten' => trim((string) ($data['konten'] ?? '')),
'dibuat_pada' => trim((string) ($data['dibuat_pada'] ?? '')),
];
$galat = [];
if ($post['judul'] === '') {
$galat['judul'] = 'Judul wajib diisi.';
}
if ($post['slug'] === '') {
$galat['slug'] = 'Slug wajib diisi.';
} elseif (preg_match('/^[a-z0-9-]+$/', $post['slug']) !== 1) {
$galat['slug'] = 'Slug hanya boleh huruf kecil, angka, dan tanda hubung.';
}
if ($post['konten'] === '') {
$galat['konten'] = 'Konten wajib diisi.';
}
if ($post['dibuat_pada'] === '') {
$post['dibuat_pada'] = date('Y-m-d H:i:s'); // diisi saat disimpan
}
return [$post, $galat];
}
}Alurnya bercabang jelas. validasi() selalu mengembalikan dua hal: nilai yang sudah dibersihkan (trim pada semua field) dan peta galat per kolom — memvalidasi sekaligus membersihkan di satu tempat. Bila peta galat kosong, simpan() dari repository dipanggil (array $post + ['id' => $id] menambahkan id hanya pada mode ubah) dan hasilnya adalah redirect 303 membawa kode flash. Bila galat ada — termasuk galat database yang tertangkap belakangan — form dirender ulang dengan status 422, membawa nilai bersih dan peta galat.
Perhatikan catch (PDOException): constraint UNIQUE pada kolom slug (Bagian 3) melempar exception dengan pesan yang menyebut posts.slug; kita terjemahkan menjadi pesan manusiawi di sisi slug. Galat database lain diteruskan apa adanya — masih kasar, tetapi jujur; pembenahan pesan galat menyeluruh menjadi tugas Bagian 6.
Dua helper terakhir menutup class. redirect() adalah rumus PRG dalam satu baris, sedangkan renderAdmin() adalah kembar render() dari Bagian 1 yang memuat template dari folder templates/admin/:
<?php
declare(strict_types=1);
namespace App;
final class AdminController
{
// ... method-method di atas ...
/** @return array{0: int, 1: array<string,string>, 2: string} */
private function redirect(string $lokasi): array
{
return [303, ['Location' => $lokasi], ''];
}
}Dua Layar Admin #
Layar daftar (templates/admin/posts.php) menampilkan tabel artikel dengan dua aksi per baris. Bagian pentingnya ada di kolom aksi: tautan “Ubah” boleh berupa GET, tetapi “Hapus” berupa form POST kecil yang menyembunyikan id artikel:
<?php
/**
* templates/admin/posts.php — halaman utama admin: daftar artikel.
*
* Menerima $posts (array dari PostRepository::semua()) dan $flash
* (string kosong atau salah satu kunci di $petaFlash). Nilai bawaan
* membuat berkas ini aman dijalankan langsung tanpa data.
*/
if (!function_exists('e')) {
/** Escape teks untuk HTML — dipakai di semua template. */
function e(?string $nilai): string
{
return htmlspecialchars($nilai ?? '', ENT_QUOTES, 'UTF-8');
}
}
$posts ??= [];
$flash ??= '';
$petaFlash = [
'simpan' => 'Artikel baru berhasil disimpan.',
'ubah' => 'Perubahan artikel berhasil disimpan.',
'hapus' => 'Artikel berhasil dihapus.',
'tak-ditemukan' => 'Artikel yang diminta tidak ditemukan.',
];
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<title>Admin — Daftar Artikel</title>
</head>
<body>
<header>
<strong>Panel Admin</strong>
<nav>
<a href="/admin">Artikel</a> |
<a href="/admin/tulis">Tulis Baru</a> |
<a href="/">Lihat Blog</a>
</nav>
</header>
<main>
<?php if (isset($petaFlash[$flash])): ?>
<p class="flash"><?= e($petaFlash[$flash]) ?></p>
<?php endif; ?>
<h1>Daftar Artikel (<?= count($posts) ?>)</h1>
<?php if ($posts === []): ?>
<p>Belum ada artikel. <a href="/admin/tulis">Tulis artikel pertama</a>.</p>
<?php else: ?>
<table border="1">
<tr>
<th>Judul</th>
<th>Slug</th>
<th>Diterbitkan</th>
<th>Aksi</th>
</tr>
<?php foreach ($posts as $post): ?>
<tr>
<td><?= e($post['judul'] ?? '') ?></td>
<td><code><?= e($post['slug'] ?? '') ?></code></td>
<td><?= e($post['dibuat_pada'] ?? '') ?></td>
<td>
<a href="/admin/edit/<?= (int) ($post['id'] ?? 0) ?>">Ubah</a>
<form method="post" action="/admin/hapus" style="display: inline">
<input type="hidden" name="id" value="<?= (int) ($post['id'] ?? 0) ?>">
<button type="submit">Hapus</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
</main>
</body>
</html>
Pesan sukses tidak dikirim lewat sesi atau cookie, melainkan lewat query string ?flash=simpan yang ditambahkan redirect, lalu diterjemahkan menjadi kalimat oleh $petaFlash di template. Sederhana, tanpa keadaan tersimpan, dan cukup untuk proyek ini.
Layar kedua (templates/admin/post-form.php) adalah form tulis sekaligus form ubah — perbedaannya hanya pada data awal dan tujuan submit. Bagian intinya:
<?php
/**
* templates/admin/post-form.php — form tulis artikel baru dan ubah lama.
*
* Menerima empat variabel:
* - $post : array nilai form (data lama saat mode ubah, hasil validasi
* yang gagal, atau null saat form kosong),
* - $galat : map nama kolom => pesan galat,
* - $aksi : URL tujuan submit form,
* - $mode : 'tulis' atau 'ubah' (menentukan judul halaman).
*
* Helper old() mengisi ulang nilai input dari $post dengan aman;
* nilai bawaan membuat berkas ini aman dijalankan langsung.
*/
if (!function_exists('e')) {
/** Escape teks untuk HTML — dipakai di semua template. */
function e(?string $nilai): string
{
return htmlspecialchars($nilai ?? '', ENT_QUOTES, 'UTF-8');
}
}
if (!function_exists('old')) {
/** Nilai lama untuk mengisi ulang form setelah validasi gagal. */
function old(?array $post, string $kunci): string
{
return e((string) (($post ?? [])[$kunci] ?? ''));
}
}
$post ??= null;
$galat ??= [];
$aksi ??= '/admin/tulis';
$mode ??= 'tulis';
$judulHalaman = $mode === 'ubah' ? 'Ubah Artikel' : 'Tulis Artikel Baru';
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<title>Admin — <?= e($judulHalaman) ?></title>
</head>
<body>
<header>
<strong>Panel Admin</strong>
<nav>
<a href="/admin">Artikel</a> |
<a href="/admin/tulis">Tulis Baru</a> |
<a href="/">Lihat Blog</a>
</nav>
</header>
<main>
<h1><?= e($judulHalaman) ?></h1>
<form method="post" action="<?= e($aksi) ?>">
<p>
<label for="judul">Judul</label><br>
<input type="text" id="judul" name="judul" size="60" value="<?= old($post, 'judul') ?>">
<?php if (isset($galat['judul'])): ?>
<br><small class="galat"><?= e($galat['judul']) ?></small>
<?php endif; ?>
</p>
<p>
<label for="slug">Slug (huruf kecil, angka, tanda hubung)</label><br>
<input type="text" id="slug" name="slug" size="60" value="<?= old($post, 'slug') ?>">
<?php if (isset($galat['slug'])): ?>
<br><small class="galat"><?= e($galat['slug']) ?></small>
<?php endif; ?>
</p>
Tiga field berikutnya (konten berupa <textarea>, dibuat_pada yang boleh dikosongkan, lalu tombol submit) mengikuti pola yang sama persis; berkas lengkapnya tersedia di bagian bawah halaman. Helper old() adalah bintang kecil di template ini: ia mengambil nilai lama dari $post dengan aman (?? '' untuk kolom yang hilang) sekaligus meng-escape-nya — perhatikan bahwa hasilnya dipakai di dalam atribut value="...", tempat tanda kutip harus menjadi " agar tidak memutus atribut HTML.
Menguji Controller Tanpa Web Server #
Karena AdminController murni mengolah nilai, seluruh alurnya bisa diuji dari CLI — termasuk redirect dan render ulang form. Folder bagian ini hanya memuat berkas baru (controller dan dua template admin), jadi class dari Bagian 3 dimuat lewat path folder saudara:
<?php
require __DIR__ . '/../bagian-3-database-dan-persistensi/src/Database.php';
require __DIR__ . '/../bagian-3-database-dan-persistensi/src/PostRepository.php';
require __DIR__ . '/src/AdminController.php';
use App\AdminController;
use App\Database;
use App\PostRepository;
$admin = new AdminController(new PostRepository(Database::siapkan(':memory:')));
$permintaan = [
['method' => 'GET', 'path' => '/admin'],
['method' => 'GET', 'path' => '/admin/tulis'],
['method' => 'GET', 'path' => '/admin/edit/2'],
['method' => 'GET', 'path' => '/admin/halusinasi'],
];
foreach ($permintaan as $p) {
[$status, $headers, $body] = $admin->jalankan($p);
printf("%-5s %-22s -> %d (%d byte body)\n", $p['method'], $p['path'], $status, strlen($body));
}Output yang Dihasilkan:
GET /admin -> 200 (2278 byte body)
GET /admin/tulis -> 200 (1473 byte body)
GET /admin/edit/2 -> 200 (1806 byte body)
GET /admin/halusinasi -> 404 (76 byte body)Tiga layar pertama merender HTML penuh; path yang tidak dikenal mendapat 404 dari controller sendiri. Sekarang alur simpan — dua POST dengan nasib berbeda:
<?php
require __DIR__ . '/../bagian-3-database-dan-persistensi/src/Database.php';
require __DIR__ . '/../bagian-3-database-dan-persistensi/src/PostRepository.php';
require __DIR__ . '/src/AdminController.php';
use App\AdminController;
use App\Database;
use App\PostRepository;
$posts = new PostRepository(Database::siapkan(':memory:'));
$admin = new AdminController($posts);
// 1. POST valid -> 303 redirect (pola Post/Redirect/Get).
[$status, $headers] = $admin->jalankan([
'method' => 'POST',
'path' => '/admin/tulis',
'post' => [
'judul' => 'Belajar SQLite dari Nol',
'slug' => 'belajar-sqlite-dari-nol',
'konten' => 'SQLite adalah database berkas tunggal.',
'dibuat_pada' => '2026-01-22 10:00:00',
],
]);
echo "POST valid : status=", $status, " Location=", $headers['Location'] ?? '-', "\n";
// Data benar-benar tersimpan — query balik dari repository yang sama.
$post = $posts->cariSlug('belajar-sqlite-dari-nol');
echo "query balik : ", $post['judul'], "\n";
// 2. POST tidak valid -> 422, form dirender ulang dengan nilai lama + galat.
[$status, , $body] = $admin->jalankan([
'method' => 'POST',
'path' => '/admin/tulis',
'post' => ['judul' => '', 'slug' => 'Slug Ilegal!', 'konten' => 'isi'],
]);
echo "POST invalid : status=", $status, "\n";
foreach (explode("\n", $body) as $baris) {
$baris = trim($baris);
if (str_contains($baris, 'galat')) {
echo " galat : ", strip_tags($baris), "\n";
}
if (preg_match('/name="slug"[^>]*value="([^"]*)"/', $baris, $m) === 1) {
echo " old : value=\"", $m[1], "\"\n";
}
}Output yang Dihasilkan:
POST valid : status=303 Location=/admin?flash=simpan&id=4
query balik : Belajar SQLite dari Nol
POST invalid : status=422
galat : Judul wajib diisi.
old : value="Slug Ilegal!"
galat : Slug hanya boleh huruf kecil, angka, dan tanda hubung.Baris pertama membuktikan PRG bekerja: 303 plus Location yang menunjuk daftar artikel. Baris kedua membuktikan datanya sungguh masuk — query balik lewat repository menemukan artikel yang barusan “disubmit”. Bagian kedua memperlihatkan kegagalan yang ramah: status 422, nilai Slug Ilegal! yang tetap berada di input (pola old()), dan dua pesan galat yang menjelaskan apa yang harus diperbaiki.
Merakit di Front Controller #
Terakhir, front controller mendelegasikan seluruh path berawalan /admin ke controller. Dua perubahan kecil menyertainya: respons halaman publik kini berbentuk [status, headers, body] yang sama (helper halaman() menambahkan header Content-Type), dan titik masuk menerapkan header dari respons lewat satu perulangan:
<?php
use App\AdminController;
use App\Database;
use App\PostRepository;
/**
* @param array<string,mixed> $server isi $_SERVER (asli atau tiruan)
* @param array<string,mixed> $get isi $_GET (asli atau tiruan)
* @param array<string,mixed> $post isi $_POST (asli atau tiruan)
*
* @return array{0: int, 1: array<string,string>, 2: string}
*/
function dispatch(
array $server,
array $get,
array $post,
PostRepository $posts,
AdminController $admin,
): array {
$path = (string) (parse_url((string) ($server['REQUEST_URI'] ?? '/'), PHP_URL_PATH) ?: '/');
$path = rtrim($path, '/') ?: '/';
// Panel admin — seluruh path berawalan /admin diserahkan ke controller.
if ($path === '/admin' || str_starts_with($path, '/admin/')) {
return $admin->jalankan([
'method' => (string) ($server['REQUEST_METHOD'] ?? 'GET'),
'path' => $path,
'query' => $get,
'post' => $post,
]);
}
// ... tabel rute publik /, /tentang, dan pola /post/{slug}
// seperti pada Bagian 2-3 ...
}
$posts = new PostRepository(Database::siapkan());
$admin = new AdminController($posts);
[$status, $headers, $body] = dispatch($_SERVER, $_GET, $_POST, $posts, $admin);
http_response_code($status);
foreach ($headers as $nama => $nilai) {
header($nama . ': ' . $nilai);
}
echo $body;Pemisahan tugasnya jelas: front controller membaca superglobal ($_SERVER, $_GET, $_POST) dan berbicara HTTP; AdminController mengolah nilai dan mengembalikan keputusan; tidak ada yang lain. Inilah bentuk mini dari apa yang framework sebut controller layer — dan karena keputusan tidak pernah bercampur dengan pengiriman, seluruh panel bisa diuji tanpa membuka satu port pun.
Menguji Alur Admin dengan Web Server Bawaan #
Saatnya menjalankan alurnya sungguhan. Nyalakan server, lalu buka daftar artikel:
# terminal 1 — dari direktori root proyek
$ php -S 127.0.0.1:8093 public/index.php# terminal 2
$ curl -sS http://127.0.0.1:8093/admin | grep -E '<h1>|flash' <h1>Daftar Artikel (3)</h1>$ curl -sS http://127.0.0.1:8093/admin | sed -n '/<table/,/<\/table>/p' | head -n 13 <table border="1">
<tr>
<th>Judul</th>
<th>Slug</th>
<th>Diterbitkan</th>
<th>Aksi</th>
</tr>
<tr>
<td>Tips Aman Menampilkan Data Pengguna</td>
<td><code>tips-aman-menampilkan-data-pengguna</code></td>
<td>2026-01-17 20:05:00</td>
<td>
<a href="/admin/edit/3">Ubah</a>Form tulis tersedia di /admin/tulis. Kirim artikel baru dengan curl — --data-urlencode menangani spasi dan baris baru pada konten dengan aman:
$ curl -sS http://127.0.0.1:8093/admin/tulis | grep -E '<h1>|<form' <h1>Tulis Artikel Baru</h1>
<form method="post" action="/admin/tulis">$ curl -sS -i -X POST \
--data-urlencode "judul=Belajar SQLite dari Nol" \
--data-urlencode "slug=belajar-sqlite-dari-nol" \
--data-urlencode "konten=SQLite adalah database berkas tunggal yang luar biasa praktis.
Kita memakainya tanpa server terpisah." \
--data-urlencode "dibuat_pada=2026-01-22 10:00:00" \
http://127.0.0.1:8093/admin/tulis | head -n 9HTTP/1.1 303 See Other
Host: 127.0.0.1:8093
Date: Tue, 13 Oct 2026 21:56:45 GMT
Connection: close
X-Powered-By: PHP/8.4.23
Location: /admin?flash=simpan&id=4
Content-type: text/html; charset=UTF-8Redirect 303 dengan Location — persis seperti hasil simulasi CLI. Mengikuti redirect itu (inilah yang otomatis dilakukan browser) menampilkan daftar yang kini berisi empat artikel plus pesan flash:
$ curl -sS "http://127.0.0.1:8093/admin?flash=simpan&id=4" | grep -E 'flash|<h1>' <p class="flash">Artikel baru berhasil disimpan.</p>
<h1>Daftar Artikel (4)</h1>Artikel baru juga langsung hidup di sisi publik — konten dua paragrafnya tersimpan utuh:
$ curl -sS http://127.0.0.1:8093/post/belajar-sqlite-dari-nol | sed -n '/<main/,/<\/main>/p' <main class="site-main">
<article class="post-full">
<h2>Belajar SQLite dari Nol</h2>
<small>Diterbitkan 2026-01-22 10:00:00</small>
<p>SQLite adalah database berkas tunggal yang luar biasa praktis.</p>
<p>Kita memakainya tanpa server terpisah.</p>
<p><a href="/">← Kembali ke beranda</a></p>
</article>
</main>Form ubah terisi data lama — GET /admin/edit/4 merender value yang berasal dari cariId():
$ curl -sS http://127.0.0.1:8093/admin/edit/4 | grep -E '<h1>|name="(judul|slug)"' <h1>Ubah Artikel</h1>
<input type="text" id="judul" name="judul" size="60" value="Belajar SQLite dari Nol">
<input type="text" id="slug" name="slug" size="60" value="belajar-sqlite-dari-nol">Kirim POST yang tidak valid — judul kosong dan slug memuat huruf kapital serta tanda seru. Responsnya 422 (web server bawaan PHP menampilkan frasa Unknown Status Code untuk kode ini karena tidak mengenalnya; artinya tetap 422) dengan form yang terisi ulang beserta galatnya:
$ curl -sS -i -X POST -d "judul=" -d "slug=Slug Ilegal!" \
-d "konten=isi yang tetap tampil" http://127.0.0.1:8093/admin/tulis | grep -E 'HTTP/|galat|name="slug"'HTTP/1.1 422 Unknown Status Code
<br><small class="galat">Judul wajib diisi.</small>
<input type="text" id="slug" name="slug" size="60" value="Slug Ilegal!">
<br><small class="galat">Slug hanya boleh huruf kecil, angka, dan tanda hubung.</small>Terakhir, hapus artikel id 4 lewat form POST, lalu pastikan daftar kembali berisi tiga artikel dan halaman publiknya menjadi 404:
$ curl -sS -i -X POST -d "id=4" http://127.0.0.1:8093/admin/hapus | grep -E 'HTTP/|Location'HTTP/1.1 303 See Other
Location: /admin?flash=hapus&id=4$ curl -sS http://127.0.0.1:8093/admin | grep '<h1>' <h1>Daftar Artikel (3)</h1>$ curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8093/post/belajar-sqlite-dari-nol404Lingkaran CRUD tertutup: buat, baca, ubah, hapus — semuanya melalui layar admin, semuanya dengan status HTTP yang benar.
Apa yang Berubah #
Dibandingkan Bagian 3, tahap ini menambahkan tiga berkas dan satu evolusi front controller:
src/AdminController.php— controller panel admin:jalankan()mendistribusikan request array,simpanAtauForm()+validasi()mengurus alur simpan, redirect303setelah sukses, form ulang422setelah gagal.templates/admin/posts.php— daftar artikel dengan tabel, pesan flash dari query string, dan penghapusan lewat formPOST.templates/admin/post-form.php— form tulis/ubah dengan helperold()untuk mengisi ulang nilai dan pesan galat per kolom.public/index.php— path/admin...didelegasikan ke controller; respons seluruh halaman diseragamkan menjadi[status, headers, body].
Panel ini terbuka untuk siapa saja yang menemukan URL-nya. Bagian 5 menutupnya dengan authentication: tabel pengguna, password hashing, session, dan middleware penghalang rute admin.
Cara Menjalankan #
Dari direktori bagian-4-admin-dan-crud/ (folder bundle halaman ini):
# 1. Alur admin dari CLI murni — salin cuplikan pengujian di atas
# ke berkas sementara lalu jalankan (memuat class Bagian 3 lewat path saudara)
php uji-admin.php
# 2. Gabungkan dengan berkas Bagian 1-3, lalu nyalakan server
php -S 127.0.0.1:8093 public/index.php
curl -sS http://127.0.0.1:8093/admin
# 3. Pemeriksaan sintaks berkas baru
php -l src/AdminController.php && php -l templates/admin/posts.php && php -l templates/admin/post-form.phpTiga berkas pendukung halaman ini — src/AdminController.php, templates/admin/posts.php, dan templates/admin/post-form.php — tersedia di bagian bawah halaman. Untuk pendalaman, baca dokumentasi resmi tentang superglobal $_POST
beserta $_GET
, header()
untuk redirect, dan http_response_code()
. Dengan panel admin yang berfungsi penuh, kita siap menguncinya dengan authentication dan authorization pada Bagian 5.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
src/AdminController.php
Unduh berkas<?php
declare(strict_types=1);
namespace App;
use PDOException;
/**
* AdminController — menangani seluruh rute panel admin (/admin...).
*
* Setiap request diwakili array bentukan:
*
* ['method' => 'GET'|'POST', 'path' => '/admin/...', 'query' => [...], 'post' => [...]]
*
* dan setiap method mengembalikan respons terstruktur:
*
* [status, headers, body]
*
* Struktur ini membuat controller murni: tidak ada echo, tidak ada
* header() yang dikirim dari dalam — front controller yang menerapkan
* ketiganya. Karena itu seluruh method bisa diuji dari skrip CLI
* dengan request tiruan, tanpa web server.
*
* CATATAN: panel ini sengaja BELUM memiliki authentication dan
* authorization — keduanya dibangun pada Bagian 5.
*/
final class AdminController
{
public function __construct(private readonly PostRepository $posts)
{
}
/**
* Dispatcher: petakan [method, path] ke method yang tepat.
*
* @param array<string,mixed> $request
*
* @return array{0: int, 1: array<string,string>, 2: string}
*/
public function jalankan(array $request): array
{
$path = (string) ($request['path'] ?? '/admin');
$method = strtoupper((string) ($request['method'] ?? 'GET'));
if ($path === '/admin' && $method === 'GET') {
return $this->index($request);
}
if ($path === '/admin/tulis' && $method === 'GET') {
return $this->tulis($request);
}
if ($path === '/admin/tulis' && $method === 'POST') {
return $this->simpan($request);
}
if (preg_match('#^/admin/edit/(?<id>\d+)$#', $path, $cocok) === 1) {
$request['id'] = (int) $cocok['id'];
return $this->edit($request);
}
if ($path === '/admin/hapus' && $method === 'POST') {
return $this->hapus($request);
}
return $this->tidakDitemukan($path);
}
/**
* GET /admin — daftar seluruh artikel beserta tombol ubah/hapus.
*
* @param array<string,mixed> $request
*
* @return array{0: int, 1: array<string,string>, 2: string}
*/
public function index(array $request): array
{
$body = $this->renderAdmin('posts.php', [
'posts' => $this->posts->semua(),
'flash' => (string) ($request['query']['flash'] ?? ''),
]);
return $this->halaman($body);
}
/**
* GET /admin/tulis — form kosong untuk artikel baru.
*
* @param array<string,mixed> $request
*
* @return array{0: int, 1: array<string,string>, 2: string}
*/
public function tulis(array $request): array
{
return $this->halaman($this->renderAdmin('post-form.php', [
'post' => null,
'galat' => [],
'aksi' => '/admin/tulis',
'mode' => 'tulis',
]));
}
/**
* POST /admin/tulis — validasi lalu simpan artikel baru.
*
* Bila validasi gagal, form dirender ulang (status 422) berisi
* nilai lama dan pesan galat. Bila berhasil, pengguna diarahkan
* ke daftar artikel dengan status 303 (Post/Redirect/Get).
*
* @param array<string,mixed> $request
*
* @return array{0: int, 1: array<string,string>, 2: string}
*/
public function simpan(array $request): array
{
return $this->simpanAtauForm($request['post'] ?? [], null);
}
/**
* GET/POST /admin/edit/{id} — form ubah artikel (GET, terisi data
* lama) dan penyimpanan perubahannya (POST).
*
* @param array<string,mixed> $request kunci 'id' disuntikkan jalankan()
*
* @return array{0: int, 1: array<string,string>, 2: string}
*/
public function edit(array $request): array
{
$id = (int) ($request['id'] ?? 0);
$post = $this->posts->cariId($id);
if ($post === null) {
return $this->redirect('/admin?flash=tak-ditemukan');
}
if (strtoupper((string) ($request['method'] ?? 'GET')) !== 'POST') {
return $this->halaman($this->renderAdmin('post-form.php', [
'post' => $post,
'galat' => [],
'aksi' => "/admin/edit/{$id}",
'mode' => 'ubah',
]));
}
return $this->simpanAtauForm($request['post'] ?? [], $id);
}
/**
* POST /admin/hapus — hapus artikel, lalu kembali ke daftar.
*
* Penghapusan lewat POST (bukan tautan GET) agar request yang
* mengubah data tidak bisa dipicu sekadar dengan membuka tautan.
*
* @param array<string,mixed> $request
*
* @return array{0: int, 1: array<string,string>, 2: string}
*/
public function hapus(array $request): array
{
$id = (int) ($request['post']['id'] ?? 0);
$this->posts->hapus($id);
return $this->redirect('/admin?flash=hapus&id=' . $id);
}
// -----------------------------------------------------------------------
// Helper privat
// -----------------------------------------------------------------------
/**
* Alur simpan bersama untuk artikel baru ($id null) dan perubahan
* artikel lama: validasi -> simpan -> redirect, atau form ulang.
*
* @param array<string,mixed> $data isi $_POST
*
* @return array{0: int, 1: array<string,string>, 2: string}
*/
private function simpanAtauForm(array $data, ?int $id): array
{
[$post, $galat] = $this->validasi($data);
$aksi = $id === null ? '/admin/tulis' : '/admin/edit/' . $id;
$mode = $id === null ? 'tulis' : 'ubah';
if ($galat === []) {
try {
$idTersimpan = $this->posts->simpan(
$id === null ? $post : $post + ['id' => $id]
);
$jenis = $id === null ? 'simpan' : 'ubah';
return $this->redirect("/admin?flash={$jenis}&id={$idTersimpan}");
} catch (PDOException $galatDb) {
$galat['slug'] = str_contains($galatDb->getMessage(), 'posts.slug')
? 'Slug sudah dipakai artikel lain.'
: 'Penyimpanan gagal: ' . $galatDb->getMessage();
}
}
return $this->halaman(
$this->renderAdmin('post-form.php', [
'post' => $post,
'galat' => $galat,
'aksi' => $aksi,
'mode' => $mode,
]),
422
);
}
/**
* Validasi input form.
*
* @param array<string,mixed> $data
*
* @return array{0: array<string,string>, 1: array<string,string>} [post bersih, galat]
*/
private function validasi(array $data): array
{
$post = [
'slug' => trim((string) ($data['slug'] ?? '')),
'judul' => trim((string) ($data['judul'] ?? '')),
'konten' => trim((string) ($data['konten'] ?? '')),
'dibuat_pada' => trim((string) ($data['dibuat_pada'] ?? '')),
];
$galat = [];
if ($post['judul'] === '') {
$galat['judul'] = 'Judul wajib diisi.';
}
if ($post['slug'] === '') {
$galat['slug'] = 'Slug wajib diisi.';
} elseif (preg_match('/^[a-z0-9-]+$/', $post['slug']) !== 1) {
$galat['slug'] = 'Slug hanya boleh huruf kecil, angka, dan tanda hubung.';
}
if ($post['konten'] === '') {
$galat['konten'] = 'Konten wajib diisi.';
}
if ($post['dibuat_pada'] === '') {
$post['dibuat_pada'] = date('Y-m-d H:i:s'); // diisi saat disimpan
}
return [$post, $galat];
}
/**
* Render template admin menjadi string.
*
* @param array<string,mixed> $data
*/
private function renderAdmin(string $template, array $data = []): string
{
extract($data, EXTR_SKIP);
ob_start();
require __DIR__ . '/../templates/admin/' . $template;
return (string) ob_get_clean();
}
/**
* @return array{0: int, 1: array<string,string>, 2: string}
*/
private function halaman(string $body, int $status = 200): array
{
return [$status, ['Content-Type' => 'text/html; charset=UTF-8'], $body];
}
/**
* @return array{0: int, 1: array<string,string>, 2: string}
*/
private function redirect(string $lokasi): array
{
return [303, ['Location' => $lokasi], ''];
}
/**
* @return array{0: int, 1: array<string,string>, 2: string}
*/
private function tidakDitemukan(string $path): array
{
$aman = htmlspecialchars($path, ENT_QUOTES, 'UTF-8');
return $this->halaman(
"<h1>404</h1><p>Rute admin <code>{$aman}</code> tidak dikenali.</p>",
404
);
}
}
templates/admin/post-form.php
Unduh berkas<?php
/**
* templates/admin/post-form.php — form tulis artikel baru dan ubah lama.
*
* Menerima empat variabel:
* - $post : array nilai form (data lama saat mode ubah, hasil validasi
* yang gagal, atau null saat form kosong),
* - $galat : map nama kolom => pesan galat,
* - $aksi : URL tujuan submit form,
* - $mode : 'tulis' atau 'ubah' (menentukan judul halaman).
*
* Helper old() mengisi ulang nilai input dari $post dengan aman;
* nilai bawaan membuat berkas ini aman dijalankan langsung.
*/
if (!function_exists('e')) {
/** Escape teks untuk HTML — dipakai di semua template. */
function e(?string $nilai): string
{
return htmlspecialchars($nilai ?? '', ENT_QUOTES, 'UTF-8');
}
}
if (!function_exists('old')) {
/** Nilai lama untuk mengisi ulang form setelah validasi gagal. */
function old(?array $post, string $kunci): string
{
return e((string) (($post ?? [])[$kunci] ?? ''));
}
}
$post ??= null;
$galat ??= [];
$aksi ??= '/admin/tulis';
$mode ??= 'tulis';
$judulHalaman = $mode === 'ubah' ? 'Ubah Artikel' : 'Tulis Artikel Baru';
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<title>Admin — <?= e($judulHalaman) ?></title>
</head>
<body>
<header>
<strong>Panel Admin</strong>
<nav>
<a href="/admin">Artikel</a> |
<a href="/admin/tulis">Tulis Baru</a> |
<a href="/">Lihat Blog</a>
</nav>
</header>
<main>
<h1><?= e($judulHalaman) ?></h1>
<form method="post" action="<?= e($aksi) ?>">
<p>
<label for="judul">Judul</label><br>
<input type="text" id="judul" name="judul" size="60" value="<?= old($post, 'judul') ?>">
<?php if (isset($galat['judul'])): ?>
<br><small class="galat"><?= e($galat['judul']) ?></small>
<?php endif; ?>
</p>
<p>
<label for="slug">Slug (huruf kecil, angka, tanda hubung)</label><br>
<input type="text" id="slug" name="slug" size="60" value="<?= old($post, 'slug') ?>">
<?php if (isset($galat['slug'])): ?>
<br><small class="galat"><?= e($galat['slug']) ?></small>
<?php endif; ?>
</p>
<p>
<label for="konten">Konten (pisahkan paragraf dengan baris kosong)</label><br>
<textarea id="konten" name="konten" cols="62" rows="10"><?= old($post, 'konten') ?></textarea>
<?php if (isset($galat['konten'])): ?>
<br><small class="galat"><?= e($galat['konten']) ?></small>
<?php endif; ?>
</p>
<p>
<label for="dibuat_pada">Diterbitkan (format YYYY-MM-DD HH:MM:SS)</label><br>
<input type="text" id="dibuat_pada" name="dibuat_pada" size="25" value="<?= old($post, 'dibuat_pada') ?>" placeholder="kosongkan untuk waktu sekarang">
</p>
<p>
<button type="submit">Simpan Artikel</button>
<a href="/admin">Batal</a>
</p>
</form>
</main>
</body>
</html>
templates/admin/posts.php
Unduh berkas<?php
/**
* templates/admin/posts.php — halaman utama admin: daftar artikel.
*
* Menerima $posts (array dari PostRepository::semua()) dan $flash
* (string kosong atau salah satu kunci di $petaFlash). Nilai bawaan
* membuat berkas ini aman dijalankan langsung tanpa data.
*/
if (!function_exists('e')) {
/** Escape teks untuk HTML — dipakai di semua template. */
function e(?string $nilai): string
{
return htmlspecialchars($nilai ?? '', ENT_QUOTES, 'UTF-8');
}
}
$posts ??= [];
$flash ??= '';
$petaFlash = [
'simpan' => 'Artikel baru berhasil disimpan.',
'ubah' => 'Perubahan artikel berhasil disimpan.',
'hapus' => 'Artikel berhasil dihapus.',
'tak-ditemukan' => 'Artikel yang diminta tidak ditemukan.',
];
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<title>Admin — Daftar Artikel</title>
</head>
<body>
<header>
<strong>Panel Admin</strong>
<nav>
<a href="/admin">Artikel</a> |
<a href="/admin/tulis">Tulis Baru</a> |
<a href="/">Lihat Blog</a>
</nav>
</header>
<main>
<?php if (isset($petaFlash[$flash])): ?>
<p class="flash"><?= e($petaFlash[$flash]) ?></p>
<?php endif; ?>
<h1>Daftar Artikel (<?= count($posts) ?>)</h1>
<?php if ($posts === []): ?>
<p>Belum ada artikel. <a href="/admin/tulis">Tulis artikel pertama</a>.</p>
<?php else: ?>
<table border="1">
<tr>
<th>Judul</th>
<th>Slug</th>
<th>Diterbitkan</th>
<th>Aksi</th>
</tr>
<?php foreach ($posts as $post): ?>
<tr>
<td><?= e($post['judul'] ?? '') ?></td>
<td><code><?= e($post['slug'] ?? '') ?></code></td>
<td><?= e($post['dibuat_pada'] ?? '') ?></td>
<td>
<a href="/admin/edit/<?= (int) ($post['id'] ?? 0) ?>">Ubah</a>
<form method="post" action="/admin/hapus" style="display: inline">
<input type="hidden" name="id" value="<?= (int) ($post['id'] ?? 0) ?>">
<button type="submit">Hapus</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
</main>
</body>
</html>