Aplikasi yang hidup tidak pernah berhenti berubah: fitur baru menuntut kolom baru, tabel baru, dan sesekali perubahan kolom lama. Schema database Anda kini tinggal di banyak tempat sekaligus — laptop pengembang, server staging, server produksi — dan setiap tempat harus mendapat perubahan yang sama, dalam urutan yang sama. Kalau perubahan dilakukan dengan mengedit langsung lewat alat GUI atau menghafal langkah manual, cepat atau lambat terjadilah schema drift: database satu lingkungan berbeda dengan lingkungan lain, dan aplikasi hanya jalan di tempat singgah terakhir si pengembang. Schema migration adalah solusinya: perubahan schema dikemas sebagai skrip ber-versi yang diterapkan secara otomatis, terurut, dan tercatat. Pada chapter ini kita membangun mini-migrator PHP yang berfungsi penuh, lalu melihat pratinjau alat migrasi yang dipakai industri.
Masalah Schema Drift #
Gejala schema drift selalu sama: kode berjalan sempurna di laptop, lalu gagal di tempat lain dengan pesan error yang menyebutkan struktur tabel. Mari kita peragakan penyebabnya — database yang dibuat sebelum kolom status ditambahkan, diakses oleh kode aplikasi terbaru yang mengasumsikan kolom tersebut ada:
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Database lama: dibuat sebelum kolom 'status' ditambahkan
$pdo->exec("CREATE TABLE posts (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL
)");
// Kode aplikasi terbaru: mengasumsikan kolom 'status' ada
try {
$pdo->exec("SELECT status FROM posts");
} catch (PDOException $e) {
echo "Aplikasi gagal: " . $e->getMessage() . "\n";
}Output yang Dihasilkan:
Aplikasi gagal: SQLSTATE[HY000]: General error: 1 no such column: statusQuery sederhana SELECT status FROM posts langsung gagal dengan no such column: status. Bayangkan error ini muncul di server produksi pukul dua pagi, hanya karena satu lingkungan terlewat saat kolom ditambahkan di tempat lain. Luka seperti ini tidak bisa dijahit dengan “nanti diingat-ingat langkahnya” — kita butuh mekanisme yang tahu persis versi schema setiap database dan bisa menyamakannya secara otomatis.
Anatomi Sebuah Migration #
Migration adalah berkas ber-versi yang berisi perubahan schema, ditulis sekali lalu disimpan bersama kode aplikasi di version control (Git). Setiap migration punya dua arah operasi: up yang menerapkan perubahan (membuat tabel, menambah kolom) dan down yang membalikkannya (menghapus tabel, membuang kolom). Nomor versinya bisa berupa angka urut sederhana atau timestamp seperti 20260115080000 — pola timestamp populer karena dua pengembang yang membuat migration bersamaan tidak akan bertabrakan nomor. Berikut bentuk datanya:
<?php
// migrations/002_create_posts_table.php
return [
"version" => 2,
"name" => "create_posts_table",
"up" => "CREATE TABLE posts (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
)",
"down" => "DROP TABLE posts",
];Satu migration sebaiknya berisi satu perubahan yang focussed: satu tabel baru, atau satu kolom baru, bukan paket besar berisi sepuluh perubahan sekaligus. Migration kecil mudah ditinjau, mudah dibatalkan, dan saat salah satu langkah gagal di tengah jalan, kerusakannya mudah dipetakan. Prinsip ini nanti terasa langsung pada alat nyata di bagian akhir chapter.
Tabel Pelacak schema_migrations #
Pertanyaan kunci migrator: bagaimana mengetahui migration mana yang sudah diterapkan pada sebuah database? Jawabannya disimpan di dalam database itu sendiri, pada tabel pelacak bernama schema_migrations. Tabel ini mencatat versi, nama, dan waktu penerapan setiap migration yang berhasil berjalan:
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL
);Dengan tabel pelacak ini, menjalankan migrator dua kali tidak akan menerapkan migration yang sama dua kali — versi yang sudah tercatat cukup dilewati. Sifat ini disebut idempoten: perintah yang diulang menghasilkan keadaan yang sama, dan karena itu migrator aman dijalankan berkali-kali pada setiap deployment. Inilah obat untuk schema drift: setiap lingkungan cukup menjalankan perintah yang sama, dan database menyamakan dirinya sendiri.
Membuat Mini-Migrator #
Sekarang kita rakit seluruh konsep di atas menjadi mini-migrator yang benar-benar bekerja. Skrip berikut memakai berkas SQLite sementara di /tmp — bukan :memory: — supaya keadaan database bertahan di antara beberapa pemanggilan fungsi, lalu membersihkan berkasnya sendiri di akhir. Perhatikan juga tanggal applied_at yang dikunci pada nilai tetap agar output demo selalu sama (di alat nyata, gunakan waktu saat itu):
<?php
$databaseFile = "/tmp/mini-migrator-demo.sqlite";
if (file_exists($databaseFile)) {
unlink($databaseFile);
}
$pdo = new PDO("sqlite:" . $databaseFile);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("PRAGMA foreign_keys = ON");
$migrations = [
[
"version" => 1,
"name" => "create_users_table",
"up" => "CREATE TABLE users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE
)",
"down" => "DROP TABLE users",
],
[
"version" => 2,
"name" => "create_posts_table",
"up" => "CREATE TABLE posts (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
)",
"down" => "DROP TABLE posts",
],
];
function ensureTrackingTable(PDO $pdo): void
{
$pdo->exec("CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL
)");
}
function appliedVersions(PDO $pdo): array
{
$rows = $pdo->query("SELECT version FROM schema_migrations ORDER BY version")
->fetchAll(PDO::FETCH_COLUMN);
return array_map("intval", $rows);
}
function migrate(PDO $pdo, array $migrations): void
{
ensureTrackingTable($pdo);
$applied = appliedVersions($pdo);
$count = 0;
foreach ($migrations as $migration) {
if (in_array($migration["version"], $applied, true)) {
continue; // sudah pernah diterapkan
}
$pdo->beginTransaction();
try {
$pdo->exec($migration["up"]);
$pdo->prepare(
"INSERT INTO schema_migrations (version, name, applied_at)
VALUES (?, ?, ?)"
)->execute([
$migration["version"],
$migration["name"],
"2026-01-15 08:00:00",
]);
$pdo->commit();
$count++;
echo "Terapkan v{$migration['version']}: {$migration['name']}\n";
} catch (Throwable $error) {
$pdo->rollBack();
throw $error;
}
}
if ($count === 0) {
echo "Tidak ada migration baru.\n";
}
}
function rollbackLast(PDO $pdo, array $migrations): void
{
$applied = appliedVersions($pdo);
if ($applied === []) {
echo "Tidak ada migration untuk dibatalkan.\n";
return;
}
$version = max($applied);
$migration = array_values(array_filter(
$migrations,
fn($m) => $m["version"] === $version
))[0];
$pdo->beginTransaction();
try {
$pdo->exec($migration["down"]);
$pdo->prepare("DELETE FROM schema_migrations WHERE version = ?")
->execute([$version]);
$pdo->commit();
echo "Batalkan v{$version}: {$migration['name']}\n";
} catch (Throwable $error) {
$pdo->rollBack();
throw $error;
}
}
echo "== migrate() pertama ==\n";
migrate($pdo, $migrations);
echo "== migrate() kedua (idempoten) ==\n";
migrate($pdo, $migrations);
echo "== rollback terakhir ==\n";
rollbackLast($pdo, $migrations);
echo "== isi schema_migrations ==\n";
foreach ($pdo->query("SELECT version, name FROM schema_migrations ORDER BY version") as $row) {
echo "v{$row['version']} {$row['name']}\n";
}
$pdo = null;
unlink($databaseFile);Output yang Dihasilkan:
== migrate() pertama ==
Terapkan v1: create_users_table
Terapkan v2: create_posts_table
== migrate() kedua (idempoten) ==
Tidak ada migration baru.
== rollback terakhir ==
Batalkan v2: create_posts_table
== isi schema_migrations ==
v1 create_users_tableMari kita bedah komponen-komponen mini-migrator tersebut:
$migrationsadalah daftar migration terurut; di proyek nyata setiap item hidup sebagai berkas terpisah di foldermigrations/dan dimuat otomatis oleh alat migrasi.ensureTrackingTable()membuat tabelschema_migrationsbila belum ada —IF NOT EXISTSmembuat fungsi ini aman dipanggil berulang.appliedVersions()membaca versi yang sudah tercatat; hasilnya menjadi daftar “yang boleh dilewati”.migrate()berjalan berurutan: lewati versi yang sudah ada, bungkusupdan pencatatan versinya dalam satu transaksi, lalu lanjut ke versi berikutnya. Bila di tengah jalan ada yang gagal,rollBack()mengembalikan keadaan dan exception diteruskan keluar.rollbackLast()mencari versi terbesar yang tercatat, menjalankandown-nya, dan menghapus catatannya.- Urutan eksekusi demo:
migrate()pertama menerapkan v1 dan v2;migrate()kedua tidak melakukan apa-apa karena keduanya sudah tercatat — idempotenti terbukti;rollbackLast()membatalkan v2; dan isischema_migrationsdi akhir hanya menyisakan v1.
Migration yang Atomik #
Perhatikan kembali pola beginTransaction() → exec($migration["up"]) → INSERT INTO schema_migrations → commit() pada fungsi migrate(). Pola ini menjaga dua hal tetap seiring: perubahan schema dan catatan versinya. Bila up berhasil tetapi pencatatan gagal, rollBack() mengembalikan tabel yang barusan dibuat — pada SQLite, pernyataan DDL seperti CREATE TABLE memang bisa dibatalkan oleh transaksi. Tanpa atomisitas ini, database bisa berada di keadaan “tabel sudah ada tetapi tidak tercatat”, dan pemanggilan migrator berikutnya mencoba membuat tabel yang sama untuk kedua kalinya.
Alat Migration di Dunia Nyata #
Mini-migrator kita sengaja minimal agar mekanismenya terlihat utuh. Di proyek nyata, tiga nama paling dikenal di ekosistem PHP: Laravel Migrations yang terintegrasi penuh dengan framework-nya, Phinx yang berdiri sendiri dan bisa dipakai tanpa framework apa pun, serta Doctrine Migrations yang umum dipakai bersama ORM Doctrine atau Symfony. Ketiganya memakai konsep yang persis sama dengan yang baru kita bangun: berkas ber-versi, tabel pelacak, dan perintah migrate/rollback dari terminal.
Sebagai gambaran, berikut wujud migration yang sama (tabel posts) dalam gaya Laravel — bukan SQL mentah, melainkan schema builder berbasis PHP:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create("posts", function (Blueprint $table): void {
$table->id();
$table->foreignId("user_id")->constrained();
$table->string("title");
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists("posts");
}
};Metode $table->foreignId("user_id")->constrained() membuat kolom foreign key sekaligus constraint-nya; $table->timestamps() menambahkan pasangan kolom created_at dan updated_at. Menulis schema lewat builder seperti ini membuat migration portabel antar database yang didukung — kode yang sama menghasilkan DDL yang sesuai untuk MySQL, PostgreSQL, maupun SQLite. Perintah php artisan make:migration dan php artisan migrate kemudian mengurus pembuatan berkas serta penerapannya, lengkap dengan tabel pelacaknya sendiri.
Ringkasan #
Schema drift adalah penyakit diam-diam yang muncul begitu database hidup di banyak lingkungan, dan migration adalah obatnya. Setiap perubahan schema dikemas sebagai berkas ber-versi dengan arah up dan down; tabel schema_migrations mencatat apa yang sudah diterapkan sehingga migrator menjadi idempoten; dan transaksi menjaga perubahan schema beserta catatannya tetap atomik. Mini-migrator di chapter ini memuat semua mekanisme inti tersebut dalam satu berkas yang bisa Anda jalankan ulang berkali-kali. Bagian berikutnya di section ini memperdalam strategi versioning dan rollback pada alat nyata, lalu melanjutkan ke seed data dan cara menguji perubahan database secara aman.