Blog pada Bagian 2 punya cacat fatal yang sengaja kita biarkan: setiap request membangun ulang datanya dari awal karena array dalam memori ikut mati saat proses PHP selesai. Tulis komentar sebanyak apa pun, tidak ada yang tersimpan. Pada Bagian 3 ini blog mendapat penyimpanan sungguhan — database SQLite yang hidup dalam satu berkas, diakses lewat PDO. Tiga berkas baru masuk: database/schema.sql yang mendefinisikan struktur tabel, src/Database.php yang memusatkan pembuatan koneksi, dan src/PostRepository.php yang isinya diganti seluruhnya dari array menjadi query SQL. Bagian menariknya: tidak satu pun handler atau template ikut berubah, karena API repository dari Bagian 2 sudah membentuk kontrak yang tepat.

Kenapa SQLite dan PDO #

Blog latihan ini membutuhkan database yang bisa dibawa pulang: tanpa server terpisah, tanpa instalasi, cukup satu berkas di dalam proyek. SQLite memenuhi semuanya — seluruh database (tabel, indeks, data) hidup dalam satu berkas .sqlite, dan driver pdo_sqlite sudah terpasang di PHP bawaan. Ketika proyek Anda kelak membesar dan pindah ke MySQL atau PostgreSQL, cukup ganti satu baris DSN di class Database; seluruh query pada repository tetap SQL standar.

PDO sendiri adalah lapisan akses database seragam di PHP: satu antarmuka (prepare(), execute(), fetchAll()) untuk banyak driver. Kita menyetel dua atribut penting pada setiap koneksi: PDO::ERRMODE_EXCEPTION membuat galat SQL melempar PDOException alih-alih diam-diam mengembalikan false, dan PDO::FETCH_ASSOC membuat hasil query berupa array asosiatif — bentuk yang sama persis dengan yang dipakai template sejak Bagian 1.

Skema Database #

Struktur tabel artikel dideklarasikan dalam SQL murni supaya bisa dibaca — dan dijalankan — tanpa PHP. Berikut database/schema.sql:

sql
-- schema.sql — struktur database website blog.
-- Aman dijalankan berulang: CREATE ... IF NOT EXISTS dan INSERT OR IGNORE
-- membuat skrip ini bisa dipakai sebagai prosedur "siapkan database"
-- tanpa takut menggandakan data.

CREATE TABLE IF NOT EXISTS posts (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    slug        TEXT    NOT NULL UNIQUE,
    judul       TEXT    NOT NULL,
    konten      TEXT    NOT NULL,
    dibuat_pada TEXT    NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_posts_dibuat_pada ON posts(dibuat_pada);

INSERT OR IGNORE INTO posts (slug, judul, konten, dibuat_pada) VALUES
    (
        'kenapa-php-masih-relevan',
        'Kenapa PHP Masih Relevan di 2026',
        'Bahasa yang menggerakkan sebagian besar web ternyata masih terus berkembang.

Rilis besar hadir setiap tahun, ekosistemnya matang, dan kinerjanya jauh melampaui reputasi lamanya.

Bagi kita, itu berarti alat yang sudah dikuasai tetap layak dipakai membangun aplikasi modern.',
        '2026-01-03 09:15:00'
    ),
    (
        'membangun-router-sederhana',
        'Membangun Router Sederhana Tanpa Framework',
        'Router pada framework favorit Anda sebenarnya hanyalah array asosiatif dan pencocokan string.

Kita buktikan dengan membangunnya sendiri dalam satu berkas kecil.

Hasilnya: routing yang bisa dibaca dari awal sampai akhir tanpa membaca dokumentasi framework.',
        '2026-01-10 14:40:00'
    ),
    (
        'tips-aman-menampilkan-data-pengguna',
        'Tips Aman Menampilkan Data Pengguna',
        'Setiap nilai yang berasal dari pengguna harus di-escape sebelum ditampilkan.

Satu helper kecil bernama e() menyelamatkan seluruh template Anda dari XSS.

Biasakan memakainya pada setiap titik keluaran, bukan hanya pada tempat yang terlihat berisiko.',
        '2026-01-17 20:05:00'
    );

Mari kita bedah keputusan-keputusan skemanya:

  1. id INTEGER PRIMARY KEY AUTOINCREMENT — di SQLite, kolom ini menjadi alias rowid yang terisi otomatis. AUTOINCREMENT menambah satu jaminan: nomor id yang sudah terpakai tidak akan pernah dipakai ulang setelah barisnya dihapus, mencegah tautan lama menunjuk artikel baru yang kebetulan dapat id daur ulang.
  2. slug TEXT NOT NULL UNIQUE — uniknya slug dijaga database, bukan hanya kode PHP. INSERT dengan slug kembar akan ditolak dengan UNIQUE constraint failed — galat yang kelak kita tangkap rapi di panel admin Bagian 4.
  3. CREATE INDEX ... ON posts(dibuat_pada) — indeks pada kolom pengurutan. Beranda dan daftar admin selalu memakai ORDER BY dibuat_pada; tanpa indeks, SQLite harus memindai seluruh tabel lalu mengurutkannya, setiap kali.
  4. IF NOT EXISTS dan INSERT OR IGNORE — menjadikan skrip idempoten: dijalankan berulang pun hasil akhirnya sama. Trik OR IGNORE bekerja berkat constraint UNIQUE pada slug — INSERT dengan slug yang sudah ada gagal secara diam, lalu diabaikan.

Kelas Database #

Seluruh pembuatan koneksi dipusatkan di satu class statis. Tidak ada pemanggil new PDO(...) yang tercecer di handler; bila lokasi database berubah, hanya berkas ini yang disentuh:

php
<?php

declare(strict_types=1);

namespace App;

use PDO;
use PDOException;
use RuntimeException;

/**
 * Database — pabrik koneksi PDO untuk blog.
 *
 * Seluruh aplikasi memanggil method statis di class ini; tidak ada
 * pemanggil `new PDO(...` yang tercerai di berbagai tempat. Bila suatu
 * hari lokasi database atau driver-nya berubah, hanya berkas ini yang
 * perlu disentuh.
 */
final class Database
{
    /**
     * Buka koneksi PDO ke SQLite.
     *
     * Lokasi database diambil dari parameter, lalu dari variabel
     * lingkungan BLOG_DB, lalu dari path bawaan data/blog.sqlite di
     * root proyek. Gunakan ':memory:' untuk database sementara di RAM
     * (praktis untuk pengujian).
     *
     * @throws PDOException bila koneksi gagal dibuka
     */
    public static function koneksi(?string $path = null): PDO
    {
        $path ??= getenv('BLOG_DB') ?: __DIR__ . '/../data/blog.sqlite';

        if ($path !== ':memory:' && !is_dir(dirname($path))) {
            mkdir(dirname($path), 0775, true);
        }

        $dsn = $path === ':memory:' ? 'sqlite::memory:' : 'sqlite:' . $path;

        $pdo = new PDO($dsn, null, null, [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        ]);
        $pdo->exec('PRAGMA foreign_keys = ON');

        return $pdo;
    }

    /**
     * Jalankan berkas schema.sql terhadap sebuah koneksi.
     *
     * Karena skema memakai IF NOT EXISTS dan INSERT OR IGNORE, method
     * ini aman dipanggil berulang kali pada database yang sudah berisi.
     */
    public static function muatSchema(PDO $pdo, ?string $berkas = null): void
    {
        $berkas ??= __DIR__ . '/../database/schema.sql';
        $sql = (string) file_get_contents($berkas);

        if ($sql === '') {
            throw new RuntimeException("Berkas schema tidak terbaca: {$berkas}");
        }

        $pdo->exec($sql);
    }

    /**
     * Siapkan database: buka koneksi lalu terapkan schema.sql.
     *
     * Dipakai sekali saat pertama kali menjalankan proyek (atau setelah
     * menghapus berkas database), termasuk untuk membuat data contoh.
     * Schema hanya dijalankan bila tabel posts belum ada, sehingga
     * request berikutnya tidak mengulang seed di setiap request.
     */
    public static function siapkan(?string $path = null): PDO
    {
        $pdo = self::koneksi($path);

        $sudahAda = $pdo->query(
            "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'posts'"
        )->fetchColumn() > 0;

        if (!$sudahAda) {
            self::muatSchema($pdo);
        }

        return $pdo;
    }
}

Tiga methodnya membentuk tangga yang nyaman. koneksi() memegang semua detail teknis: urutan prioritas lokasi database (parameter → variabel lingkungan BLOG_DB → path bawaan), pembuatan direktori data/ bila belum ada, DSN yang benar untuk :memory: versus berkas, serta dua atribut error/fetch mode yang kita bahas di awal. muatSchema() menjalankan isi schema.sql terhadap koneksi mana pun. siapkan() menggabungkan keduanya menjadi prosedur “pastikan database siap” — dan hanya menjalankan schema bila tabelnya belum ada, lewat satu query ke sqlite_master (katalog internal SQLite yang mencatat semua objek database). Berkat itu, menjalankan siapkan() di awal setiap request tetap murah: satu koneksi plus satu query kecil.

PostRepository Berbasis PDO #

Sekarang bintang bagiannya: src/PostRepository.php yang isinya diganti total. Method semua(), terbaru(), dan cariSlug() tetap ada dengan tanda tangan yang sama — hanya kini mereka berbicara SQL:

php
<?php

declare(strict_types=1);

namespace App;

use PDO;
use PDOException;

/**
 * PostRepository — lapisan akses data artikel blog (keadaan Bagian 3).
 *
 * Evolusi dari Bagian 2: array dalam memori diganti query PDO ke SQLite,
 * TANPA mengubah method yang sudah dipakai konsumen (semua, cariSlug,
 * terbaru). Dua method pengelolaan (simpan, hapus) dan satu pencarian
 * tambahan (cariId) disiapkan untuk panel admin pada Bagian 4.
 */
final class PostRepository
{
    public function __construct(private readonly PDO $pdo)
    {
    }

    /**
     * Seluruh artikel, terbaru duluan.
     *
     * @return list<array<string,mixed>>
     */
    public function semua(): array
    {
        return $this->pdo
            ->query('SELECT id, slug, judul, konten, dibuat_pada
                       FROM posts
                   ORDER BY dibuat_pada DESC, id DESC')
            ->fetchAll();
    }

    /**
     * N artikel terbaru.
     *
     * @return list<array<string,mixed>>
     */
    public function terbaru(int $n = 3): array
    {
        $stmt = $this->pdo->prepare('SELECT id, slug, judul, konten, dibuat_pada
                                       FROM posts
                                   ORDER BY dibuat_pada DESC, id DESC
                                      LIMIT ?');
        $stmt->bindValue(1, $n, PDO::PARAM_INT);
        $stmt->execute();

        return $stmt->fetchAll();
    }

    /**
     * Cari satu artikel berdasarkan slug-nya.
     *
     * @return array<string,mixed>|null null bila slug tidak dikenal
     */
    public function cariSlug(string $slug): ?array
    {
        $stmt = $this->pdo->prepare('SELECT id, slug, judul, konten, dibuat_pada
                                       FROM posts
                                      WHERE slug = ?');
        $stmt->execute([$slug]);
        $post = $stmt->fetch();

        return $post === false ? null : $post;
    }
}

Konstruktornya kini menerima PDO lewat constructor property promotion dengan readonly — repository tidak punya koneksi sendiri, ia dipasangi koneksi dari luar. Bandingkan dengan Bagian 2 yang membangun datanya sendiri di konstruktor: ketergantungan yang disuntikkan dari luar seperti ini bisa diganti-ganti, dan itulah yang membuat pengujian dengan :memory: sesederhana mengganti satu argumen.

Query pada terbaru() dan cariSlug() memakai prepared statement dua tahap: prepare() mengkompilasi pola SQL dengan penanda ?, lalu execute() mengirim nilai parameternya terpisah. Nilai tidak pernah disisipkan ke string SQL, sehingga tanda kutip atau -- pada input tidak bisa mengubah makna query — pertahanan pertama melawan SQL injection. Satu detail kecil yang sering terlewat: bindValue(1, $n, PDO::PARAM_INT) wajib memberi tahu PDO bahwa $n adalah integer; tanpa itu, nilai dikirim sebagai string dan sebagian database menolak LIMIT '3'.

Tiga method berikutnya melengkapi kebutuhan panel admin Bagian 4 — cariId() kembarannya cariSlug() dengan kunci id, simpan() memilih INSERT atau UPDATE dari ada-tidaknya id, dan hapus() menghapus berdasarkan id:

php
<?php

declare(strict_types=1);

namespace App;

use PDO;

final class PostRepository
{
    // ... konstruktor, semua(), terbaru(), cariSlug() seperti di atas ...

    /**
     * Cari satu artikel berdasarkan id-nya (untuk form ubah di admin).
     *
     * @return array<string,mixed>|null null bila id tidak dikenal
     */
    public function cariId(int $id): ?array
    {
        $stmt = $this->pdo->prepare('SELECT id, slug, judul, konten, dibuat_pada
                                       FROM posts
                                      WHERE id = ?');
        $stmt->execute([$id]);
        $post = $stmt->fetch();

        return $post === false ? null : $post;
    }

    /**
     * Simpan artikel: INSERT bila belum punya id, UPDATE bila punya.
     *
     * @param array<string,mixed> $post kolom slug, judul, konten,
     *        dibuat_pada — dan id untuk mode ubah
     *
     * @return int id artikel yang tersimpan
     *
     * @throws PDOException bila constraint dilanggar (mis. slug duplikat)
     */
    public function simpan(array $post): int
    {
        if (!empty($post['id'])) {
            $stmt = $this->pdo->prepare('UPDATE posts
                                           SET slug = ?, judul = ?, konten = ?, dibuat_pada = ?
                                         WHERE id = ?');
            $stmt->execute([
                $post['slug'],
                $post['judul'],
                $post['konten'],
                $post['dibuat_pada'],
                $post['id'],
            ]);

            return (int) $post['id'];
        }

        $stmt = $this->pdo->prepare('INSERT INTO posts (slug, judul, konten, dibuat_pada)
                                     VALUES (?, ?, ?, ?)');
        $stmt->execute([
            $post['slug'],
            $post['judul'],
            $post['konten'],
            $post['dibuat_pada'],
        ]);

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

    /**
     * Hapus artikel berdasarkan id-nya.
     */
    public function hapus(int $id): void
    {
        $stmt = $this->pdo->prepare('DELETE FROM posts WHERE id = ?');
        $stmt->execute([$id]);
    }
}

simpan() mengembalikan id artikel — untuk INSERT, nomornya diambil dari lastInsertId(). Method ini juga tempat constraint UNIQUE pada slug menjadi nyata: INSERT dengan slug yang sudah dipakai melempar PDOException, dan pemanggil (controller di Bagian 4) yang memutuskan bagaimana menyampaikannya ke pengguna.

Pergantian Tanpa Memutus Kontrak #

Inilah momen yang dijanjikan sejak Bagian 2. Di front controller, seluruh perubahannya satu baris — cara membuat repository:

php
<?php

use App\Database;
use App\PostRepository;

// Bagian 2: data hidup di memori, hilang tiap request.
$posts = new PostRepository();

// Bagian 3: data hidup di SQLite, bertahan antar request.
$posts = new PostRepository(Database::siapkan());

Handler halamanBeranda() tetap menulis $posts->terbaru(3); template tetap membaca $post['judul'] — karena PDO::FETCH_ASSOC menghasilkan array asosiatif dengan kunci yang sama seperti array buatan tangan kita. Konsumen tidak tahu dan tidak perlu tahu bahwa di baliknya kini ada SQL. Mari buktikan dengan database sungguhan di RAM:

php
<?php

require __DIR__ . '/src/Database.php';
require __DIR__ . '/src/PostRepository.php';

use App\Database;
use App\PostRepository;

// Database sementara di RAM — sempurna untuk mencoba tanpa menyentuh disk.
$pdo = Database::siapkan(':memory:');
$posts = new PostRepository($pdo);

echo "terbaru(2):\n";
foreach ($posts->terbaru(2) as $post) {
    echo "  - ", $post['slug'], "\n";
}

$post = $posts->cariSlug('kenapa-php-masih-relevan');
echo "cariSlug('kenapa-php-masih-relevan'):\n";
echo "  id            = ", $post['id'], "\n";
echo "  judul         = ", $post['judul'], "\n";
echo "  dibuat_pada   = ", $post['dibuat_pada'], "\n";

Output yang Dihasilkan:

text
terbaru(2):
  - tips-aman-menampilkan-data-pengguna
  - membangun-router-sederhana
cariSlug('kenapa-php-masih-relevan'):
  id            = 1
  judul         = Kenapa PHP Masih Relevan di 2026
  dibuat_pada   = 2026-01-03 09:15:00

:memory: menjalankan schema.sql lengkap dengan tiga artikel contohnya, jadi hasilnya langsung berisi data. Putaran penuh tulis-baca-ubah-hapus juga bisa diperiksa dari CLI:

php
<?php

require __DIR__ . '/src/Database.php';
require __DIR__ . '/src/PostRepository.php';

use App\Database;
use App\PostRepository;

$posts = new PostRepository(Database::siapkan(':memory:'));

// simpan() tanpa id -> INSERT, mengembalikan id baru.
$id = $posts->simpan([
    'slug' => 'belajar-pdo-dari-nol',
    'judul' => 'Belajar PDO dari Nol',
    'konten' => 'PDO menyatukan akses banyak database di satu antarmuka.',
    'dibuat_pada' => '2026-01-22 10:00:00',
]);
echo "INSERT  : artikel baru tersimpan dengan id = ", $id, "\n";

// Query balik lewat slug.
$post = $posts->cariSlug('belajar-pdo-dari-nol');
echo "QUERY   : ", $post['judul'], " (", $post['dibuat_pada'], ")\n";

// simpan() dengan id -> UPDATE pada artikel yang sama.
$posts->simpan([
    'id' => $id,
    'slug' => 'belajar-pdo-dari-nol',
    'judul' => 'Belajar PDO dari Nol (edisi revisi)',
    'konten' => 'Konten revisi.',
    'dibuat_pada' => '2026-01-22 10:00:00',
]);
echo "UPDATE  : ", $posts->cariId($id)['judul'], "\n";

// hapus() lalu pastikan benar-benar hilang.
$posts->hapus($id);
echo "DELETE  : ";
echo $posts->cariSlug('belajar-pdo-dari-nol') === null
    ? "slug tidak ditemukan lagi\n"
    : "masih ada (ada yang salah!)\n";
echo "TOTAL   : ", count($posts->semua()), " artikel\n";

Output yang Dihasilkan:

text
INSERT  : artikel baru tersimpan dengan id = 4
QUERY   : Belajar PDO dari Nol (2026-01-22 10:00:00)
UPDATE  : Belajar PDO dari Nol (edisi revisi)
DELETE  : slug tidak ditemukan lagi
TOTAL   : 3 artikel

Empat operasi CRUD berjalan dalam satu proses, dan karena database ini hanya hidup di RAM, tidak ada berkas yang perlu dibersihkan setelahnya.

Menguji dengan Web Server Bawaan #

Dengan baris $posts = new PostRepository(Database::siapkan()); diterapkan di public/index.php, nyalakan server dari root proyek:

bash
# terminal 1 — dari direktori root proyek
$ php -S 127.0.0.1:8093 public/index.php

Request pertama otomatis menyiapkan semuanya: siapkan() membuat direktori data/, berkas blog.sqlite, tabel, dan tiga artikel contoh:

bash
# terminal 2
$ curl -sS http://127.0.0.1:8093/ | grep '<h3>'
text
        <h3><a href="/post/tips-aman-menampilkan-data-pengguna">Tips Aman Menampilkan Data Pengguna</a></h3>
        <h3><a href="/post/membangun-router-sederhana">Membangun Router Sederhana Tanpa Framework</a></h3>
        <h3><a href="/post/kenapa-php-masih-relevan">Kenapa PHP Masih Relevan di 2026</a></h3>
bash
$ ls -la data/
text
total 28
drwxrwxr-x 2 z z 4096 Oct 13 21:53 .
drwxrwxr-x 7 z z 4096 Oct 13 21:53 ..
-rw-r--r-- 1 z z 20480 Oct 13 21:53 blog.sqlite

Ujian sebenarnya dari persistensi: tulis data dari proses PHP yang sama sekali terpisah dari web server, lalu minta beranda lagi tanpa me-restart apa pun:

bash
$ php -r '
$pdo = new PDO("sqlite:data/blog.sqlite", null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$pdo->exec("INSERT INTO posts (slug, judul, konten, dibuat_pada) VALUES (\"uji-persistensi-sqlite\", \"Uji Persistensi SQLite\", \"Artikel ini ditulis oleh proses PHP yang terpisah dari web server.\", \"2026-01-20 07:30:00\")");
echo "terinsert\n";'
text
terinsert
bash
$ curl -sS http://127.0.0.1:8093/ | grep '<h3>'
text
        <h3><a href="/post/uji-persistensi-sqlite">Uji Persistensi SQLite</a></h3>
        <h3><a href="/post/tips-aman-menampilkan-data-pengguna">Tips Aman Menampilkan Data Pengguna</a></h3>
        <h3><a href="/post/kenapa-php-masih-relevan">Kenapa PHP Masih Relevan di 2026</a></h3>

Artikel baru langsung tampil di posisi teratas karena dibuat_pada-nya paling baru — dua proses berbeda kini membaca dan menulis satu sumber kebenaran yang sama. Halaman detailnya pun hidup:

bash
$ curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8093/post/uji-persistensi-sqlite
text
200

Apa yang Berubah #

Tiga berkas baru dan satu baris yang berubah:

  • database/schema.sql — tabel posts dengan id AUTOINCREMENT, slug UNIQUE, indeks dibuat_pada, dan tiga artikel contoh; idempoten berkat IF NOT EXISTS dan INSERT OR IGNORE.
  • src/Database.php — pabrik koneksi PDO: prioritas lokasi parameter → BLOG_DB → data/blog.sqlite, error mode exception, fetch mode asosiatif, plus muatSchema() dan siapkan().
  • src/PostRepository.php — isi diganti query PDO: API lama (semua, terbaru, cariSlug) dipertahankan, method baru cariId, simpan, dan hapus disiapkan untuk admin.
  • public/index.php — satu-satunya baris yang berubah: new PostRepository(Database::siapkan()).

Blog kini bisa menyimpan, tetapi satu-satunya cara menulis artikel masih menyunting SQL dengan tangan. Bagian 4 membangun panel admin — layar daftar, form tulis, form ubah, dan tombol hapus — di atas method simpan() dan hapus() yang barusan kita uji.

Cara Menjalankan #

Dari direktori bagian-3-database-dan-persistensi/ (folder bundle halaman ini):

bash
# 1. Query langsung dengan database di RAM — tanpa web server
php -r 'require "src/Database.php"; require "src/PostRepository.php"; $p = new App\PostRepository(App\Database::siapkan(":memory:")); foreach ($p->semua() as $post) echo $post["slug"], "\n";'

# 2. Gabungkan dengan berkas Bagian 1-2, lalu nyalakan server
php -S 127.0.0.1:8093 public/index.php
curl -sS http://127.0.0.1:8093/post/kenapa-php-masih-relevan

# 3. Pemeriksaan sintaks berkas baru
php -l src/Database.php && php -l src/PostRepository.php

Tiga berkas pendukung halaman ini — database/schema.sql, src/Database.php, dan src/PostRepository.php — tersedia di bagian bawah halaman. Untuk pendalaman, baca dokumentasi resmi tentang PDO , koneksi PDO , prepared statements dan parameter binding , serta lastInsertId() . Dengan data yang kini bertahan, kita siap memberi blog ini layar kelola artikel pada Bagian 4.

Berkas Sumber

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

database/schema.sql

Unduh berkas
sql
-- schema.sql — struktur database website blog.
-- Aman dijalankan berulang: CREATE ... IF NOT EXISTS dan INSERT OR IGNORE
-- membuat skrip ini bisa dipakai sebagai prosedur "siapkan database"
-- tanpa takut menggandakan data.

CREATE TABLE IF NOT EXISTS posts (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    slug        TEXT    NOT NULL UNIQUE,
    judul       TEXT    NOT NULL,
    konten      TEXT    NOT NULL,
    dibuat_pada TEXT    NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_posts_dibuat_pada ON posts(dibuat_pada);

INSERT OR IGNORE INTO posts (slug, judul, konten, dibuat_pada) VALUES
    (
        'kenapa-php-masih-relevan',
        'Kenapa PHP Masih Relevan di 2026',
        'Bahasa yang menggerakkan sebagian besar web ternyata masih terus berkembang.

Rilis besar hadir setiap tahun, ekosistemnya matang, dan kinerjanya jauh melampaui reputasi lamanya.

Bagi kita, itu berarti alat yang sudah dikuasai tetap layak dipakai membangun aplikasi modern.',
        '2026-01-03 09:15:00'
    ),
    (
        'membangun-router-sederhana',
        'Membangun Router Sederhana Tanpa Framework',
        'Router pada framework favorit Anda sebenarnya hanyalah array asosiatif dan pencocokan string.

Kita buktikan dengan membangunnya sendiri dalam satu berkas kecil.

Hasilnya: routing yang bisa dibaca dari awal sampai akhir tanpa membaca dokumentasi framework.',
        '2026-01-10 14:40:00'
    ),
    (
        'tips-aman-menampilkan-data-pengguna',
        'Tips Aman Menampilkan Data Pengguna',
        'Setiap nilai yang berasal dari pengguna harus di-escape sebelum ditampilkan.

Satu helper kecil bernama e() menyelamatkan seluruh template Anda dari XSS.

Biasakan memakainya pada setiap titik keluaran, bukan hanya pada tempat yang terlihat berisiko.',
        '2026-01-17 20:05:00'
    );

src/Database.php

Unduh berkas
php
<?php

declare(strict_types=1);

namespace App;

use PDO;
use PDOException;
use RuntimeException;

/**
 * Database — pabrik koneksi PDO untuk blog.
 *
 * Seluruh aplikasi memanggil method statis di class ini; tidak ada
 * pemanggil `new PDO(...` yang tercerai di berbagai tempat. Bila suatu
 * hari lokasi database atau driver-nya berubah, hanya berkas ini yang
 * perlu disentuh.
 */
final class Database
{
    /**
     * Buka koneksi PDO ke SQLite.
     *
     * Lokasi database diambil dari parameter, lalu dari variabel
     * lingkungan BLOG_DB, lalu dari path bawaan data/blog.sqlite di
     * root proyek. Gunakan ':memory:' untuk database sementara di RAM
     * (praktis untuk pengujian).
     *
     * @throws PDOException bila koneksi gagal dibuka
     */
    public static function koneksi(?string $path = null): PDO
    {
        $path ??= getenv('BLOG_DB') ?: __DIR__ . '/../data/blog.sqlite';

        if ($path !== ':memory:' && !is_dir(dirname($path))) {
            mkdir(dirname($path), 0775, true);
        }

        $dsn = $path === ':memory:' ? 'sqlite::memory:' : 'sqlite:' . $path;

        $pdo = new PDO($dsn, null, null, [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        ]);
        $pdo->exec('PRAGMA foreign_keys = ON');

        return $pdo;
    }

    /**
     * Jalankan berkas schema.sql terhadap sebuah koneksi.
     *
     * Karena skema memakai IF NOT EXISTS dan INSERT OR IGNORE, method
     * ini aman dipanggil berulang kali pada database yang sudah berisi.
     */
    public static function muatSchema(PDO $pdo, ?string $berkas = null): void
    {
        $berkas ??= __DIR__ . '/../database/schema.sql';
        $sql = (string) file_get_contents($berkas);

        if ($sql === '') {
            throw new RuntimeException("Berkas schema tidak terbaca: {$berkas}");
        }

        $pdo->exec($sql);
    }

    /**
     * Siapkan database: buka koneksi lalu terapkan schema.sql.
     *
     * Dipakai sekali saat pertama kali menjalankan proyek (atau setelah
     * menghapus berkas database), termasuk untuk membuat data contoh.
     * Schema hanya dijalankan bila tabel posts belum ada, sehingga
     * request berikutnya tidak mengulang seed di setiap request.
     */
    public static function siapkan(?string $path = null): PDO
    {
        $pdo = self::koneksi($path);

        $sudahAda = $pdo->query(
            "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'posts'"
        )->fetchColumn() > 0;

        if (!$sudahAda) {
            self::muatSchema($pdo);
        }

        return $pdo;
    }
}

src/PostRepository.php

Unduh berkas
php
<?php

declare(strict_types=1);

namespace App;

use PDO;
use PDOException;

/**
 * PostRepository — lapisan akses data artikel blog (keadaan Bagian 3).
 *
 * Evolusi dari Bagian 2: array dalam memori diganti query PDO ke SQLite,
 * TANPA mengubah method yang sudah dipakai konsumen (semua, cariSlug,
 * terbaru). Dua method pengelolaan (simpan, hapus) dan satu pencarian
 * tambahan (cariId) disiapkan untuk panel admin pada Bagian 4.
 */
final class PostRepository
{
    public function __construct(private readonly PDO $pdo)
    {
    }

    /**
     * Seluruh artikel, terbaru duluan.
     *
     * @return list<array<string,mixed>>
     */
    public function semua(): array
    {
        return $this->pdo
            ->query('SELECT id, slug, judul, konten, dibuat_pada
                       FROM posts
                   ORDER BY dibuat_pada DESC, id DESC')
            ->fetchAll();
    }

    /**
     * N artikel terbaru.
     *
     * @return list<array<string,mixed>>
     */
    public function terbaru(int $n = 3): array
    {
        $stmt = $this->pdo->prepare('SELECT id, slug, judul, konten, dibuat_pada
                                       FROM posts
                                   ORDER BY dibuat_pada DESC, id DESC
                                      LIMIT ?');
        $stmt->bindValue(1, $n, PDO::PARAM_INT);
        $stmt->execute();

        return $stmt->fetchAll();
    }

    /**
     * Cari satu artikel berdasarkan slug-nya.
     *
     * @return array<string,mixed>|null null bila slug tidak dikenal
     */
    public function cariSlug(string $slug): ?array
    {
        $stmt = $this->pdo->prepare('SELECT id, slug, judul, konten, dibuat_pada
                                       FROM posts
                                      WHERE slug = ?');
        $stmt->execute([$slug]);
        $post = $stmt->fetch();

        return $post === false ? null : $post;
    }

    /**
     * Cari satu artikel berdasarkan id-nya (untuk form ubah di admin).
     *
     * @return array<string,mixed>|null null bila id tidak dikenal
     */
    public function cariId(int $id): ?array
    {
        $stmt = $this->pdo->prepare('SELECT id, slug, judul, konten, dibuat_pada
                                       FROM posts
                                      WHERE id = ?');
        $stmt->execute([$id]);
        $post = $stmt->fetch();

        return $post === false ? null : $post;
    }

    /**
     * Simpan artikel: INSERT bila belum punya id, UPDATE bila punya.
     *
     * @param array<string,mixed> $post kolom slug, judul, konten,
     *        dibuat_pada — dan id untuk mode ubah
     *
     * @return int id artikel yang tersimpan
     *
     * @throws PDOException bila constraint dilanggar (mis. slug duplikat)
     */
    public function simpan(array $post): int
    {
        if (!empty($post['id'])) {
            $stmt = $this->pdo->prepare('UPDATE posts
                                           SET slug = ?, judul = ?, konten = ?, dibuat_pada = ?
                                         WHERE id = ?');
            $stmt->execute([
                $post['slug'],
                $post['judul'],
                $post['konten'],
                $post['dibuat_pada'],
                $post['id'],
            ]);

            return (int) $post['id'];
        }

        $stmt = $this->pdo->prepare('INSERT INTO posts (slug, judul, konten, dibuat_pada)
                                     VALUES (?, ?, ?, ?)');
        $stmt->execute([
            $post['slug'],
            $post['judul'],
            $post['konten'],
            $post['dibuat_pada'],
        ]);

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

    /**
     * Hapus artikel berdasarkan id-nya.
     */
    public function hapus(int $id): void
    {
        $stmt = $this->pdo->prepare('DELETE FROM posts WHERE id = ?');
        $stmt->execute([$id]);
    }
}