Pada chapter Schema Migrations kita sudah memperlakukan perubahan schema sebagai berkas migration yang dikelola di version control. Begitu proyek berjalan beberapa bulan, pertanyaan baru muncul: dari puluhan berkas migration, mana saja yang sudah diterapkan di database ini? Dalam urutan apa mereka harus dijalankan? Dan apa yang terjadi ketika dua developer membawa migration baru dari dua cabang berbeda? Migration versioning adalah disiplin menjawab ketiga pertanyaan itu: konvensi penamaan yang menentukan urutan, tabel pencatat versi yang menyimpan status, dan aturan main saat bekerja dalam tim.
Penamaan Migration: Sekuensial vs Timestamp #
Cara paling sederhana memberi nama migration adalah penomoran sekuensial: 001_create_users.php, 002_create_articles.php, 003_add_telepon_to_users.php. Penamaan ini mudah dibaca, tetapi rapuh ketika dipakai tim. Jika Budi dan Siti masing-masing membuat cabang fitur dari commit yang sama, keduanya sama-sama menulis 004_... — dan perintah git merge akan melaporkan konflik yang harus diselesaikan manual, termasuk penomoran ulang seluruh berkas setelahnya.
Solusi yang dipakai hampir semua alat migration modern adalah prefix timestamp pada nama berkas:
2026_01_10_000001_create_categories.php
2026_01_15_000001_create_users.php
2026_01_15_000002_create_articles.php
2026_01_16_000001_add_telepon_to_users.phpAnatomi namanya terdiri dari tiga bagian: tanggal (2026_01_15) dalam format YYYY_MM_DD, penomoran enam digit dalam satu hari (000001) untuk kasus dua migration dibuat pada hari yang sama, lalu deskripsi singkat (create_users). Karena dua developer hampir mustahil membuat migration pada tanggal dan detik yang persis sama, tabrakan nama menjadi langka — dan begitu migration dari kedua cabang digabungkan, urutannya sudah tertentu dari namanya saja.
Berkas di sistem operasi tidak menjamin urutan tertentu, jadi migrator wajib mengurutkannya sendiri sebelum dieksekusi:
<?php
// Berkas migration di proyek - urutan di disk tidak menentu
$files = [
"2026_01_15_000002_create_articles.php",
"2026_01_15_000001_create_users.php",
"2026_01_10_000001_create_categories.php",
];
sort($files);
echo "urutan eksekusi:\n";
foreach ($files as $i => $file) {
echo ($i + 1) . ". $file\n";
}Output yang Dihasilkan:
urutan eksekusi:
1. 2026_01_10_000001_create_categories.php
2. 2026_01_15_000001_create_users.php
3. 2026_01_15_000002_create_articles.phpFungsi sort() mengurutkan string secara leksikografis, dan karena format tanggalnya seragam (YYYY_MM_DD diikuti penomoran tetap), urutan alfabetis selalu sama dengan urutan kronologis. Migration tertua (2026_01_10_...) dijalankan lebih dulu meskipun dalam array berada di posisi terakhir — persis perilaku yang kita butuhkan.
Tabel Pencatat Versi #
Migrator tidak boleh menebak status schema; database sendiri yang harus mengingat migration mana yang sudah diterapkan. Pola standarnya adalah satu tabel khusus, biasa dinamai schema_migrations, berisi nama versi dan waktu penerapannya. Alur migrator lalu menjadi: baca versi yang tercatat, bandingkan dengan daftar berkas, jalankan yang belum tercatat secara berurutan, lalu catat.
Mari kita bangun mini-migrator lengkap dengan pola tersebut:
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Tabel versi: sumber kebenaran status schema
$pdo->exec("CREATE TABLE schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
)");
$migrations = [
"2026_01_15_000002_create_articles" => function (PDO $pdo): void {
$pdo->exec("CREATE TABLE articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
judul TEXT NOT NULL,
penulis_id INTEGER NOT NULL REFERENCES users(id)
)");
},
"2026_01_15_000001_create_users" => function (PDO $pdo): void {
$pdo->exec("CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE
)");
},
];
function migrate(PDO $pdo, array $migrations): void
{
// 1. baca versi yang sudah tercatat di database
$rows = $pdo->query("SELECT version FROM schema_migrations")
->fetchAll(PDO::FETCH_COLUMN);
$applied = array_fill_keys($rows, true);
// 2. timestamp di depan nama menjamin urutan eksekusi
ksort($migrations);
foreach ($migrations as $version => $up) {
if (isset($applied[$version])) {
continue; // sudah tercatat -> tidak dijalankan ulang
}
$pdo->beginTransaction();
try {
$up($pdo);
$pdo->prepare("INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)")
->execute([$version, "2026-01-15 08:00:00"]);
$pdo->commit();
echo "terapkan: $version\n";
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
}
migrate($pdo, $migrations);
echo "--- pemanggilan kedua ---\n";
migrate($pdo, $migrations);
echo "versi tercatat: " . $pdo->query("SELECT COUNT(*) FROM schema_migrations")->fetchColumn() . "\n";Output yang Dihasilkan:
terapkan: 2026_01_15_000001_create_users
terapkan: 2026_01_15_000002_create_articles
--- pemanggilan kedua ---
versi tercatat: 2Beberapa keputusan desain pada kode di atas layak dicermati:
- Array
$migrationssengaja diisi tidak berurutan —create_articlesberada sebelumcreate_users. Fungsiksort()memperbaiki urutan berdasarkan kunci string sebelum eksekusi, meniru perilaku migrator sungguhan yang mengurutkan nama berkas. - Pemeriksaan
isset($applied[$version])membuat migrator idempoten: pemanggilan kedua tidak menjalankan apa pun karena kedua versi sudah tercatat, dan jumlah baris dischema_migrationstetap2. beginTransaction()membungkus query schema dan pencatatan versi dalam satu transaksi. JikaCREATE TABLEgagal setengah jalan, catatan versinya ikut dibatalkan sehingga status tidak pernah bohong — pola yang sudah kita bahas pada chapter Transactions .
Mengelola Migration di Tim #
Timestamp mempermudah penggabungan cabang. Bayangkan cabang fitur A membawa 2026_01_16_000001_add_telepon_to_users dan cabang fitur B membawa 2026_01_16_000002_create_tags:
<?php
// Cabang fitur A dan B masing-masing membawa satu migration baru.
// Setelah merge, keduanya berdampingan tanpa penomoran ulang.
$branchA = "2026_01_16_000001_add_telepon_to_users";
$branchB = "2026_01_16_000002_create_tags";
$setelahMerge = [$branchB, $branchA]; // git merge tidak menjamin urutan
sort($setelahMerge);
echo "urutan setelah merge:\n";
foreach ($setelahMerge as $i => $version) {
echo ($i + 1) . ". $version\n";
}Output yang Dihasilkan:
urutan setelah merge:
1. 2026_01_16_000001_add_telepon_to_users
2. 2026_01_16_000002_create_tagsSetelah penggabungan, kedua migration hidup berdampingan dan urutannya tertentu tanpa penomoran ulang manual. Dua situasi tetap butuh penanganan khusus. Pertama, jika kedua cabang kebetulan menghasilkan nama yang identik, ganti nama migration yang lebih baru sebelum merge. Kedua, jika migration dari cabang B ternyata bergantung pada tabel yang dibuat migration cabang A padahal timestamp-nya lebih kecil, majukan tanggalnya agar urutannya benar — lakukan ini sebelum migration tersebut diterapkan di mana pun.
Jangan Mengedit Migration yang Sudah Diterapkan #
Aturan emas migration versioning: berkas yang sudah diterapkan pada database mana pun bersifat beku. Migrator mencocokkan nama versi, bukan isi berkas — mengedit isi 2026_01_15_000001_create_users.php tidak akan pernah dieksekusi ulang pada database yang sudah mencatatnya:
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Migration 001 sudah pernah diterapkan pada database ini
$pdo->exec("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)");
$pdo->exec("CREATE TABLE schema_migrations (version TEXT PRIMARY KEY)");
$pdo->exec("INSERT INTO schema_migrations VALUES ('2026_01_15_000001_create_users')");
// Keesokan harinya developer MENGEDIT isi berkas
// 2026_01_15_000001_create_users.php untuk menambah kolom telepon.
// Nama versi tidak berubah, sehingga migrator tetap menganggapnya
// "sudah diterapkan".
$rows = $pdo->query("SELECT version FROM schema_migrations")->fetchAll(PDO::FETCH_COLUMN);
$applied = array_fill_keys($rows, true);
$nama = "2026_01_15_000001_create_users";
echo "$nama: " . (isset($applied[$nama]) ? "dilewati, isi baru tidak dieksekusi" : "dieksekusi") . "\n";
// Kolom telepon tidak pernah tercipta di database yang sudah ada
try {
$pdo->query("SELECT telepon FROM users");
echo "kolom telepon: ada\n";
} catch (PDOException) {
echo "kolom telepon: tidak ada\n";
}Output yang Dihasilkan:
2026_01_15_000001_create_users: dilewati, isi baru tidak dieksekusi
kolom telepon: tidak adaPada database lama, versi tersebut sudah tercatat sehingga isinya yang baru diabaikan — kolom telepon tidak pernah tercipta. Sementara itu pada database baru (misalnya milik rekan yang baru clone proyek), isi yang diedit justru dijalankan. Dua database yang sama-sama “migrasi terbaru” akhirnya berbeda struktur, dan perbedaan seperti ini selalu muncul pada waktu terburuk.
Jalan keluarnya sederhana: perubahan schema apa pun ditulis sebagai migration baru. Lupa kolom telepon? Buat 2026_01_16_000001_add_telepon_to_users.php yang berisi ALTER TABLE users ADD COLUMN telepon TEXT. Migration lama dibiarkan utuh sebagai catatan sejarah; schema semua database bertemu di titik yang sama lewat jalur yang mungkin berbeda.
Strategi Forward-Only di Produksi #
Database produksi hampir selalu diperlakukan forward-only: migration hanya berjalan maju, tidak pernah dibatalkan. Ada tiga alasan praktis. Pertama, data produksi jauh lebih berharga daripada schema — membatalkan migration yang menambah kolom berarti menghapus nilai di dalamnya. Kedua, rollback di produksi menuntut skrip down yang benar-benar kebalikan up, sesuatu yang sulit dijamin saat panik. Ketiga, ada banyak lingkungan (staging, beberapa server produksi) yang mungkin berada di versi berbeda; bergerak maju satu per satu lebih terkendali daripada maju-mundur di banyak tempat sekaligus.
Konsekuensinya: jika migration terlanjur diterapkan dan ternyata keliru, perbaikannya adalah migration baru yang memperbaiki kerusakan — bukan mengedit yang lama, bukan pula langsung membatalkannya. Teknik ini kita bahas tuntas pada chapter berikutnya, mulai dari menulis skrip down untuk masa pengembangan hingga alternatif forward-fix untuk produksi.
Ringkasan #
Prefix timestamp pada nama berkas menentukan urutan eksekusi tanpa penomoran ulang saat cabang digabungkan; tabel schema_migrations menjadi sumber kebenaran status schema sehingga migrator idempoten; dan migration yang sudah diterapkan tidak boleh diedit — perubahan selalu berjalan lewat migration baru. Arah geraknya selalu maju. Namun di masa pengembangan kita tetap perlu cara menarik kembali migration yang keliru, dan di sanalah skrip down berperan — dibahas pada chapter Migration Rollback
.