Kodeloka
  1. Kodeloka
  2. Kelas PHP
  3. Materi

7 · Kualitas & Produksi · Modul 25 dari 25

Proyek Akhir Kelas PHP: Aplikasi Manajemen Modul & Catatan Belajar Aman

Membangun aplikasi web PHP 8.4/8.5 berarsitektur bersih dari nol yang memadukan PSR-4 Autoloading, Backed Enum, Readonly Class, PDO Prepared Statements, proteksi sesi, proteksi CSRF hash_equals(), serta keluaran HTML bebas XSS.

Gambaran Proyek

Selamat! Anda telah menuntaskan seluruh perjalanan di Kelas PHP Kodeloka—mulai dari siklus versi dan model eksekusi PHP, sistem tipe ketat declare(strict_types=1), ekspresi match dan operator pipe |>, pengolahan string multibyte mb_* dan fungsi array baru PHP 8.4/8.5, OOP modern dengan Property Hooks dan Asymmetric Visibility, Enum & Exception, Composer & PSR-4, penanganan request HTTP dan pencegahan XSS, manajemen sesi & cookie, unggah berkas dengan finfo, akses basis data PDO, keamanan kata sandi & CSRF, pengujian PHPUnit, hingga penyajian produksi dengan PHP-FPM.

Dalam proyek akhir ini, kita akan merakit Aplikasi Pengelola Catatan Belajar Kodeloka yang menerapkan seluruh standar keamanan dan arsitektur bersih tersebut:

proyek-catatan-kodeloka/
├── composer.json
├── public/
│   └── index.php                  <-- Front Controller (Routing, Session, CSRF, & View)
├── src/
│   ├── Domain/
│   │   ├── PrioritasCatatan.php   <-- Backed Enum (PHP 8.1+)
│   │   └── Catatan.php            <-- Entitas Readonly (PHP 8.2+)
│   ├── Keamanan/
│   │   └── PenjagaKeamanan.php    <-- Helper XSS e(), CSRF hash_equals(), & Sesi Aman
│   └── Repository/
│       └── CatatanRepository.php  <-- PDO Prepared Statements
└── tests/
    └── CatatanRepositoryTest.php  <-- Pengujian Otomatis PHPUnit 12

Kode Lengkap Proyek

1. Backed Enum Prioritas (src/Domain/PrioritasCatatan.php)

<?php

declare(strict_types=1);

namespace Kodeloka\Catatan\Domain;

enum PrioritasCatatan: string
{
    case Rendah  = 'rendah';
    case Normal  = 'normal';
    case Penting = 'penting';

    public function label(): string
    {
        return match ($this) {
            self::Rendah  => 'Rendah',
            self::Normal  => 'Normal',
            self::Penting => 'Penting ★',
        };
    }
}

2. Entitas Readonly (src/Domain/Catatan.php)

<?php

declare(strict_types=1);

namespace Kodeloka\Catatan\Domain;

readonly class Catatan
{
    public function __construct(
        public int $id,
        public string $judul,
        public string $isi,
        public PrioritasCatatan $prioritas,
        public string $dibuatPada,
    ) {}

    public static function dariBarisDb(array $baris): self
    {
        return new self(
            id: (int) $baris['id'],
            judul: (string) $baris['judul'],
            isi: (string) $baris['isi'],
            prioritas: PrioritasCatatan::tryFrom((string) $baris['prioritas']) ?? PrioritasCatatan::Normal,
            dibuatPada: (string) $baris['dibuat_pada'],
        );
    }
}

3. Utilitas Keamanan Sesi, CSRF, dan Anti-XSS (src/Keamanan/PenjagaKeamanan.php)

<?php

declare(strict_types=1);

namespace Kodeloka\Catatan\Keamanan;

final class PenjagaKeamanan
{
    public static function mulaiSesi(): void
    {
        if (session_status() === PHP_SESSION_ACTIVE) {
            return;
        }

        ini_set('session.use_strict_mode', '1');
        ini_set('session.use_only_cookies', '1');

        session_set_cookie_params([
            'lifetime' => 0,
            'path'     => '/',
            'secure'   => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
            'httponly' => true,
            'samesite' => 'Lax',
        ]);

        session_start();
    }

    public static function e(string|int|float|null $nilai): string
    {
        return htmlspecialchars(
            (string) $nilai,
            ENT_QUOTES | ENT_SUBSTITUTE,
            'UTF-8',
        );
    }

    public static function tokenCsrf(): string
    {
        self::mulaiSesi();
        if (empty($_SESSION['_csrf_token'])) {
            $_SESSION['_csrf_token'] = bin2hex(random_bytes(32));
        }
        return (string) $_SESSION['_csrf_token'];
    }

    public static function verifikasiCsrf(?string $tokenMasukan): bool
    {
        self::mulaiSesi();
        $tokenSesi = (string) ($_SESSION['_csrf_token'] ?? '');
        if ($tokenSesi === '' || $tokenMasukan === null || $tokenMasukan === '') {
            return false;
        }
        return hash_equals($tokenSesi, $tokenMasukan);
    }
}

4. Repositori Basis Data PDO (src/Repository/CatatanRepository.php)

<?php

declare(strict_types=1);

namespace Kodeloka\Catatan\Repository;

use InvalidArgumentException;
use Kodeloka\Catatan\Domain\Catatan;
use Kodeloka\Catatan\Domain\PrioritasCatatan;
use PDO;

final class CatatanRepository
{
    public function __construct(
        private readonly PDO $pdo,
    ) {}

    public function inisialisasiSkema(): void
    {
        $this->pdo->exec(
            'CREATE TABLE IF NOT EXISTS catatan (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                judul TEXT NOT NULL,
                isi TEXT NOT NULL,
                prioritas TEXT NOT NULL DEFAULT \'normal\',
                dibuat_pada TEXT NOT NULL
            )'
        );
    }

    /**
     * @return list<Catatan>
     */
    public function ambilSemua(): array
    {
        $stmt = $this->pdo->query(
            'SELECT id, judul, isi, prioritas, dibuat_pada
             FROM catatan
             ORDER BY id DESC'
        );

        $daftarBaris = $stmt->fetchAll(PDO::FETCH_ASSOC);
        return array_map(Catatan::dariBarisDb(...), $daftarBaris);
    }

    public function tambah(string $judul, string $isi, PrioritasCatatan $prioritas): int
    {
        $judulBersih = trim($judul);
        $isiBersih   = trim($isi);

        if (mb_strlen($judulBersih) < 3 || mb_strlen($judulBersih) > 120) {
            throw new InvalidArgumentException('Judul catatan harus antara 3 hingga 120 karakter.');
        }
        if ($isiBersih === '') {
            throw new InvalidArgumentException('Isi catatan tidak boleh kosong.');
        }

        // Prepared Statement: 100% aman dari SQL Injection!
        $stmt = $this->pdo->prepare(
            'INSERT INTO catatan (judul, isi, prioritas, dibuat_pada)
             VALUES (:judul, :isi, :prioritas, :dibuat_pada)'
        );

        $stmt->execute([
            'judul'       => $judulBersih,
            'isi'         => $isiBersih,
            'prioritas'   => $prioritas->value,
            'dibuat_pada' => gmdate('Y-m-d\TH:i:s\Z'),
        ]);

        return (int) $this->pdo->lastInsertId();
    }

    public function hapus(int $id): bool
    {
        $stmt = $this->pdo->prepare('DELETE FROM catatan WHERE id = :id');
        $stmt->execute(['id' => $id]);
        return $stmt->rowCount() > 0;
    }
}

5. Front Controller (public/index.php)

<?php

declare(strict_types=1);

use Kodeloka\Catatan\Domain\PrioritasCatatan;
use Kodeloka\Catatan\Keamanan\PenjagaKeamanan as Aman;
use Kodeloka\Catatan\Repository\CatatanRepository;

require dirname(__DIR__) . '/vendor/autoload.php';

Aman::mulaiSesi();

// Koneksi PDO SQLite dengan konfigurasi ketat
$jalurDb = dirname(__DIR__) . '/storage/catatan.sqlite';
$pdo = new PDO('sqlite:' . $jalurDb, null, null, [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES   => false,
]);

$repo = new CatatanRepository($pdo);
$repo->inisialisasiSkema();

$pesanGalat = null;

// Menangani pengiriman formulir POST (dilindungi token CSRF!)
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if (!Aman::verifikasiCsrf($_POST['csrf_token'] ?? null)) {
        http_response_code(403);
        exit('Token keamanan CSRF tidak valid.');
    }

    $aksi = (string) ($_POST['aksi'] ?? '');

    try {
        if ($aksi === 'tambah') {
            $prioritas = PrioritasCatatan::tryFrom((string) ($_POST['prioritas'] ?? ''))
                ?? PrioritasCatatan::Normal;

            $repo->tambah(
                judul: (string) ($_POST['judul'] ?? ''),
                isi: (string) ($_POST['isi'] ?? ''),
                prioritas: $prioritas,
            );
        } elseif ($aksi === 'hapus') {
            $idHapus = filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT);
            if (is_int($idHapus) && $idHapus > 0) {
                $repo->hapus($idHapus);
            }
        }

        // Pola Post/Redirect/Get (PRG) agar menekan F5 di peramban tidak mengirim ulang form POST!
        header('Location: /', true, 303);
        exit;
    } catch (InvalidArgumentException $e) {
        http_response_code(422);
        $pesanGalat = $e->getMessage();
    }
}

$daftarCatatan = $repo->ambilSemua();
$tokenCsrf = Aman::tokenCsrf();
?>
<!DOCTYPE html>
<html lang="id">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Catatan Belajar Aman — Kodeloka PHP</title>
  </head>
  <body>
    <main>
      <h1>Catatan Belajar Kodeloka</h1>

      <?php if ($pesanGalat !== null): ?>
        <p role="alert"><strong>Galat:</strong> <?= Aman::e($pesanGalat) ?></p>
      <?php endif; ?>

      <section aria-labelledby="judul-form">
        <h2 id="judul-form">Tambah Catatan Baru</h2>
        <form action="/" method="post">
          <input type="hidden" name="csrf_token" value="<?= Aman::e($tokenCsrf) ?>">
          <input type="hidden" name="aksi" value="tambah">

          <div>
            <label for="input-judul">Judul Modul / Topik</label>
            <input type="text" id="input-judul" name="judul" required minlength="3" maxlength="120">
          </div>

          <div>
            <label for="pilih-prioritas">Prioritas</label>
            <select id="pilih-prioritas" name="prioritas">
              <?php foreach (PrioritasCatatan::cases() as $kasus): ?>
                <option value="<?= Aman::e($kasus->value) ?>">
                  <?= Aman::e($kasus->label()) ?>
                </option>
              <?php endforeach; ?>
            </select>
          </div>

          <div>
            <label for="input-isi">Ringkasan Catatan</label>
            <textarea id="input-isi" name="isi" rows="4" required></textarea>
          </div>

          <button type="submit">Simpan Catatan</button>
        </form>
      </section>

      <section aria-labelledby="judul-daftar">
        <h2 id="judul-daftar">Daftar Catatan Tersimpan (<?= count($daftarCatatan) ?>)</h2>
        <?php foreach ($daftarCatatan as $catatan): ?>
          <article>
            <header>
              <h3><?= Aman::e($catatan->judul) ?></h3>
              <span>Prioritas: <?= Aman::e($catatan->prioritas->label()) ?></span>
              <time datetime="<?= Aman::e($catatan->dibuatPada) ?>"><?= Aman::e($catatan->dibuatPada) ?></time>
            </header>
            <p><?= nl2br(Aman::e($catatan->isi)) ?></p>
            <form action="/" method="post">
              <input type="hidden" name="csrf_token" value="<?= Aman::e($tokenCsrf) ?>">
              <input type="hidden" name="aksi" value="hapus">
              <input type="hidden" name="id" value="<?= Aman::e($catatan->id) ?>">
              <button type="submit">Hapus Catatan</button>
            </form>
          </article>
        <?php endforeach; ?>
      </section>
    </main>
  </body>
</html>