PostgreSQL menempati posisi khusus di dunia database: sepenuhnya open source, sangat patuh pada standar SQL, dan membawa fitur tingkat lanjut — tipe JSONB, larik kolom, pencarian teks lengkap — yang membuatnya favorit aplikasi skala berat. Seperti MySQL, PostgreSQL berjalan sebagai server terpisah yang diakses PHP lewat jaringan, sehingga pola koneksinya serupa. Yang membedakan adalah dialeknya: PostgreSQL punya klausa RETURNING, ON CONFLICT, dan DISTINCT ON yang mengubah cara kita menulis query harian. Pada chapter ini kita akan membahas koneksi dari PHP, lalu mencicipi fitur dialek tersebut — dan beruntung bagi kita, sebagian besarnya juga didukung SQLite, sehingga setiap contoh tetap bisa dieksekusi sungguhan di :memory:.

Driver PostgreSQL pada PHP #

PHP menyediakan dua jalur menuju PostgreSQL: driver pdo_pgsql untuk PDO, dan ekstensi pgsql berisi fungsi procedural seperti pg_connect() dan pg_query(). Sama seperti sebelumnya, ketersediaannya kita pastikan lewat extension_loaded():

php
<?php
var_dump(extension_loaded('pdo_pgsql'), extension_loaded('pgsql'));

echo "driver PDO tersedia: " . implode(', ', PDO::getAvailableDrivers()) . "\n";

Output yang Dihasilkan:

text
bool(true)
bool(true)
driver PDO tersedia: mysql, pgsql, sqlite

Pada build latihan ini kedua ekstensi aktif (bool(true) dua kali). Ekstensi pgsql adalah API generasi lama yang masih banyak dipakai di kode warisan — fungsinya mengembalikan resource dan menuntut penanganan galat manual. Untuk kode baru, PDO kembali menjadi pilihan utama dengan alasan yang sama seperti pada chapter MySQL: satu API untuk semua database, prepared statements yang konsisten, dan pengecualian berbasis PDOException.

Membangun Koneksi dengan DSN pgsql #

DSN driver pdo_pgsql berformat pgsql:host=...;port=...;dbname=... dengan kredensial sebagai argumen terpisah. Bentuk lengkapnya:

php
<?php
$pdo = new PDO(
    'pgsql:host=127.0.0.1;port=5432;dbname=toko',
    'budi',
    'YOUR_DATABASE_PASSWORD',
    [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ],
);

Struktur argumennya identik dengan koneksi MySQL: host adalah alamat server, port boleh dihilangkan bila memakai bawaan 5432, dan dbname memilih database tujuan. Satu perbedaan mencolok: DSN pgsql tidak punya parameter charset= seperti MySQL. Encoding pada PostgreSQL melekat pada database itu sendiri (ditetapkan saat database diciptakan, umumnya UTF8), dan bila perlu diubah, encoding klien disetel lewat SET client_encoding setelah koneksi terbuka. Port 5432 ini pula yang perlu Anda teruskan bila menjalankan PostgreSQL di Docker, dengan pola docker run yang sama seperti pada chapter MySQL dan nama image postgres.

RETURNING: Hasil dalam Query yang Sama #

Menyimpan baris lalu membaca kembali ID-nya adalah ritual klasik: INSERT dulu, baru lastInsertId(). PostgreSQL menawarkan jalan lebih elegan lewat klausa RETURNING — kolom yang Anda butuhkan dikembalikan sebagai hasil query INSERT itu sendiri. SQLite mendukung klausa yang sama sejak versi 3.35, jadi kita bisa mencobanya langsung:

php
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('CREATE TABLE pesanan (id INTEGER PRIMARY KEY AUTOINCREMENT, pelanggan TEXT)');

$pdo->exec("INSERT INTO pesanan (pelanggan) VALUES ('Budi')");
echo "lastInsertId(): " . $pdo->lastInsertId() . "\n";

$stmt = $pdo->prepare('INSERT INTO pesanan (pelanggan) VALUES (?) RETURNING id, pelanggan');
$stmt->execute(['Siti']);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
echo "RETURNING: id={$row['id']} pelanggan={$row['pelanggan']}\n";

Output yang Dihasilkan:

text
lastInsertId(): 1
RETURNING: id=2 pelanggan=Siti

Kedua baris hasil memperlihatkan dua teknik yang sama-sama sah namun berbeda karakter. lastInsertId() membutuhkan satu perjalanan bolak-balik ke database untuk setiap nilai yang ingin dibaca, dan perilakunya bergantung pada driver: pada pdo_pgsql, kolom SERIAL mengharuskan Anda memberi nama sequence (lastInsertId('pesanan_id_seq')), sementara pdo_mysql mengandalkan ID sesi koneksi. RETURNING menghapus seluruh kerumitan itu: satu query, satu hasil, dan kolom apa pun boleh diminta — bahkan kolom yang dihitung. Perlu dicatat bahwa MySQL belum mendukung klausa ini, jadi RETURNING adalah salah satu daya tarik nyata saat Anda memilih PostgreSQL atau SQLite.

Upsert dengan ON CONFLICT #

Skenario “sisipkan bila belum ada, perbarui bila sudah ada” sebelumnya dipenuhi dengan query SELECT pengecekan plus INSERT atau UPDATE — tiga perjalanan ke database dan rawan berlomba dengan proses lain. PostgreSQL 9.5 memperkenalkan INSERT ... ON CONFLICT, dan SQLite mengadopsi sintaks yang sama persis, termasuk tabel semu excluded yang berisi nilai yang gagal disisipkan:

php
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('CREATE TABLE stok (kode TEXT PRIMARY KEY, jumlah INTEGER NOT NULL)');

$tambah = $pdo->prepare(
    'INSERT INTO stok (kode, jumlah) VALUES (?, ?)
     ON CONFLICT (kode) DO UPDATE SET jumlah = stok.jumlah + excluded.jumlah'
);
$tambah->execute(['KPI-01', 10]);
$tambah->execute(['KPI-01', 5]);

$abaikan = $pdo->prepare(
    'INSERT INTO stok (kode, jumlah) VALUES (?, ?)
     ON CONFLICT (kode) DO NOTHING'
);
$abaikan->execute(['KPI-01', 99]);

foreach ($pdo->query('SELECT kode, jumlah FROM stok', PDO::FETCH_ASSOC) as $row) {
    echo "{$row['kode']}: {$row['jumlah']}\n";
}

Output yang Dihasilkan:

text
KPI-01: 15

Mari kita ikuti alurnya baris demi baris: eksekusi pertama menyisipkan KPI-01 dengan jumlah 10 karena tabel masih kosong. Eksekusi kedua menabrak konflik pada primary key, sehingga cabang DO UPDATE bekerja — stok.jumlah + excluded.jumlah menjumlahkan nilai lama (10) dengan nilai baru (5) menjadi 15. Eksekusi ketiga memakai cabang DO NOTHING: konflik terjadi lagi, tetapi nilai 99 sengaja diabaikan, sehingga jumlah tetap 15 seperti tercetak pada hasil. Satu prepared statement menggantikan pola cek-lalu-tulis, dan seluruh operasi berlangsung atomik dalam satu statement — tidak ada celah bagi dua proses yang sama-sama menulis stok di detik yang sama. Di sisi PHP tidak ada teknik baru sama sekali; inilah contoh terbaik bahwa peningkatan terbesar sering kali ada di SQL-nya, bukan di kode PHP-nya.

DISTINCT ON dan Padanannya #

PostgreSQL punya klausa DISTINCT ON yang memilih satu baris per kelompok sesuai urutan ORDER BY — sempurna untuk pertanyaan seperti “pesanan terbaru tiap pelanggan”. Bentuknya di PostgreSQL:

sql
SELECT DISTINCT ON (pelanggan)
    pelanggan, id, total
FROM pesanan
ORDER BY pelanggan, dibuat_pada DESC;

Klausa ini menjaga baris pertama dari setiap kelompok pelanggan setelah data diurutkan berdasarkan tanggal terbaru. SQLite dan MySQL tidak memiliki DISTINCT ON, tetapi hasil yang sama bisa diraih dengan window function ROW_NUMBER() yang justru berjalan di ketiga database:

php
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('CREATE TABLE pesanan (id INTEGER PRIMARY KEY, pelanggan TEXT, total INTEGER, dibuat_pada TEXT)');
$pdo->exec("INSERT INTO pesanan (pelanggan, total, dibuat_pada) VALUES
    ('Budi', 120000, '2026-01-03'),
    ('Budi', 95000,  '2026-01-10'),
    ('Siti', 200000, '2026-01-05'),
    ('Siti', 75000,  '2026-01-12')");

$sql = 'SELECT pelanggan, total FROM (
    SELECT pelanggan, total,
           ROW_NUMBER() OVER (PARTITION BY pelanggan ORDER BY dibuat_pada DESC) AS nomor
    FROM pesanan
) WHERE nomor = 1 ORDER BY pelanggan';

foreach ($pdo->query($sql, PDO::FETCH_ASSOC) as $row) {
    echo "{$row['pelanggan']}: Rp{$row['total']}\n";
}

Output yang Dihasilkan:

text
Budi: Rp95000
Siti: Rp75000

Query terluar menyaring nomor = 1, sedangkan ROW_NUMBER() memberi nomor urut dalam tiap partisi pelanggan mulai dari pesanan yang paling baru. Hasilnya persis padanan DISTINCT ON: Budi mendapat pesanan 2026-01-10 senilai Rp95000, dan Siti mendapat pesanan 2026-01-12 senilai Rp75000. Versi window function memang lebih panjang, tetapi karena berjalan identik di PostgreSQL, SQLite, dan MySQL 8, versi ini layak menjadi default bila kode Anda mungkin berpindah database.

Schema sebagai Namespace #

PostgreSQL punya satu lapis pengorganisasian yang tidak ada di MySQL: schema. Hierarkinya berjenjang — satu server memuat banyak database, satu database memuat banyak schema, dan schema bawaan bernama public menampung seluruh tabel yang selama ini Anda buat tanpa prefiks. Nama schema ditulis sebelum nama tabel dengan titik: penjualan.pesanan adalah tabel yang berbeda dari gudang.pesanan. Kode SQL berikut menggambarkan pemisahan itu:

sql
CREATE SCHEMA penjualan;
CREATE TABLE penjualan.pesanan (id BIGSERIAL PRIMARY KEY, pelanggan TEXT);

Pemakaian schema memungkinkan satu database menampung modul-modul yang tabelnya sengaja dipisah, tanpa harus membuat database terpisah dengan koneksi terpisah. Pengaturan search_path menentukan urutan schema yang dicari ketika tabel dipanggil tanpa prefiks, jadi SET search_path TO penjualan membuat SELECT * FROM pesanan mengarah ke tabel yang benar. Prefiks schema murni urusan string SQL — kode PHP Anda tidak berubah sama sekali. SQLite memiliki mekanisme sejenis lewat ATTACH DATABASE yang menempelkan berkas lain dengan alias, sehingga query bisa menulis gudang.produk; secara teknis berbeda, tetapi tujuan penamaan berlapisnya sama.

Kapan Memilih PostgreSQL #

Memilih database adalah keputusan arsitektural, dan keunggulan PostgreSQL paling terasa ketika kebutuhan data Anda melampaui tabel-tabel sederhana. Tabel berikut memetakan kebutuhan aplikasi ke fitur yang menjawabnya:

Kebutuhan aplikasiFitur PostgreSQL
Dokumen JSON dengan pencarian cepattipe JSONB + indeks GIN
Menyimpan daftar nilai dalam satu kolomlarik asli seperti TEXT[], INTEGER[]
Mesin pencari teksfull-text search bawaan (tsvector)
Data geografisekstensi PostGIS
Penulisan konkuren tinggiMVCC tanpa penguncian tabel penuh
Query analitik rumitwindow functions, CTE, materialized view

Dari sisi PHP, tidak ada biaya tambahan untuk memilih PostgreSQL: prepared statements, transactions, dan seluruh pola PDO bekerja persis seperti biasa. Yang perlu ditimbang adalah sisi operasional — administrasi server PostgreSQL menuntut sedikit lebih banyak perhatian dibanding MySQL pada hosting bersama, dan pilihan hosting murah untuk MySQL lebih banyak. Untuk proyek baru yang kompleks, patokan praktisnya sederhana: mulai dengan SQLite, lalu naik ke PostgreSQL ketika fitur atau skala benar-benar menuntutnya.

Ringkasan #

PostgreSQL diakses dari PHP melalui DSN pgsql:host;dbname dengan pola PDO yang sama seperti MySQL. Klausa RETURNING menggabungkan penulisan dan pembacaan dalam satu query, ON CONFLICT menghadirkan upsert atomik dengan tabel semu excluded, dan DISTINCT ON memberi satu baris per kelompok — semuanya fitur dialek yang membuat SQL lebih ringkas, dan sebagiannya berbagi sintaks dengan SQLite. Schema menambah lapis namespace untuk tabel, sementara JSONB, larik, dan MVCC menjawab kebutuhan tingkat lanjut. Namun secanggih apa pun databasenya, kode PHP yang menempelkan string SQL di banyak tempat akan cepat kusut — pada chapter berikutnya kita merapikannya dengan repository pattern.

Referensi #