Migration bergerak maju, tetapi masa pengembangan selalu punya momen “migration barusan keliru, tarik kembali dulu”. Rollback adalah mekanisme untuk itu: setiap migration menyertakan skrip down yang membalikkan apa yang dilakukan skrip up. Seperti seatbelt, skrip down harus dipasang sebelum dibutuhkan — dan seperti seatbelt juga, memakainya tetap butuh kehati-hatian karena rollback yang salah bisa menghapus data secara permanen. Pada chapter ini kita akan menulis pasangan up/down yang bisa diuji, mengenali kapan rollback tidak aman, lalu mempelajari alternatif untuk produksi: forward-fix lewat migration baru.

Skrip down sebagai Kebalikan up #

Migration yang bisa di-rollback terdiri dari dua bagian: skrip up yang menerapkan perubahan dan skrip down yang membalikkannya. Aturan praktisnya: apa pun yang dibuat up — tabel, kolom, index — harus dibongkar down dalam urutan terbalik. Untuk menguji bahwa down benar-benar kebalikannya, kita butuh cara memeriksa struktur database secara objektif. SQLite menyediakannya lewat tabel sqlite_master, katalog bawaan yang mencatat semua objek schema; padanannya di MySQL dan PostgreSQL adalah information_schema.

Mari kita terapkan keduanya sekaligus:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

// sqlite_master: katalog schema bawaan SQLite
function daftarTabel(PDO $pdo): array
{
    $names = [];
    $rows = $pdo->query(
        "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
    );
    foreach ($rows as $row) {
        $names[] = $row["name"];
    }
    return $names;
}

$migrations = [
    "2026_01_15_000001_create_users" => [
        "up" => function (PDO $pdo): void {
            $pdo->exec("CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE)");
        },
        "down" => function (PDO $pdo): void {
            $pdo->exec("DROP TABLE users");
        },
    ],
    "2026_01_15_000002_create_profiles" => [
        "up" => function (PDO $pdo): void {
            $pdo->exec("CREATE TABLE profiles (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                bio TEXT,
                user_id INTEGER NOT NULL REFERENCES users(id)
            )");
        },
        "down" => function (PDO $pdo): void {
            $pdo->exec("DROP TABLE profiles");
        },
    ],
];

$terapan = [];
foreach ($migrations as $version => $m) {
    $m["up"]($pdo);
    $terapan[] = $version;
}
echo "setelah up   : " . implode(", ", daftarTabel($pdo)) . "\n";

// rollback selalu dimulai dari migration terakhir
$terakhir = array_pop($terapan);
$migrations[$terakhir]["down"]($pdo);
echo "setelah down : " . implode(", ", daftarTabel($pdo)) . "\n";
echo "dibatalkan   : $terakhir\n";

Output yang Dihasilkan:

text
setelah up   : profiles, users
setelah down : users
dibatalkan   : 2026_01_15_000002_create_profiles

Tiga hal penting terjadi di sini. Pertama, array $terapan bekerja persis seperti tabel schema_migrations pada chapter Migration Versioning — rollback memeriksa versi terakhir yang diterapkan, bukan menebak-menebak. Kedua, rollback selalu membongkar dari belakang: create_profiles dibatalkan lebih dulu karena dialah yang paling baru dibangun, dan ia-lah yang bergantung pada users — bukan sebaliknya. Ketiga, fungsi daftarTabel() membuktikan hasilnya lewat katalog schema, bukan lewat perasaan: setelah down, tabel profiles sungguh hilang dari sqlite_master sementara users tetap ada.

Kapan Rollback Tidak Aman #

Skrip down pada contoh di atas menjalankan DROP TABLE — dan di sinilah bahaya rollback bersembunyi. DROP TABLE tidak hanya membuang struktur, tetapi juga seluruh isinya. Menjalankan up kembali mengembalikan struktur, tetapi tidak mengembalikan data yang sudah terhapus:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$up = function (PDO $pdo): void {
    $pdo->exec("CREATE TABLE kategori (id INTEGER PRIMARY KEY, nama TEXT)");
};
$down = function (PDO $pdo): void {
    $pdo->exec("DROP TABLE kategori");
};

$up($pdo);
$pdo->exec("INSERT INTO kategori (nama) VALUES ('Elektronik'), ('Buku')");
echo "setelah up + isi data: " . $pdo->query("SELECT COUNT(*) FROM kategori")->fetchColumn() . " baris\n";

$down($pdo);
$up($pdo); // struktur dikembalikan, tapi...

echo "setelah down + up lagi: " . $pdo->query("SELECT COUNT(*) FROM kategori")->fetchColumn() . " baris\n";

Output yang Dihasilkan:

text
setelah up + isi data: 2 baris
setelah down + up lagi: 0 baris

Dua baris kategori lenyap permanen meskipun struktur tabelnya kembali. Pola yang sama berlaku untuk kasus yang lebih halus: migration yang menambah kolom lalu di-rollback akan menghapus semua nilai di kolom itu; migration yang mempersempit tipe data bisa memotong nilai yang tidak muat. Pertanyaan yang selalu harus dijawab sebelum rollback bukan “apakah down berjalan?”, melainkan “data apa yang ikut hilang?”.

Cadangkan Database sebelum Rollback #

Karena rollback selalu punya risiko merusak, jadikan pencadangan sebagai pintu masuknya. SQLite menyediakan pernyataan VACUUM INTO yang menyalin seluruh database ke berkas baru dalam kondisi konsisten — jauh lebih aman daripada menyalin berkas secara manual saat database sedang aktif:

php
<?php
$dbFile = "/tmp/rollback-demo.sqlite";
$backupFile = "/tmp/rollback-demo-backup.sqlite";
@unlink($dbFile);
@unlink($backupFile);

$pdo = new PDO("sqlite:$dbFile");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE pesanan (id INTEGER PRIMARY KEY, total INTEGER)");
$pdo->exec("INSERT INTO pesanan (total) VALUES (150000), (275000)");

// Cadangan sebelum rollback (SQLite 3.27+)
$pdo->exec("VACUUM INTO '$backupFile'");
echo "berkas cadangan: " . (file_exists($backupFile) ? "ada" : "tidak ada") . "\n";

// Simulasi rollback yang merusak
$pdo->exec("DROP TABLE pesanan");

// Buka cadangan dan periksa datanya
$pdoBackup = new PDO("sqlite:$backupFile");
$pdoBackup->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "baris pesanan di cadangan: " . $pdoBackup->query("SELECT COUNT(*) FROM pesanan")->fetchColumn() . "\n";

unlink($dbFile);
unlink($backupFile);

Output yang Dihasilkan:

text
berkas cadangan: ada
baris pesanan di cadangan: 2

Urutan kodenya sengaja dibuat cermin dari skenario buruk: cadangan dibuat sebelum DROP TABLE dijalankan, sehingga koneksi kedua yang membuka berkas cadangan masih menemukan kedua baris pesanan utuh. Panggilan @unlink() di awal sekadar membersihkan sisa percobaan sebelumnya, dan dua unlink() di akhir merapikan berkas sementara. Untuk MySQL atau PostgreSQL, perannya digantikan mysqldump dan pg_dump — prinsipnya sama: cadangkan dulu, batalkan kemudian.

Alur pengambilan keputusannya bisa diringkas menjadi diagram berikut:

Diagram di atas menegaskan dua hal: pencadangan dilakukan di kedua jalur (bahkan rollback yang “aman” tetap dicadangkan), dan jawaban atas pertanyaan keamanan data-lah yang menentukan pilihan antara rollback dan forward-fix — bukan kecepatan jalan keluar.

Squashing Migration Lama #

Setelah berbulan-bulan, proyek bisa menumpuk puluhan berkas migration — dan setiap anggota tim baru harus menjalankan semuanya berurutan dari nol. Squashing merapikan situasi ini: seluruh riwayat lama diganti menjadi satu migration baseline yang berisi schema akhir.

text
migrations/
├── 2026_01_10_000001_create_categories.php
├── 2026_01_15_000001_create_users.php
├── ... 57 berkas lainnya ...
└── 2026_05_30_000003_add_index_to_email.php
text
migrations/
└── 2026_06_01_000001_baseline.php

Syaratnya ketat: hanya migration yang sudah diterapkan di semua lingkungan (laptop semua developer, staging, produksi) yang boleh digabungkan. Prosesnya sendiri bersifat manual dan hati-hati — buat schema akhir dari database yang sudah termigrasi penuh, tulis sebagai baseline.php, hapus berkas-berkas lama, lalu sesuaikan isi tabel versi agar hanya berisi baseline. Squashing adalah operasi koordinasi tim, bukan rutinitas harian; sekali setahun ketika riwayat sudah tidak lagi memberi nilai historis adalah irama yang wajar.

Forward-Fix: Perbaiki ke Depan #

Ingat strategi forward-only dari chapter sebelumnya: produksi tidak mundur. Lalu bagaimana menangani migration yang terlanjur keliru — misalnya kolom harga terbuat bertipe TEXT padahal seharusnya INTEGER? Jawabannya: tulis migration baru yang memperbaiki kerusakannya, lalu deploy seperti migration biasa.

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

// Migration 001 (terlanjur diterapkan, berisi kesalahan):
// kolom harga seharusnya INTEGER, tapi terbuat sebagai TEXT
$pdo->exec("CREATE TABLE produk (id INTEGER PRIMARY KEY, harga TEXT)");
$pdo->exec("INSERT INTO produk (harga) VALUES ('15000'), ('27500')");

// Migration 002 - forward-fix: perbaiki lewat migration baru
$pdo->exec("CREATE TABLE produk_baru (id INTEGER PRIMARY KEY, harga INTEGER)");
$pdo->exec("INSERT INTO produk_baru (id, harga) SELECT id, CAST(harga AS INTEGER) FROM produk");
$pdo->exec("DROP TABLE produk");
$pdo->exec("ALTER TABLE produk_baru RENAME TO produk");

$row = $pdo->query("SELECT harga, typeof(harga) FROM produk WHERE id = 1")->fetch(PDO::FETCH_NUM);
echo "harga: {$row[0]} (tipe: {$row[1]})\n";

Output yang Dihasilkan:

text
harga: 15000 (tipe: integer)

Forward-fix di atas memakai pola table rebuild: buat tabel baru dengan struktur yang benar, salin datanya dengan CAST, buang tabel lama, lalu ganti nama. Data 15000 dan 27500 selamat karena disalin sebelum tabel lama dihapus — inilah bedanya dengan rollback yang justru menghancurkan data untuk memulihkan struktur.

Ringkasan #

Skrip down harus menjadi kebalikan persis dari up, dan kebalikannya dibuktikan lewat katalog schema seperti sqlite_master. Namun rollback punya batas: DROP TABLE dan perubahan destruktif lainnya menghapus data secara permanen, sehingga pencadangan menjadi syarat mutlak dan forward-fix menjadi pilihan yang lebih aman di produksi. Setelah schema bergerak dengan aman lewat versioning dan rollback, masih ada satu kekosongan yang menyambut setiap database baru: isinya. Di situlah seed data berperan, dibahas pada chapter Seed Data .

Referensi #