Database hasil migration ibarat rumah baru: strukturnya berdiri, tetapi isinya kosong. Aplikasi nyata jarang bisa berjalan di atas schema kosong — halaman utama butuh artikel untuk ditampilkan, form pesanan butuh daftar kategori, dan panel admin butuh setidaknya satu akun untuk login pertama kali. Seed data adalah berkas yang mengisi database dengan data awal tersebut, terpisah dari migration yang hanya mengurus struktur. Pada chapter ini kita akan membedakan seed untuk pengembangan dan produksi, membuat seed yang aman dijalankan berulang kali, lalu melihat batas antara seed dan fixture yang dipakai pengujian.
Seed vs Migration #
Kedua berkas ini sama-sama berisi INSERT atau CREATE, sehingga sering tertukar perannya. Garis pembatasnya sederhana: migration mengubah struktur dan berjalan sekali per versi; seed mengisi data dan harus aman dijalankan berulang kali karena setiap deploy biasanya menjalankannya lagi.
| Aspek | Migration | Seed |
|---|---|---|
| Yang diubah | Struktur schema (tabel, kolom, index) | Isi tabel (baris data) |
| Tujuan | Membawa schema maju dari versi ke versi | Menyiapkan data awal agar aplikasi berfungsi |
| Frekuensi jalan | Sekali per versi, lalu tercatat | Setiap deploy, harus idempoten |
| Contoh isi | CREATE TABLE, ALTER TABLE | Akun admin, data referensi, data contoh |
Pemisahan ini penting karena umur keduanya berbeda. Migration adalah sejarah yang beku — sekali diterapkan tidak boleh diedit, seperti kita bahas pada chapter Migration Versioning . Seed justru hidup dan terus disesuaikan: daftar kategori bertambah, akun admin berganti, data contoh diperbarui mengikuti fitur baru. Mencampurkannya ke dalam migration berarti membekukan data yang seharusnya bisa berubah.
Seed untuk Lingkungan Pengembangan #
Tujuan seed development sederhana: membuat aplikasi terlihat hidup sejak detik pertama di laptop developer. Isinya adalah data contoh yang realistis — nama orang fiktif, artikel dengan judul masuk akal, relasi antar tabel yang benar — cukup banyak untuk menampilkan halaman yang penuh, tetapi cukup kecil untuk dipahami sekali lihat:
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
)");
$pdo->exec("CREATE TABLE articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
judul TEXT NOT NULL,
isi TEXT NOT NULL,
penulis_id INTEGER NOT NULL REFERENCES users(id)
)");
function seedDev(PDO $pdo): void
{
$users = [
["Budi Santoso", "[email protected]"],
["Siti Aminah", "[email protected]"],
["Rudi Hartono", "[email protected]"],
];
$stmt = $pdo->prepare("INSERT INTO users (nama, email) VALUES (?, ?)");
foreach ($users as $user) {
$stmt->execute($user);
}
$articles = [
["Belajar PHP Dasar", "PHP adalah bahasa server-side yang populer...", 1],
["Memahami PDO", "PDO menyediakan antarmuka konsisten untuk database...", 1],
["Tips Migrasi Database", "Migration membuat perubahan schema terkontrol...", 3],
];
$stmt = $pdo->prepare("INSERT INTO articles (judul, isi, penulis_id) VALUES (?, ?, ?)");
foreach ($articles as $article) {
$stmt->execute($article);
}
}
seedDev($pdo);
echo "users terisi: " . $pdo->query("SELECT COUNT(*) FROM users")->fetchColumn() . "\n";
echo "articles terisi: " . $pdo->query("SELECT COUNT(*) FROM articles")->fetchColumn() . "\n";
foreach ($pdo->query("SELECT judul, penulis_id FROM articles ORDER BY id", PDO::FETCH_ASSOC) as $row) {
echo "- {$row['judul']} (penulis #{$row['penulis_id']})\n";
}Output yang Dihasilkan:
users terisi: 3
articles terisi: 3
- Belajar PHP Dasar (penulis #1)
- Memahami PDO (penulis #1)
- Tips Migrasi Database (penulis #3)Dua kebiasaan baik terlihat pada kode di atas. Pertama, seed memakai prepared statement dengan placeholder — data contoh tetap melewati jalur yang sama dengan data produksi, seperti pola pada chapter Prepared Statements . Kedua, relasi antar tabel dijaga tetap masuk akal: artikel “Tips Migrasi Database” menunjuk ke penulis #3 (Rudi), bukan ke nomor acak, sehingga halaman detail tidak pernah menampilkan penulis kosong saat diuji.
Seed untuk Produksi yang Idempoten #
Seed production berbeda sasaran: bukan data contoh, melainkan data yang aplikasi butuhkan untuk berjalan — data referensi seperti daftar kategori, dan akun admin pertama. Ciri khasnya: seed production dijalankan berulang kali di setiap deploy, jadi ia wajib idempoten — dijalankan dua kali memberi hasil yang sama dengan sekali. Kuncinya di SQLite adalah INSERT OR IGNORE, yang diam-diam melewati baris yang menabrak constraint unik:
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE admins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL
)");
$pdo->exec("CREATE TABLE kategori (
kode TEXT PRIMARY KEY,
nama TEXT NOT NULL
)");
function seedProd(PDO $pdo): void
{
// Data referensi: INSERT OR IGNORE membuat seed idempoten
$stmt = $pdo->prepare("INSERT OR IGNORE INTO kategori (kode, nama) VALUES (?, ?)");
foreach ([
["ELEKTRONIK", "Elektronik"],
["BUKU", "Buku"],
["PAKAIAN", "Pakaian"],
] as $kategori) {
$stmt->execute($kategori);
}
// Akun admin bawaan - hanya dibuat bila belum ada
$stmt = $pdo->prepare("INSERT OR IGNORE INTO admins (email, password_hash) VALUES (?, ?)");
$stmt->execute(["[email protected]", password_hash("ganti-sandi-ini", PASSWORD_DEFAULT)]);
}
seedProd($pdo);
seedProd($pdo); // deploy berikutnya menjalankan seed yang sama
echo "kategori terisi: " . $pdo->query("SELECT COUNT(*) FROM kategori")->fetchColumn() . "\n";
echo "admins terisi : " . $pdo->query("SELECT COUNT(*) FROM admins")->fetchColumn() . "\n";
$hash = $pdo->query("SELECT password_hash FROM admins WHERE email = '[email protected]'")->fetchColumn();
echo "sandi terverifikasi: " . var_export(password_verify("ganti-sandi-ini", $hash), true) . "\n";Output yang Dihasilkan:
kategori terisi: 3
admins terisi : 1
sandi terverifikasi: trueMeskipun seedProd() dipanggil dua kali, jumlah kategori tetap 3 dan admin tetap satu — INSERT OR IGNORE mengabaikan baris yang kunci uniknya sudah ada, termasuk email [email protected] yang berstatus UNIQUE. Sandi disimpan sebagai hash hasil password_hash() dan diverifikasi ulang dengan password_verify(), bukan disimpan sebagai teks polos. Tentu saja “ganti-sandi-ini” hanyalah penampung: ganti segera setelah login pertama, dan jangan pernah menaruh sandi nyata di dalam kode seed.
Menjaga Seed Tetap Sinkron dengan Schema #
Seed dan schema berkembang bersama, dan keduanya bisa saling meninggalkan. Migration baru yang menambah kolom NOT NULL tanpa nilai bawaan akan mematahkan seed lama yang tidak menyebut kolom itu. Sebaliknya, seed yang menyebut kolom secara eksplisit jauh lebih tahan terhadap perubahan:
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Migration 001: tabel produk awal
$pdo->exec("CREATE TABLE produk (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT NOT NULL
)");
// Migration 002: menambah kolom wajib dengan nilai bawaan
$pdo->exec("ALTER TABLE produk ADD COLUMN harga INTEGER NOT NULL DEFAULT 0");
// Seed lama tetap berjalan karena menyebut kolom secara eksplisit
$stmt = $pdo->prepare("INSERT INTO produk (nama) VALUES (?)");
$stmt->execute(["Kopi Torabika"]);
$row = $pdo->query("SELECT nama, harga FROM produk")->fetch(PDO::FETCH_ASSOC);
echo "nama : {$row['nama']}\n";
echo "harga: {$row['harga']}\n";Output yang Dihasilkan:
nama : Kopi Torabika
harga: 0INSERT INTO produk (nama) VALUES (?) hanya mengisi kolom nama, sehingga kolom harga yang datang belakangan terisi nilai bawaan 0 tanpa seed perlu diubah. Bandingkan dengan INSERT INTO produk VALUES (...) tanpa daftar kolom: pernyataan itu mengikat posisi, bukan nama, sehingga penambahan satu kolom saja membuatnya gagal atau — lebih buruk — mengisi kolom yang salah. Menyebut kolom secara eksplisit adalah asuransi termurah untuk umur panjang sebuah seed.
Mengatur Berkas Seed per Lingkungan #
Karena target dev dan prod berbeda, berkasnya pun dipisah. Pola yang ringkas: satu skrip pintu masuk yang memilih berkas seeder sesuai lingkungan:
proyek/
├── seed.php
└── seeds/
├── dev.php -> data contoh realistis
└── prod.php -> data referensi + akun admin<?php
// seed.php - pemakaian: php seed.php dev atau php seed.php prod
$env = $argv[1] ?? "dev";
$seeders = [
"dev" => "seeds/dev.php",
"prod" => "seeds/prod.php",
];
if (!isset($seeders[$env])) {
fwrite(STDERR, "lingkungan tidak dikenal: $env\n");
exit(1);
}
echo "menjalankan seeder untuk lingkungan: $env\n";
echo "berkas seeder: " . $seeders[$env] . "\n";
// di aplikasi nyata: buka koneksi PDO dari konfigurasi, lalu
// require __DIR__ . "/" . $seeders[$env];
Output yang Dihasilkan:
menjalankan seeder untuk lingkungan: dev
berkas seeder: seeds/dev.phpDi terminal, pemakaiannya konvensional:
php seed.php dev
php seed.php prodSkrip membaca argumen pertama dari $argv, memetakan lingkungan ke berkas seeder, dan menolak nama lingkungan yang tidak dikenal dengan exit code 1 — pola argumen CLI yang sudah kita bahas pada chapter Memahami PHP CLI
. Framework memberikan versi lebih ramping dari pola yang sama: Laravel menyediakan kelas seeder dengan php artisan db:seed, dan alat seperti Doctrine fixtures mengatur data awal lewat objek berkelas. Prinsip pemisahan dev/prod dan idempotensi tetap berlaku di semuanya.
Seed vs Fixture #
Satu istilah lagi yang sering tertukar: fixture. Fixture adalah data yang disiapkan khusus untuk pengujian otomatis — deterministik, minimal, dan dibongkar lagi setelah tiap test selesai. Seed adalah data untuk lingkungan yang hidup: database development yang dipakai berhari-hari, atau production yang melayani pengguna sungguhan. Keduanya berbagi teknik (array data, INSERT, prepared statement), tetapi berbeda umur dan tujuan.
Ungkapan paling jelas dari perbedaan itu ada di chapter berikutnya: pengujian database yang benar membangun database segar per test — persis seperti fixture — lalu menguji migration dan constraint di atasnya. Kita akan mempraktikkannya dengan assertion sederhana di Database Testing , sementara pendalaman pengujian lapisan data dengan PHPUnit tersedia di section Testing dan Quality Assurance pada chapter Database Testing untuk PHPUnit .
Ringkasan #
Migration mengurus struktur, seed mengurus isi; seed development berisi data contoh yang realistis, seed production berisi data referensi dan akun admin yang wajib idempoten lewat INSERT OR IGNORE. Sinkronisasi dengan schema dijaga dengan menyebut kolom secara eksplisit, dan fixture pengujian dipisahkan dari seed karena umur dan tujuannya berbeda. Dengan schema yang ter-version rapi dan data awal yang siap, kita kini punya semua bahan untuk menguji keduanya secara otomatis — tugas chapter Database Testing
.