Skema dari Bagian 1 sudah berdiri dan aturannya terbukti; sekarang giliran kode PHP yang bekerja di atasnya. Kebutuhan operasionalnya klasik: menyimpan kontak baru, mencari berdasarkan id, menampilkan daftar, memperbarui, dan menghapus — lima operasi CRUD. Pada bagian ini seluruh operasi tersebut kita pusatkan ke satu class ContactRepository yang menerima koneksi PDO dari luar dan hanya berbicara lewat prepared statements. Pola repository-nya sendiri sudah pernah kita bangun pada chapter Repository Pattern
; di sini pola itu menjadi tulang punggung proyek yang sesungguhnya.
Repository sebagai Satu Pintu Data #
Tanpa repository, string SQL menyebar ke setiap skrip yang membutuhkannya, dan setiap salinan adalah titik rawan: lupa prepared statement di satu tempat, celah SQL injection terbuka kembali. Dengan repository, seluruh query untuk tabel contacts hidup di satu class, dan kode aplikasi hanya memanggil method bernama operasi bisnis — simpan(), cariId(), semua() — tanpa pernah menyusun SQL sendiri. Konsekuensi positifnya terasa sampai Bagian 5 nanti: karena SQL terpusat, test otomatis cukup mengarahkan satu class ini ke database :memory: untuk memverifikasi seluruh lapisan data.
Koneksi database tidak dibuat di dalam class. Constructor menerima objek PDO dari pemanggil lewat dependency injection, sehingga repository tidak perlu tahu DSN, berkas, maupun kredensial — aplikasi sungguhan menyuntikkan koneksi ke kontak.sqlite, sementara test menyuntikkan sqlite::memory: yang musnah sendiri.
Class ContactRepository #
Berikut isi lengkap ContactRepository.php sebagaimana tersedia pada berkas sumber di bagian bawah halaman:
<?php
declare(strict_types=1);
/**
* Lapisan akses data untuk tabel contacts.
*
* Seluruh query SQL aplikasi kontak dipusatkan di class ini.
* Koneksi PDO disuntikkan lewat constructor, sehingga pemanggil-lah
* yang menentukan database yang dipakai: berkas di disk atau database
* sementara `sqlite::memory:`.
*/
final class ContactRepository
{
public function __construct(private PDO $pdo)
{
// Pemeriksaan foreign key SQLite nonaktif secara default.
// Menyalakannya di sini adalah kebiasaan murah yang menjaga
// integritas begitu tabel berelasi ditambahkan kelak.
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
/**
* Simpan kontak baru, kembalikan id baris yang baru dibuat.
*/
public function simpan(string $nama, string $email, string $telepon): int
{
$stmt = $this->pdo->prepare(
'INSERT INTO contacts (nama, email, telepon) VALUES (?, ?, ?)'
);
$stmt->execute([$nama, $email, $telepon]);
return (int) $this->pdo->lastInsertId();
}
/**
* Ambil satu kontak berdasarkan id; null bila tidak ditemukan.
*
* @return array<string, mixed>|null
*/
public function cariId(int $id): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id, nama, email, telepon, created_at
FROM contacts
WHERE id = ?'
);
$stmt->execute([$id]);
$baris = $stmt->fetch(PDO::FETCH_ASSOC);
return $baris === false ? null : $baris;
}
/**
* Ambil seluruh kontak, urut berdasarkan nama.
*
* @return list<array<string, mixed>>
*/
public function semua(): array
{
return $this->pdo
->query('SELECT id, nama, email, telepon, created_at
FROM contacts
ORDER BY nama')
->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Perbarui seluruh kolom data satu kontak.
* Mengembalikan false bila id tidak ditemukan.
*/
public function perbarui(int $id, string $nama, string $email, string $telepon): bool
{
$stmt = $this->pdo->prepare(
'UPDATE contacts
SET nama = ?, email = ?, telepon = ?
WHERE id = ?'
);
$stmt->execute([$nama, $email, $telepon, $id]);
return $stmt->rowCount() > 0;
}
/**
* Hapus satu kontak. Mengembalikan false bila id tidak ditemukan.
*/
public function hapus(int $id): bool
{
$stmt = $this->pdo->prepare('DELETE FROM contacts WHERE id = ?');
$stmt->execute([$id]);
return $stmt->rowCount() > 0;
}
}Beberapa keputusan pada class tersebut perlu dibedah satu per satu:
__construct(private PDO $pdo)memakai constructor property promotion: koneksi masuk sebagai dependency, disimpan ke property$pdo, dan hidup sepanjang umur objek repository. BarisPRAGMA foreign_keys = ONdi dalamnya menindaklanjuti catatan Bagian 1 — pragma berlaku per koneksi, dan constructor adalah titik yang pasti dilewati setiap kali repository dibuat.simpan()mengembalikan id kontak baru lewatlastInsertId(), dipandu cast(int)karena PDO mengembalikannya sebagai string. Pemanggil tidak perlu query ulang hanya untuk mengetahui id-nya.cariId()menerjemahkanfalsedarifetch()menjadinullagar kontrak return type?arraybersih: tidak ketemu berartinull, bukanfalsebertipe campuran. ModePDO::FETCH_ASSOCmembuat setiap baris menjadi array asosiatif bernama kolom.semua()adalah satu-satunya method yang boleh memakaiquery()langsung karena SQL-nya tidak memuat nilai dari luar. Semua method lain memakaiprepare()laluexecute()dengan parameter terpisah sesuai materi Prepared Statements .perbarui()danhapus()membacarowCount()sebagai sinyal: lebih dari nol berarti barisnya ada dan terpengaruh, nol berarti id tersebut tidak ditemukan. Kesalahan seperti “menghapus dua kali” pun tidak melempar exception — pemanggil cukup memeriksa nilai balikfalse.
Pola Pembuka Setiap Contoh #
Seluruh contoh pada bagian ini dan bagian berikutnya dimulai dengan blok pembuka yang sama: buka koneksi :memory:, nyalakan error mode exception, aktifkan pragma foreign key, lalu pasang schema:
<?php
require __DIR__ . '/ContactRepository.php';
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-desain-data/schema.sql'));
$repo = new ContactRepository($pdo);Empat baris tengahnya memegang peran masing-masing. sqlite::memory: menciptakan database baru yang hidup di RAM dan musnah saat skrip selesai, sehingga setiap eksekusi selalu mulai dari kondisi bersih. ERRMODE_EXCEPTION mengubah galat SQL menjadi PDOException yang bisa ditangkap try/catch — tanpa opsi ini, kegagalan query hanya mengembalikan false dan bug bersembunyi diam-diam. Pragma dan schema kemudian memastikan aturan serta data awal dari Bagian 1 selalu terpasang. Pada aplikasi sungguhan, blok pembuka ini tinggal diganti satu baris new PDO('sqlite:/path/ke/kontak.sqlite') sebagaimana dibahas pada chapter Koneksi PDO
.
Walkthrough Menyimpan dan Mencari #
Dengan repository siap, kita jalankan alur pertama: simpan kontak baru, ambil kembali datanya lewat id, lalu buktikan id yang tidak dikenal menghasilkan null:
<?php
require __DIR__ . '/ContactRepository.php';
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-desain-data/schema.sql'));
$repo = new ContactRepository($pdo);
$idBaru = $repo->simpan('Eko Prasetyo', '[email protected]', '081399998888');
echo "Kontak baru tersimpan dengan id $idBaru\n";
$kontak = $repo->cariId($idBaru);
echo "Nama : {$kontak['nama']}\n";
echo "Email : {$kontak['email']}\n";
echo "Telepon: {$kontak['telepon']}\n";
var_dump($repo->cariId(999));Output yang Dihasilkan:
Kontak baru tersimpan dengan id 7
Nama : Eko Prasetyo
Email : [email protected]
Telepon: 081399998888
NULLsimpan() mengembalikan 7 karena enam data awal sudah memakai id 1 sampai 6, dan AUTOINCREMENT melanjutkan urutannya. Hasil cariId() berupa array asosiatif sehingga kolom bisa diakses dengan nama yang jelas. Untuk id 999 yang memang tidak ada, var_dump() menegaskan nilainya NULL — kontrak ?array terpenuhi, dan pemanggil cukup memeriksa === null.
Walkthrough Daftar, Memperbarui, dan Menghapus #
Alur kedua menyusuri tiga operasi sisanya sekaligus. Daftar kontak dicetak sebagai tabel sederhana lewat helper printf(), kemudian telepon Siti diperbarui, Rian dihapus, dan dua id yang tidak dikenal diuji:
<?php
require __DIR__ . '/ContactRepository.php';
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-desain-data/schema.sql'));
$repo = new ContactRepository($pdo);
function cetakDaftar(array $daftar): void
{
printf("%-4s %-16s %-26s %s\n", 'ID', 'Nama', 'Email', 'Telepon');
foreach ($daftar as $kontak) {
printf(
"%-4d %-16s %-26s %s\n",
$kontak['id'],
$kontak['nama'],
$kontak['email'],
$kontak['telepon']
);
}
}
echo "== semua() setelah schema dimuat ==\n";
cetakDaftar($repo->semua());
$repo->perbarui(2, 'Siti Nurhaliza', '[email protected]', '082198765001');
echo "\n== perbarui(2): telepon Siti diganti ==\n";
$kontak = $repo->cariId(2);
echo "Telepon Siti kini: {$kontak['telepon']}\n";
echo "\n== hapus(5): Rian dihapus ==\n";
echo $repo->hapus(5) ? "Kontak #5 terhapus\n" : "Kontak #5 tidak ada\n";
echo "\n== id yang tidak ada ==\n";
echo $repo->perbarui(999, 'X', '[email protected]', '081')
? "terperbarui\n" : "perbarui(999): id tidak ditemukan\n";
echo $repo->hapus(999) ? "terhapus\n" : "hapus(999): id tidak ditemukan\n";
echo "\n== semua() setelah perubahan ==\n";
cetakDaftar($repo->semua());Output yang Dihasilkan:
== semua() setelah schema dimuat ==
ID Nama Email Telepon
3 Andi Wijaya [email protected] 081377712345
1 Budi Santoso [email protected] 081234567890
4 Dewi Lestari [email protected] 085611122233
6 Maya Putri [email protected] 089955667788
5 Rian Hidayat [email protected] 087812340098
2 Siti Nurhaliza [email protected] 082198765432
== perbarui(2): telepon Siti diganti ==
Telepon Siti kini: 082198765001
== hapus(5): Rian dihapus ==
Kontak #5 terhapus
== id yang tidak ada ==
perbarui(999): id tidak ditemukan
hapus(999): id tidak ditemukan
== semua() setelah perubahan ==
ID Nama Email Telepon
3 Andi Wijaya [email protected] 081377712345
1 Budi Santoso [email protected] 081234567890
4 Dewi Lestari [email protected] 085611122233
6 Maya Putri [email protected] 089955667788
2 Siti Nurhaliza [email protected] 082198765001Perhatikan tiga hal pada output tersebut. Daftar dari semua() tampil urut nama meskipun id berantakan, karena ORDER BY nama bekerja pada index idx_contacts_nama dari Bagian 1. Setelah perbarui(2, ...), hanya telepon Siti yang berubah — method memang selalu menulis tiga kolom, tapi nilai nama dan email dikembalikan sama, dan barisnya tetap ada sehingga rowCount() melaporkan satu baris terpengaruh. Terakhir, dua operasi pada id 999 mengembalikan false tanpa melempar galat, dan daftar akhir memperlihatkan Rian sudah hilang serta telepon Siti sudah berganti.
Constraint Menolak Data yang Lolos #
Satu skenario terakhir: apa yang terjadi bila kode tetap memaksa menyimpan email yang sudah dipakai? Bagian 1 sudah membuktikan databasenya menolak; kini kita lihat wujud penolakan itu dari sisi PHP:
<?php
require __DIR__ . '/ContactRepository.php';
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-desain-data/schema.sql'));
$repo = new ContactRepository($pdo);
try {
$repo->simpan('Budi Kedua', '[email protected]', '081111122222');
echo "Tersimpan.\n";
} catch (PDOException $e) {
echo "Kode SQLSTATE : {$e->getCode()}\n";
echo "Pesan lengkap : {$e->getMessage()}\n";
}Output yang Dihasilkan:
Kode SQLSTATE : 23000
Pesan lengkap : SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: contacts.emailError mode exception mengubah penolakan constraint menjadi PDOException berkode 23000 yang tertangkap rapi. Untuk sekarang, perilaku ini kita biarkan mentah: repository melaporkan apa adanya, dan pemanggil memutuskan sendiri artinya. Pesan mentah semacam ini pantas masuk log developer, tetapi bukan sesuatu yang layak ditampilkan kepada pengguna akhir — Bagian 4 akan membangun lapisan validasi dan pesan galat yang ramah di depannya, sambil menjadikan constraint ini sebagai baris pertahanan terakhir.
Cara Menjalankan Contoh #
Contoh pada bagian ini membutuhkan schema.sql dari Bagian 1 dan ContactRepository.php dari bagian ini. Susun foldernya sebagai berikut:
aplikasi-kontak/
├── bagian-1-desain-data/
│ └── schema.sql
└── bagian-2-operasi-kontak/
├── ContactRepository.php
└── uji-crud.phpSimpan salah satu walkthrough di atas sebagai uji-crud.php, lalu jalankan dari dalam folder bagian-2-operasi-kontak:
php uji-crud.phpPath __DIR__ . '/../bagian-1-desain-data/schema.sql' menunjuk satu tingkat ke atas lalu masuk ke folder bagian saudara, sehingga struktur folder ini harus dipertahankan. Seluruh contoh memakai :memory:, jadi tidak ada berkas database yang tersisa setelah eksekusi.
Apa yang Berubah #
Dibandingkan Bagian 1 yang hanya memiliki schema, proyek kini memiliki lapisan akses data yang hidup:
- Berkas
ContactRepository.phpbaru berisi class final dengan lima operasi CRUD:simpan(),cariId(),semua(),perbarui(), danhapus(). - Seluruh operasi berjalan di atas prepared statements; hanya
semua()yang memakaiquery()karena tanpa input dari luar. - Koneksi PDO menjadi dependency yang disuntikkan, dengan
PRAGMA foreign_keys = ONdinyalakan otomatis di constructor. - Kontrak return type sudah tegas:
?arrayuntuk pencarian,booluntuk operasi tulis,intuntuk id baru — fondasi yang akan memudahkan test pada Bagian 5.
Ringkasan #
ContactRepository menjadikan tabel contacts sebagai satu-satunya urusan class ini: PDO disuntikkan dari luar, setiap nilai pengguna berjalan lewat parameter binding, dan hasil query kembali sebagai array asosiatif dengan kontrak tipe yang jelas. Alur CRUD lengkap sudah teruji pada database :memory: lengkap dengan kasus id yang tidak ditemukan, dan penolakan constraint UNIQUE terlihat sebagai PDOException berkode 23000. Namun aplikasi kontak yang serius tidak berhenti pada menampilkan semua data — pengguna perlu mencari. Bagian 3 menambahkan pencarian nama yang aman, filter domain email, dan paginasi.
Referensi #
- PDO Prepared Statements — php.net
- PDO::lastInsertId — php.net
- PDOStatement::rowCount — php.net
- PHP: The Right Way
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
ContactRepository.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Lapisan akses data untuk tabel contacts.
*
* Seluruh query SQL aplikasi kontak dipusatkan di class ini.
* Koneksi PDO disuntikkan lewat constructor, sehingga pemanggil-lah
* yang menentukan database yang dipakai: berkas di disk atau database
* sementara `sqlite::memory:`.
*/
final class ContactRepository
{
public function __construct(private PDO $pdo)
{
// Pemeriksaan foreign key SQLite nonaktif secara default.
// Menyalakannya di sini adalah kebiasaan murah yang menjaga
// integritas begitu tabel berelasi ditambahkan kelak.
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
/**
* Simpan kontak baru, kembalikan id baris yang baru dibuat.
*/
public function simpan(string $nama, string $email, string $telepon): int
{
$stmt = $this->pdo->prepare(
'INSERT INTO contacts (nama, email, telepon) VALUES (?, ?, ?)'
);
$stmt->execute([$nama, $email, $telepon]);
return (int) $this->pdo->lastInsertId();
}
/**
* Ambil satu kontak berdasarkan id; null bila tidak ditemukan.
*
* @return array<string, mixed>|null
*/
public function cariId(int $id): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id, nama, email, telepon, created_at
FROM contacts
WHERE id = ?'
);
$stmt->execute([$id]);
$baris = $stmt->fetch(PDO::FETCH_ASSOC);
return $baris === false ? null : $baris;
}
/**
* Ambil seluruh kontak, urut berdasarkan nama.
*
* @return list<array<string, mixed>>
*/
public function semua(): array
{
return $this->pdo
->query('SELECT id, nama, email, telepon, created_at
FROM contacts
ORDER BY nama')
->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Perbarui seluruh kolom data satu kontak.
* Mengembalikan false bila id tidak ditemukan.
*/
public function perbarui(int $id, string $nama, string $email, string $telepon): bool
{
$stmt = $this->pdo->prepare(
'UPDATE contacts
SET nama = ?, email = ?, telepon = ?
WHERE id = ?'
);
$stmt->execute([$nama, $email, $telepon, $id]);
return $stmt->rowCount() > 0;
}
/**
* Hapus satu kontak. Mengembalikan false bila id tidak ditemukan.
*/
public function hapus(int $id): bool
{
$stmt = $this->pdo->prepare('DELETE FROM contacts WHERE id = ?');
$stmt->execute([$id]);
return $stmt->rowCount() > 0;
}
}