Topik ini membangun aplikasi Daftar Tugas yang direncanakan pada topik Perencanaan Proyek. Empat fitur inti masuk lingkup, yaitu menampilkan daftar, menambah tugas, menandai selesai, dan menghapus. Keempatnya diwujudkan menjadi empat berkas PHP yang disusun bertahap dari lapisan data hingga lapisan tampilan. Setiap berkas ditampilkan lengkap beserta keluaran eksekusinya, kemudian seluruh aplikasi dijalankan melalui server bawaan PHP dan diperiksa dari sisi klien menggunakan curl. Setelah topik ini diselesaikan, aplikasi berjalan penuh dengan pertahanan keamanan yang tercantum pada daftar periksa perencanaan.
Struktur berkas yang dibangun#
Struktur berkas mengikuti pohon yang ditetapkan pada perencanaan, yaitu empat berkas yang tersebar dalam tiga folder.
proyek-tugas/
├── public/
│ └── index.php
├── src/
│ └── TugasRepository.php
├── templates/
│ └── daftar.php
└── skema.phpFolder public tetap menjadi satu-satunya sumber berkas yang dilayani server, sedangkan src dan templates berada di luar jangkauan URL. Pemuatan antarberkas dihitung dari __DIR__, yaitu konstanta yang berisi folder tempat berkas PHP itu berada, sehingga server maupun skrip dapat dijalankan dari folder kerja mana pun tanpa menyesuaikan jalur. Apabila sebuah berkas dijalankan sebagai salinan tunggal di luar pohon proyek, pencarian folder src beralih ke folder kerja. Mekanisme tersebut terlihat pada skema.php dan public/index.php, sedangkan repository dan template tidak memuat berkas lain.
Skrip penyiapan basis data#
Skrip skema.php menyiapkan basis data demo sebelum server dijalankan. Skrip ini menghapus berkas /tmp/proyek-tugas.db yang tersisa dari eksekusi sebelumnya, lalu membuka koneksi berkas dengan mode error EXCEPTION sebagaimana dibahas modul Basis Data. Selanjutnya, skrip memanggil TugasRepository::buatSkema agar pernyataan CREATE TABLE hanya hidup di satu tempat. Struktur tabel kemudian dicetak kembali melalui PRAGMA table_info sebagai bukti bahwa tabelnya benar-benar terbentuk.
<?php
declare(strict_types=1);
// menyiapkan basis data demo pada berkas /tmp/proyek-tugas.db
$akar = __DIR__;
if (!is_file($akar . '/src/TugasRepository.php')) {
// salinan berkas tunggal di luar pohon proyek mencari src pada folder kerja
$akar = getcwd();
}
require $akar . '/src/TugasRepository.php';
$berkasBasisData = '/tmp/proyek-tugas.db';
if (is_file($berkasBasisData)) {
unlink($berkasBasisData);
}
$pdo = new PDO('sqlite:' . $berkasBasisData, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
TugasRepository::buatSkema($pdo);
echo 'Tabel tugas dibuat pada ', $berkasBasisData, "\n";
foreach ($pdo->query('PRAGMA table_info(tugas)') as $kolom) {
printf(
"%d|%s|%s|%d|%s|%d\n",
$kolom['cid'],
$kolom['name'],
$kolom['type'],
$kolom['notnull'],
$kolom['dflt_value'] ?? '',
$kolom['pk']
);
}Tabel tugas dibuat pada /tmp/proyek-tugas.db
0|id|INTEGER|0||1
1|judul|TEXT|1||0
2|selesai|INTEGER|1|0|0
3|dibuat_pada|TEXT|1||0Empat baris terakhir memperlihatkan kolom yang sama dengan pratinjau skema pada perencanaan, sehingga skrip ini sekaligus memeriksa kesesuaian implementasi terhadap rancangannya. DSN pada skrip ini berupa sqlite:/tmp/proyek-tugas.db dengan jalur mutlak, bukan jalur relatif, sehingga lokasi basis data tidak bergantung pada folder tempat server dijalankan.
Repository sebagai satu-satunya akses data#
Kelas TugasRepository memusatkan seluruh akses data, dan tidak ada berkas lain yang menulis query. Method buatSkema sengaja dibuat static karena dipanggil oleh pihak yang tidak selalu memiliki koneksi yang sama, yaitu skema.php, demo CLI pada controller, dan berkas uji pada topik Penguatan dan Tinjauan. Koneksi PDO diserahkan lewat constructor, sehingga satu objek repository selalu terikat pada satu basis data yang jelas.
Empat method antarmukanya sesuai rencana, yaitu semua() yang mengembalikan seluruh baris, tambah() yang menyisipkan satu judul dan mengembalikan id barisnya, tandaiSelesai() yang menaikkan kolom selesai menjadi 1, dan hapus() yang menghapus satu baris. Setiap pernyataan yang menerima masukan luar memakai prepared statement dengan parameter terikat, sehingga tidak ada judul maupun id yang pernah ditempel langsung ke teks query.
<?php
declare(strict_types=1);
final class TugasRepository
{
public function __construct(private PDO $pdo)
{
}
// membuat tabel tugas; dipakai skema.php, demo CLI, dan pengujian
public static function buatSkema(PDO $pdo): void
{
$pdo->exec('CREATE TABLE tugas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
judul TEXT NOT NULL,
selesai INTEGER NOT NULL DEFAULT 0,
dibuat_pada TEXT NOT NULL
)');
}
public function semua(): array
{
$stmt = $this->pdo->query('SELECT id, judul, selesai, dibuat_pada FROM tugas ORDER BY id');
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function tambah(string $judul): int
{
$stmt = $this->pdo->prepare('INSERT INTO tugas (judul, dibuat_pada) VALUES (:judul, :dibuat_pada)');
$stmt->execute([
':judul' => $judul,
':dibuat_pada' => date('Y-m-d H:i:s'),
]);
return (int) $this->pdo->lastInsertId();
}
public function tandaiSelesai(int $id): void
{
$stmt = $this->pdo->prepare('UPDATE tugas SET selesai = 1 WHERE id = :id');
$stmt->execute([':id' => $id]);
}
public function hapus(int $id): void
{
$stmt = $this->pdo->prepare('DELETE FROM tugas WHERE id = :id');
$stmt->execute([':id' => $id]);
}
}Template daftar dengan pelolosan keluaran#
Template templates/daftar.php berisi satu fungsi tampilkan_daftar yang menerima array tugas dan token CSRF, lalu merendernya menjadi halaman HTML. Bentuk fungsi dipilih agar controller dapat memuat berkas ini sekali lalu memanggilnya, sama seperti peran view pada modul Pola Aplikasi Web. Template tidak mengambil data sendiri dan tidak berisi keputusan aksi, sehingga seluruh datanya berasal dari parameter fungsi.
Pelolosan dipusatkan pada satu helper, yaitu $e, yang memanggil htmlspecialchars dengan ENT_QUOTES dan UTF-8. Setiap nilai dinamis melewati helper tersebut, mulai dari judul tugas, token pada setiap input tersembunyi, hingga id pada tombol aksi. Form tambah berada di atas daftar, sedangkan setiap baris tugas membawa dua form kecil yang sama-sama membawa token serta id barisnya, yaitu tombol Tandai selesai dan tombol Hapus.
<?php
declare(strict_types=1);
// template daftar tugas: merender data yang diberikan menjadi halaman HTML
function tampilkan_daftar(array $tugas, string $tokenCsrf): void
{
$e = static fn (string $nilai): string => htmlspecialchars($nilai, ENT_QUOTES, 'UTF-8');
?>
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="utf-8">
<title>Daftar Tugas</title>
</head>
<body>
<h1>Daftar Tugas</h1>
<form method="post" action="/">
<input type="hidden" name="aksi" value="tambah">
<input type="hidden" name="token" value="<?= $e($tokenCsrf) ?>">
<input type="text" name="judul">
<button type="submit">Tambah</button>
</form>
<?php foreach ($tugas as $baris) { ?>
<div class="tugas">
<p><?= $e((string) $baris['judul']) ?><?= (int) $baris['selesai'] === 1 ? ' (selesai)' : ' (belum)' ?></p>
<form method="post" action="/">
<input type="hidden" name="aksi" value="selesai">
<input type="hidden" name="token" value="<?= $e($tokenCsrf) ?>">
<input type="hidden" name="id" value="<?= $e((string) $baris['id']) ?>">
<button type="submit">Tandai selesai</button>
</form>
<form method="post" action="/">
<input type="hidden" name="aksi" value="hapus">
<input type="hidden" name="token" value="<?= $e($tokenCsrf) ?>">
<input type="hidden" name="id" value="<?= $e((string) $baris['id']) ?>">
<button type="submit">Hapus</button>
</form>
</div>
<?php } ?>
</body>
</html>
<?php
}
Front controller dan alur permintaan#
Berkas public/index.php adalah satu-satunya berkas yang dilayani server dan berperan sebagai front controller, yaitu titik masuk tunggal yang menerima seluruh permintaan sebelum mengarahkannya. Berkas ini memuat repository dan template lebih dahulu, kemudian bercabang sesuai konteks eksekusinya. Pada eksekusi CLI yang ditandai PHP_SAPI === 'cli', controller menjalankan skenario demo singkat, yaitu menambah dua tugas, menandai satu selesai, lalu mencetak HTML daftar.
<?php
declare(strict_types=1);
// front controller aplikasi Daftar Tugas
$akar = dirname(__DIR__);
if (!is_file($akar . '/src/TugasRepository.php')) {
// salinan berkas tunggal di luar pohon proyek mencari src pada folder kerja
$akar = getcwd();
}
require $akar . '/src/TugasRepository.php';
require $akar . '/templates/daftar.php';
if (PHP_SAPI === 'cli') {
// demo CLI: basis data di memori dan token tetap agar keluaran dapat direproduksi
$pdo = new PDO('sqlite::memory:', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
TugasRepository::buatSkema($pdo);
$repo = new TugasRepository($pdo);
$repo->tambah('Menyusun rencana belajar mingguan');
$repo->tambah('Merapikan arsip catatan');
$repo->tandaiSelesai(1);
tampilkan_daftar($repo->semua(), 'token-demo-cli');
exit;
}
// cookie session ditandai httponly dan samesite sebelum session dimulai
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
if (!isset($_SESSION['token_csrf'])) {
$_SESSION['token_csrf'] = bin2hex(random_bytes(32));
}
$pdo = new PDO('sqlite:/tmp/proyek-tugas.db', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$repo = new TugasRepository($pdo);
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
$token = $_POST['token'] ?? '';
if (!is_string($token) || $token === '' || !hash_equals($_SESSION['token_csrf'], $token)) {
http_response_code(403);
exit('Permintaan ditolak: token CSRF tidak valid.' . PHP_EOL);
}
$aksi = $_POST['aksi'] ?? '';
if ($aksi === 'tambah') {
$judul = $_POST['judul'] ?? '';
if (is_string($judul) && trim($judul) !== '') {
$repo->tambah(trim($judul));
}
} elseif ($aksi === 'selesai' || $aksi === 'hapus') {
$id = filter_var($_POST['id'] ?? '', FILTER_VALIDATE_INT);
if ($id !== false && $id > 0) {
if ($aksi === 'selesai') {
$repo->tandaiSelesai($id);
} else {
$repo->hapus($id);
}
}
}
}
tampilkan_daftar($repo->semua(), $_SESSION['token_csrf']);<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="utf-8">
<title>Daftar Tugas</title>
</head>
<body>
<h1>Daftar Tugas</h1>
<form method="post" action="/">
<input type="hidden" name="aksi" value="tambah">
<input type="hidden" name="token" value="token-demo-cli">
<input type="text" name="judul">
<button type="submit">Tambah</button>
</form>
<div class="tugas">
<p>Menyusun rencana belajar mingguan (selesai)</p>
<form method="post" action="/">
<input type="hidden" name="aksi" value="selesai">
<input type="hidden" name="token" value="token-demo-cli">
<input type="hidden" name="id" value="1">
<button type="submit">Tandai selesai</button>
</form>
<form method="post" action="/">
<input type="hidden" name="aksi" value="hapus">
<input type="hidden" name="token" value="token-demo-cli">
<input type="hidden" name="id" value="1">
<button type="submit">Hapus</button>
</form>
</div>
<div class="tugas">
<p>Merapikan arsip catatan (belum)</p>
<form method="post" action="/">
<input type="hidden" name="aksi" value="selesai">
<input type="hidden" name="token" value="token-demo-cli">
<input type="hidden" name="id" value="2">
<button type="submit">Tandai selesai</button>
</form>
<form method="post" action="/">
<input type="hidden" name="aksi" value="hapus">
<input type="hidden" name="token" value="token-demo-cli">
<input type="hidden" name="id" value="2">
<button type="submit">Hapus</button>
</form>
</div>
</body>
</html>Keluaran di atas diperoleh dengan menjalankan php public/index.php lewat CLI tanpa permintaan HTTP apa pun, dan hasilnya tetap berupa halaman HTML yang utuh. Skema basis data demo dan struktur halaman dengan demikian terperiksa sebelum server dijalankan.
Pada eksekusi web, cookie session ditandai httponly dan samesite Lax sebelum session_start, kemudian token CSRF dihasilkan sekali per sesi. Permintaan POST diverifikasi lebih dahulu dengan hash_equals, dan permintaan tanpa token yang cocok ditolak dengan kode status 403 sebelum satu pun aksi tulis dijalankan. Aksi tambah membersihkan judul dengan trim, sedangkan aksi selesai dan hapus memeriksa id dengan filter_var dan FILTER_VALIDATE_INT sebelum dikirim sebagai parameter terikat. Setelah cabang aksi selesai, controller selalu merender daftar terbaru beserta token sesi.
Menjalankan aplikasi melalui server bawaan#
Alur web lengkapnya diperiksa dengan curl, sebagaimana pola pada modul Keamanan. Cookie session disimpan ke sebuah jar, yaitu berkas tempat curl menyimpan cookie antarpermintaan, lalu token diekstrak dari input tersembunyi form dengan sed. Setiap aksi dikirim sebagai permintaan POST yang membawa token tersebut, dan satu permintaan sengaja dikirim tanpa token untuk membuktikan penolakannya.
# hapus basis data lama dan berkas cookie agar demo dimulai bersih
rm -f /tmp/proyek-tugas.db /tmp/jar-proyek.txt
# siapkan skema pada basis data berkas
php skema.php
# jalankan server bawaan PHP pada port 8170 dari akar proyek
php -S localhost:8170 public/index.php >/dev/null 2>&1 &
sleep 1
# ambil halaman utama beserta cookie session
curl -s -c /tmp/jar-proyek.txt http://localhost:8170/ | grep -oE '<h1>[^<]*</h1>|name="token"'
# ekstrak token CSRF dari input tersembunyi pada form
TOKEN=$(curl -s -b /tmp/jar-proyek.txt http://localhost:8170/ | sed -n 's/.*name="token" value="\([0-9a-f]*\)".*/\1/p' | head -1)
# tambah dua tugas melalui form
curl -s -b /tmp/jar-proyek.txt -d "aksi=tambah&judul=Menyusun rencana belajar mingguan&token=$TOKEN" http://localhost:8170/ | grep -oE '<p>[^<]*</p>'
curl -s -b /tmp/jar-proyek.txt -d "aksi=tambah&judul=Merapikan arsip catatan&token=$TOKEN" http://localhost:8170/ | grep -oE '<p>[^<]*</p>'
# tandai tugas pertama selesai
curl -s -b /tmp/jar-proyek.txt -d "aksi=selesai&id=1&token=$TOKEN" http://localhost:8170/ | grep -oE '<p>[^<]*</p>'
# hapus tugas kedua
curl -s -b /tmp/jar-proyek.txt -d "aksi=hapus&id=2&token=$TOKEN" http://localhost:8170/ | grep -oE '<p>[^<]*</p>'
# kirim aksi tulis tanpa token, cookie session saja tidak cukup
curl -s -b /tmp/jar-proyek.txt -d "aksi=tambah&judul=Tugas tanpa token" http://localhost:8170/
# hentikan server
kill %1Tabel tugas dibuat pada /tmp/proyek-tugas.db
0|id|INTEGER|0||1
1|judul|TEXT|1||0
2|selesai|INTEGER|1|0|0
3|dibuat_pada|TEXT|1||0
<h1>Daftar Tugas</h1>
name="token"
<p>Menyusun rencana belajar mingguan (belum)</p>
<p>Menyusun rencana belajar mingguan (belum)</p>
<p>Merapikan arsip catatan (belum)</p>
<p>Menyusun rencana belajar mingguan (selesai)</p>
<p>Merapikan arsip catatan (belum)</p>
<p>Menyusun rencana belajar mingguan (selesai)</p>
Permintaan ditolak: token CSRF tidak valid.Lima baris pertama adalah keluaran skema.php, sama seperti pada bagian sebelumnya. Baris <h1> dan name="token" berasal dari halaman utama yang masih kosong, sekaligus menegaskan form membawa input token. Tiga baris <p> berikutnya adalah daftar yang dirender ulang setiap kali satu tugas ditambahkan, lalu baris berstatus (selesai) dan (belum) menunjukkan tugas pertama sudah ditandai. Baris tunggal berikutnya memperlihatkan daftar setelah tugas kedua dihapus, dan baris terakhir adalah penolakan atas permintaan tanpa token. Seluruh halaman dirender ulang pada setiap permintaan, sehingga keadaan basis data selalu terlihat tanpa alat bantu tambahan.
Kesalahan umum#
Empat kesalahan berikut lazim muncul saat menyusun aplikasi kecil seperti ini.
- Aksi tulis dijalankan sebelum token diverifikasi. Handler yang mengeksekusi
tambahlebih dahulu lalu memeriksa token sesudahnya membiarkan satu aksi bebas token. Perbaikannya adalah memverifikasi token denganhash_equalssebelum cabang aksi, tepat seperti urutan pada controller di atas. - Judul dicetak tanpa
htmlspecialchars. Satu judul yang berisi<script>cukup untuk mengubah struktur halaman dan mengeksekusi kode di browser orang lain. Perbaikannya adalah helper$epada setiap keluaran dinamis tanpa kecuali. - DSN ditulis dengan jalur relatif, misalnya
sqlite:tugas.db. Basis data terbentuk pada folder kerja server, sehingga menjalankan server dari folder lain berarti membuka basis data yang berbeda. Perbaikannya adalah jalur mutlak seperti/tmp/proyek-tugas.db. - Pernyataan
CREATE TABLEditaruh di controller. Tabel yang dibuat ulang pada setiap permintaan membuang waktu dan menutupi ketergantungan skema yang tidak terkelola. Perbaikannya adalah menempatkannya padabuatSkemayang dipanggil skrip penyiapan.
Ringkasan#
- Empat berkas membagi peran sesuai perencanaan, yaitu skrip skema, repository, template, dan front controller, dengan pemuatan antarberkas berbasis
__DIR__. - Seluruh query hidup di
TugasRepository, dan setiap pernyataan yang menerima masukan luar memakai prepared statement dengan parameter terikat. - Template meloloskan setiap keluaran dinamis melalui satu helper
htmlspecialchars, sedangkan controller memverifikasi token CSRF dan memeriksaidsebelum aksi tulis. - Aplikasi berjalan penuh melalui server bawaan, dan alurnya diperiksa dengan curl, termasuk penolakan permintaan POST tanpa token.
Referensi#
- PDO::prepare — PHP Manual — Dokumentasi prepared statement yang dipakai seluruh query berparameter pada repository.
- Sessions — PHP Manual
— Dokumentasi session, termasuk penyetelan cookie sebelum
session_start. - Security — PHP The Right Way — Ringkasan pertahanan XSS, SQL injection, dan CSRF pada aplikasi PHP.