Menjalankan php artisan migrate di laptop hampir bebas risiko — database lokal tinggal di-refresh dengan migrate:fresh. Di production, tabel yang sama berisi pesanan pelanggan dan riwayat transaksi nyata, sehingga satu migration yang salah bisa menghapus kolom yang masih dipakai kode lama. Chapter ini membahas perintah migrasi produksi yang benar, strategi urutan migrasi dalam alur deploy, dan pola perubahan skema yang aman untuk data hidup — semuanya diuji nyata dengan environment production.

Kenapa –force Dibutuhkan #

Ketika APP_ENV=production, Laravel memaksa perintah yang mengubah data meminta konfirmasi eksplisit. Kita coba langsung dengan override environment dari shell dan menjawab no pada prompt:

bash
APP_ENV=production php artisan migrate

Output yang Dihasilkan:

text
                           APPLICATION IN PRODUCTION.

   WARN  Command cancelled.  

Perintah ditolak begitu kita menjawab tidak — dan hasil yang sama terjadi dengan --no-interaction karena penolakan adalah default aman. Script deployment berjalan tanpa manusia untuk menjawab prompt, sehingga niat eksekusi harus dinyatakan eksplisit:

bash
APP_ENV=production php artisan migrate --force

Output yang Dihasilkan:

text
   INFO  Running migrations.  

  2026_10_13_221830_add_biografi_to_users_table .................. 1.82ms DONE

Backup Sebelum Migrasi #

Aturan pertama migrasi produksi: jangan pernah migrasi tanpa jalan pulang. Backup harus dibuat sebelum perintah migrate --force berjalan. Pada SQLite, operasi VACUUM INTO membuat salinan konsisten dari database tanpa mengganggu koneksi berjalan:

php
<?php
$pdo = new PDO('sqlite:database/database.sqlite');
$ok = $pdo->exec("VACUUM INTO 'database/backup-pre-migrasi.sqlite'");
echo $ok === false ? 'GAGAL' : 'OK', PHP_EOL;

Output yang Dihasilkan:

text
OK
bash
ls -la database/

Output yang Dihasilkan:

text
-rw-rw-r--  1 z z 122880 Oct 13 22:17 database.sqlite
-rw-rw-r--  1 z z 122880 Oct 13 22:18 backup-pre-migrasi.sqlite

Untuk MySQL di server sungguhan, padanannya adalah mysqldump — contoh tampilan perintahnya:

bash
mysqldump --single-transaction --routines \
  -u tokobudi_app -p tokobudi_prod > backup-$(date +%F).sql

Opsi --single-transaction mengambil snapshot konsisten tanpa mengunci tabel, dan berkas .sql hasilnya bisa dipulihkan dengan mysql tokobudi_prod < backup-....sql. Uji pemulihan backup setidaknya sekali — backup yang belum pernah direstore hanyalah harapan, bukan rencana.

Waktu Migrasi dalam Alur Deploy #

Ada dua strategi kapan migrasi dijalankan relatif terhadap pergantian kode. Migrasi sebelum kode baru aktif cocok untuk perubahan aditif (kolom baru ditambahkan dulu; kode lama tidak mengenalinya, kode baru langsung siap). Migrasi sesudah kode aktif diperlukan saat kolom lama dihapus: kode baru harus berhenti membacanya lebih dulu. Kebanyakan rilis sehat memakai strategi aditif sehingga migrasi bisa berjalan di awal alur deploy tanpa downtime.

Pola Migrasi Aman: Perubahan Aditif #

Pola paling aman untuk kolom baru adalah empat langkah yang tidak pernah mengubah data lama secara destruktif. Kita praktekkan dengan menambahkan kolom biografi ke tabel users:

  1. Tambahkan kolom nullable lewat migration.
  2. Deploy kode yang mulai menulis dan membaca kolom tersebut.
  3. Backfill nilai untuk baris lama secara bertahap.
  4. Baru pertimbangkan menjadikannya wajib (mis. kolom NOT NULL terpisah).

Langkah pertama adalah migration aditif berikut:

php
public function up(): void
{
    Schema::table('users', function (Blueprint $table) {
        $table->text('biografi')->nullable();
    });
}

Karena nullable, baris yang sudah ada tidak perlu diberi nilai — migration selesai dalam milidetik. Setelah migrate --force, struktur tabel terbukti berubah:

bash
php -r '$p=new PDO("sqlite:database/database.sqlite");
foreach($p->query("PRAGMA table_info(users)") as $c){echo $c["name"],"\n";}'

Output yang Dihasilkan:

text
id
name
email
email_verified_at
password
remember_token
created_at
updated_at
biografi

Kolom biografi berada di baris terakhir, dan status migrasinya tercatat dengan nomor batch baru:

bash
php artisan migrate:status

Output yang Dihasilkan:

text
  Migration name .............................................. Batch / Status  
  0001_01_01_000000_create_users_table ............................... [1] Ran  
  0001_01_01_000001_create_cache_table ............................... [1] Ran  
  0001_01_01_000002_create_jobs_table ................................ [1] Ran  
  2026_10_13_153049_create_personal_access_tokens_table .............. [1] Ran  
  2026_10_13_221830_add_biografi_to_users_table ...................... [2] Ran  

Backfill di langkah ketiga dikerjakan per-cabang agar tidak mengunci tabel besar sekaligus: User::whereNull('biografi')->chunkById(500, fn ($users) => $users->each->update([...])).

Rencana Rollback #

Setelah migrasi berjalan, migrate:rollback mengembalikan skema dengan memanggil method down():

bash
APP_ENV=production php artisan migrate:rollback --force

Output yang Dihasilkan:

text
  2026_10_13_221830_add_biografi_to_users_table .................. 4.46ms DONE

Kolom biografi lenyap dari PRAGMA table_info(users) — namun begitu juga datanya. Karena down() yang destruktif hanya menyelamatkan skema, pemulihan data sejati tetap bergantung pada backup yang dibuat sebelum migrasi. Untuk tabel berukuran besar (puluhan juta baris), migrasi berat seperti penambahan index sebaiknya dipecah atau dijadwalkan di jam sepi karena ALTER TABLE bisa berjalan menit-ke-jam. Kebiasaan ini melengkapi dasar konsep migration dan rollback lokal yang sudah kita kuasai, kini dengan taruhannya yang sebenarnya.

Skema database aman, tapi aplikasi masih menyimpan satu area yang setiap hari ditulis berulang kali oleh server itu sendiri: direktori storage/. Kesalahan izin di area inilah penyebab klasik halaman putih 500 setelah rilis pertama — kita urai di Storage Permissions .