Routing dari Bagian 2 sudah mengarahkan request ke handler yang tepat, tetapi handler masih mengarang data dari ingatan — begitu proses berakhir, semuanya hilang. Pada Bagian 3 kita menghubungkan API ke SQLite lewat PDO: satu berkas database/schema.sql mendefinisikan tabel tasks, kelas Database menjadi satu-satunya pabrik koneksi, dan TaskRepository merangkum seluruh SQL sehingga handler cukup memanggil method berorientasi objek seperti cariId() atau buat(). Karena repository mengembalikan baris berupa array asosiatif, hasilnya tinggal dibungkus Response::json() tanpa konversi tambahan.

Skema Tabel Tasks #

Satu tabel sudah mencukupi kebutuhan proyek ini. Kolom status dibatasi constraint CHECK agar hanya tiga nilai resmi yang bisa masuk — database ikut menjaga integritas meski ada bug di lapisan aplikasi:

sql
-- database/schema.sql
CREATE TABLE tasks (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    judul       TEXT NOT NULL,
    status      TEXT NOT NULL DEFAULT 'belum'
                CHECK (status IN ('belum', 'proses', 'selesai')),
    dibuat_pada TEXT NOT NULL
);

INSERT INTO tasks (judul, status, dibuat_pada) VALUES
    ('Belajar PHP dasar', 'selesai', '2026-01-05 08:00:00'),
    ('Merancang REST API', 'proses', '2026-01-10 09:30:00'),
    ('Menulis dokumentasi API', 'belum', '2026-01-12 14:15:00');

Empat kolom sengaja dipilih minimal: id sebagai primary key yang menaik otomatis, judul sebagai teks wajib, status dengan nilai default belum, dan dibuat_pada sebagai waktu pembuatan berformat TEXT (YYYY-MM-DD HH:MM:SS) — format serupa ISO 8601 yang mudah dibaca manusia dan terurut benar sebagai string. Tiga baris INSERT di bagian bawah adalah seed data dengan waktu tetap, sehingga setiap database baru selalu berisi contoh yang sama dan contoh-contoh halaman ini deterministik.

Factory PDO di Kelas Database #

Seluruh kode proyek hanya mengenal PDO, bukan driver spesifik, sehingga berpindah ke MySQL atau PostgreSQL kelak cukup mengganti DSN di satu tempat. Kelas Database menyediakan dua mode koneksi yang keduanya penting:

  • In-memory (Database::buat() tanpa argumen) — database kosong baru setiap kali koneksi dibuat; sempurna untuk pengujian karena selalu bersih dan cepat.
  • Berkas (Database::buat('/path/tasks.sqlite')) — data bertahan antar proses; wajib dipakai saat melayani HTTP karena setiap request adalah proses PHP yang berbeda.
php
<?php

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

use App\Database;
use App\TaskRepository;

$pdo = Database::buat();
Database::migrasi($pdo, __DIR__ . '/database/schema.sql');

$repo = new TaskRepository($pdo);

echo json_encode(
    $repo->semua(),
    JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
), "\n";

Output yang Dihasilkan:

text
[
    {
        "id": 1,
        "judul": "Belajar PHP dasar",
        "status": "selesai",
        "dibuat_pada": "2026-01-05 08:00:00"
    },
    {
        "id": 2,
        "judul": "Merancang REST API",
        "status": "proses",
        "dibuat_pada": "2026-01-10 09:30:00"
    },
    {
        "id": 3,
        "judul": "Menulis dokumentasi API",
        "status": "belum",
        "dibuat_pada": "2026-01-12 14:15:00"
    }
]

Alur tiga baris di atas adalah pola standar setup: Database::buat() membangun koneksi dengan dua pengaturan penting — ERRMODE_EXCEPTION membuat kesalahan SQL melempar PDOException alih-alih diam, dan FETCH_ASSOC menjadikan array asosiatif mode fetch default. Database::migrasi() kemudian mengeksekusi isi schema.sql (membuat tabel sekaligus mengisi seed data), dan TaskRepository dibungkus di sekeliling koneksi tersebut. Perhatikan bahwa hasil semua() langsung bisa di-json_encode — kolom-kolomnya persis field JSON yang ingin kita kirim ke klien.

TaskRepository dengan Prepared Statements #

Repository menyembunyikan SQL dari handler. Lima method publiknya memetakan ke kebutuhan CRUD: semua() untuk daftar, cariId() untuk detail, buat() untuk menambah, perbarui() untuk mengubah sebagian, dan hapus() untuk menghapus. Setiap query memakai prepared statement dengan parameter terikat — nilai apa pun dari pengguna tidak pernah digabung langsung ke string SQL.

Siklus hidup lengkap sebuah tugas bisa kita jalankan dari CLI. Satu-satunya kolom yang perlu diperlakukan hati-hati adalah dibuat_pada milik baris baru: nilainya diisi date() saat insert, jadi contoh ini mencetak kolom lain agar hasilnya tetap deterministik:

php
<?php

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

use App\Database;
use App\TaskRepository;

$pdo = Database::buat();
Database::migrasi($pdo, __DIR__ . '/database/schema.sql');
$repo = new TaskRepository($pdo);

$baru = $repo->buat(['judul' => 'Belajar PDO', 'status' => 'proses']);
printf("buat     : id=%d judul=%s status=%s\n", $baru['id'], $baru['judul'], $baru['status']);

$ubah = $repo->perbarui(2, ['status' => 'selesai']);
printf("perbarui : id=%d judul=%s status=%s\n", $ubah['id'], $ubah['judul'], $ubah['status']);

var_dump($repo->cariId(99));
var_dump($repo->hapus(1));
var_dump($repo->hapus(1));

$tanpaWaktu = array_map(
    static fn (array $t): array => ['id' => $t['id'], 'status' => $t['status']],
    $repo->semua(),
);
echo 'semua    : ', json_encode($tanpaWaktu, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), "\n";

Output yang Dihasilkan:

text
buat     : id=4 judul=Belajar PDO status=proses
perbarui : id=2 judul=Merancang REST API status=selesai
NULL
bool(true)
bool(false)
semua    : [{"id":2,"status":"selesai"},{"id":3,"status":"belum"},{"id":4,"status":"proses"}]

Mari kita bedah perilaku tiap method dari hasil di atas:

  1. buat() mengembalikan baris lengkap hasil cariId() pada id baru — pola “insert lalu baca ulang” memastikan handler menerima bentuk data yang sama dengan endpoint detail. Id yang dihasilkan adalah 4 karena tiga seed data menempati id 1 sampai 3.
  2. perbarui(2, ['status' => 'selesai']) hanya mengubah kolom yang diberikan; kunci judul yang tidak dikirim membuat judul lama tetap dipertahankan. Inilah dasar perilaku partial update untuk method PUT nantinya.
  3. cariId(99) mengembalikan NULL — penanda bersih untuk “tidak ada” yang akan diterjemahkan handler menjadi response 404 pada Bagian 4.
  4. hapus(1) mengembalikan true sekali lalu false pada panggilan kedua, karena rowCount() melaporkan jumlah baris yang benar-benar terhapus. Handler tinggal memetakan false menjadi 404.
  5. semua() di akhir memperlihatkan akibat operasi di atas: tugas id 1 sudah terhapus, id 2 berstatus selesai, dan id 4 adalah tugas baru.

Batasan yang Dijaga Database #

Constraint CHECK pada kolom status bukan hiasan. Ketika aplikasi (atau penyerang) mencoba menyelundupkan nilai di luar daftar resmi, SQLite menolaknya dengan PDOException alih-alih menyimpan data rusak:

php
<?php

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

use App\Database;
use App\TaskRepository;

$pdo = Database::buat();
Database::migrasi($pdo, __DIR__ . '/database/schema.sql');
$repo = new TaskRepository($pdo);

try {
    $repo->buat(['judul' => 'Status bohong', 'status' => 'ngaco']);
} catch (PDOException $e) {
    echo 'Ditolak database: ', $e->getMessage(), "\n";
}

Output yang Dihasilkan:

text
Ditolak database: SQLSTATE[23000]: Integrity constraint violation: 19 CHECK constraint failed: status IN ('belum', 'proses', 'selesai')

Pesan CHECK constraint failed dari SQLite tersebut adalah jaring pengaman terakhir. Lapisan validasi di Bagian 4 akan menangkap input seperti ini jauh lebih awal dengan pesan yang ramah pengguna; constraint database tetap bernilai untuk kasus ketika data masuk dari jalur lain — skrip impor, migrasi, atau bug di masa depan.

In-memory itu Per-Proses #

Satu jebakan penting SQLite perlu dibuktikan sebelum kita memakainya lewat HTTP: database in-memory hidup dan mati bersama koneksinya. Dua panggilan Database::buat() menghasilkan dua dunia yang saling tidak kenal:

php
<?php

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

use App\Database;
use App\TaskRepository;

$pdoA = Database::buat();
Database::migrasi($pdoA, __DIR__ . '/database/schema.sql');
$repoA = new TaskRepository($pdoA);
$repoA->buat(['judul' => 'Hanya hidup di koneksi pertama']);

$pdoB = Database::buat();
try {
    $pdoB->query('SELECT COUNT(*) FROM tasks')->fetchColumn();
} catch (PDOException $e) {
    echo 'Koneksi kedua: ', $e->getMessage(), "\n";
}

echo 'Koneksi pertama masih utuh: ', count($repoA->semua()), " tugas\n";

Output yang Dihasilkan:

text
Koneksi kedua: SQLSTATE[HY000]: General error: 1 no such table: tasks
Koneksi pertama masih utuh: 4 tugas

Koneksi kedua bahkan belum mengenali tabel tasks — bukan hanya kosong, tetapi benar-benar database yang berbeda. Konsekuensinya langsung: web server menjalankan satu proses PHP per request, jadi mode in-memory akan “lupa” semua data antar request. Saat API berjalan lewat HTTP, kita wajib memakai database berkas melalui argumen Database::buat() atau variabel lingkungan TASK_API_DB.

Apa yang Berubah #

Tiga berkas baru masuk ke proyek pada tahap ini:

  • database/schema.sql — tabel tasks dengan constraint CHECK untuk status dan tiga seed data berwaktu tetap.
  • src/Database.php — factory PDO dengan mode in-memory dan berkas (mendukung TASK_API_DB), plus migrasi() untuk mengeksekusi berkas skema.
  • src/TaskRepository.php — lima method CRUD (semua, cariId, buat, perbarui, hapus) yang seluruhnya memakai prepared statements dan mengembalikan array siap JSON.

Handler belum menyentuh repository — perangkat sudah siap, tinggal dirangkai. Bagian 4 menyambungkan router, repository, dan lapisan validasi menjadi endpoint POST /api/tugas yang benar-benar menyimpan data beserta format error yang konsisten.

Cara Menjalankan #

Seluruh contoh di halaman ini berjalan murni dari CLI tanpa web server:

bash
# dari direktori proyek — siapkan database in-memory lalu jalankan siklus CRUD
php -r 'require "src/Database.php"; require "src/TaskRepository.php";
        $pdo = App\Database::buat();
        App\Database::migrasi($pdo, "database/schema.sql");
        $repo = new App\TaskRepository($pdo);
        echo count($repo->semua()), " tugas siap\n";'

# pemeriksaan sintaks kedua kelas baru
php -l src/Database.php && php -l src/TaskRepository.php

Tiga berkas pendukung halaman ini — src/Database.php, src/TaskRepository.php, dan database/schema.sql — tersedia di bagian bawah halaman. Untuk pendalaman, dokumentasi resmi yang relevan adalah buku PDO , koneksi dan DSN , prepared statements , serta SQLite . Dengan lapisan data yang berdiri, kita lanjut merangkainya menjadi API yang menolak input sampah dengan sopan pada Bagian 4.

Berkas Sumber

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

database/schema.sql

Unduh berkas
sql
-- Skema database REST API Manajemen Tugas (SQLite).
--
-- Satu tabel tasks menyimpan seluruh data tugas. Kolom status dibatasi
-- lewat constraint CHECK agar hanya boleh berisi tiga nilai resmi, dan
-- seed data di bagian bawah memberi kita data awal yang deterministik
-- untuk latihan dan pengujian.

CREATE TABLE tasks (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    judul       TEXT NOT NULL,
    status      TEXT NOT NULL DEFAULT 'belum'
                CHECK (status IN ('belum', 'proses', 'selesai')),
    dibuat_pada TEXT NOT NULL
);

INSERT INTO tasks (judul, status, dibuat_pada) VALUES
    ('Belajar PHP dasar', 'selesai', '2026-01-05 08:00:00'),
    ('Merancang REST API', 'proses', '2026-01-10 09:30:00'),
    ('Menulis dokumentasi API', 'belum', '2026-01-12 14:15:00');

src/Database.php

Unduh berkas
php
<?php
declare(strict_types=1);

namespace App;

use PDO;

/**
 * Factory koneksi PDO untuk SQLite.
 *
 * Seluruh kode proyek hanya mengenal PDO, bukan driver spesifik, sehingga
 * berpindah ke MySQL atau PostgreSQL cukup mengganti DSN di satu tempat ini.
 * Dua mode koneksi tersedia:
 *
 *  - berkas  : Database::buat('/path/demo.sqlite') — data bertahan antar
 *              proses, dipakai saat menjalankan web server;
 *  - memori  : Database::buat() — database baru setiap proses, ideal untuk
 *              pengujian karena selalu bersih dan deterministik.
 *
 * Variabel lingkungan TASK_API_DB dapat menetapkan path berkas secara
 * global; argumen eksplisit tetap menang atas variabel lingkungan.
 */
final class Database
{
    /**
     * Bangun koneksi PDO SQLite dengan pengaturan yang aman.
     *
     * @param string|null $path path berkas database; null berarti pakai
     *                          variabel lingkungan TASK_API_DB, atau
     *                          database in-memory bila variabel itu kosong.
     */
    public static function buat(?string $path = null): PDO
    {
        if ($path === null) {
            $dariEnv = getenv('TASK_API_DB');
            $path = is_string($dariEnv) && $dariEnv !== '' ? $dariEnv : null;
        }

        $dsn = $path === null ? '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 skema SQL (CREATE TABLE + data awal) pada koneksi.
     */
    public static function migrasi(PDO $pdo, string $berkasSkema): void
    {
        if (!is_file($berkasSkema)) {
            throw new \InvalidArgumentException("Berkas skema tidak ditemukan: $berkasSkema");
        }

        $pdo->exec((string) file_get_contents($berkasSkema));
    }
}

src/TaskRepository.php

Unduh berkas
php
<?php
declare(strict_types=1);

namespace App;

use PDO;

/**
 * Repository tabel tasks di atas PDO.
 *
 * Repository menyembunyikan SQL dari handler: handler hanya memanggil
 * method seperti cariId() atau buat() dan menerima array baris yang siap
 * di-json_encode. Semua query memakai prepared statements dengan parameter
 * terikat, sehingga bebas dari SQL injection.
 */
final class TaskRepository
{
    public function __construct(private readonly PDO $pdo)
    {
    }

    /**
     * Ambil seluruh tugas, terurut menaik berdasarkan id.
     *
     * @return list<array<string, mixed>>
     */
    public function semua(): array
    {
        $stmt = $this->pdo->query(
            'SELECT id, judul, status, dibuat_pada FROM tasks ORDER BY id',
        );

        return $stmt->fetchAll();
    }

    /**
     * Cari satu tugas berdasarkan id.
     *
     * @return array<string, mixed>|null baris tugas, atau null bila tidak ada.
     */
    public function cariId(int $id): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, judul, status, dibuat_pada FROM tasks WHERE id = :id',
        );
        $stmt->execute(['id' => $id]);
        $baris = $stmt->fetch();

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

    /**
     * Simpan tugas baru dan kembalikan baris lengkapnya termasuk id.
     *
     * Kolom dibuat_pada diisi server dengan waktu saat ini agar client
     * tidak bisa memalsukan waktu pembuatan.
     *
     * @param array<string, mixed> $data minimal berisi kunci 'judul'.
     *
     * @return array<string, mixed>
     */
    public function buat(array $data): array
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO tasks (judul, status, dibuat_pada) '
            . 'VALUES (:judul, :status, :dibuat_pada)',
        );
        $stmt->execute([
            'judul' => (string) $data['judul'],
            'status' => (string) ($data['status'] ?? 'belum'),
            'dibuat_pada' => date('Y-m-d H:i:s'),
        ]);

        $idBaru = (int) $this->pdo->lastInsertId();

        return (array) $this->cariId($idBaru);
    }

    /**
     * Perbarui tugas sebagian atau seluruhnya.
     *
     * Hanya kunci 'judul' dan 'status' yang diperhatikan; kunci yang tidak
     * ada membiarkan nilai lama tetap. Mengembalikan baris terbaru, atau
     * null bila id tidak ditemukan.
     *
     * @param array<string, mixed> $data
     *
     * @return array<string, mixed>|null
     */
    public function perbarui(int $id, array $data): ?array
    {
        $tugas = $this->cariId($id);
        if ($tugas === null) {
            return null;
        }

        $judul = array_key_exists('judul', $data) ? (string) $data['judul'] : $tugas['judul'];
        $status = array_key_exists('status', $data) ? (string) $data['status'] : $tugas['status'];

        $stmt = $this->pdo->prepare(
            'UPDATE tasks SET judul = :judul, status = :status WHERE id = :id',
        );
        $stmt->execute(['judul' => $judul, 'status' => $status, 'id' => $id]);

        return $this->cariId($id);
    }

    /**
     * Hapus satu tugas.
     *
     * @return bool true bila ada baris yang terhapus, false bila id tidak ada.
     */
    public function hapus(int $id): bool
    {
        $stmt = $this->pdo->prepare('DELETE FROM tasks WHERE id = :id');
        $stmt->execute(['id' => $id]);

        return $stmt->rowCount() > 0;
    }
}