Banyak proyek Laravel berjalan mulus di lokal, lalu mendapati anomali saat ke staging/produksi: urutan sort yang berbeda,
indeks mendadak meledak, data yang “diam-diam” terpotong, atau jam yang tidak sinkron. Seringkali penyebabnya sederhana:
kita membiarkan default MySQL apa adanya. Artikel ini membedah risiko-risiko tersebut dan memberikan pola aman yang
pragmatis—tanpa mengorbankan kenyamanan pengembangan sehari-hari.
Ringkasan Cepat
- Samakan charset/collation: Pastikan skema, tabel, dan kolom konsisten (idealnya
utf8mb4+ collation yang tepat). - Kendalikan panjang indeks: Hindari workaround global
defaultStringLength(191)bila Anda pakai MySQL 5.7+/8—benahi di akar. - Gunakan SQL strict mode: Biar kegagalan terlihat cepat, bukan menyimpan utang teknis.
- Selaraskan timezone: Simpan UTC di DB, konversi di aplikasi/UI.
- Pilih tipe data dengan sadar:
decimaluntuk uang,jsonuntuk semi-terstruktur,bigIncrementsuntuk tabel besar.
1) Charset & Collation: Konsistensi yang Menentukan
Laravel cenderung menset utf8mb4 dan kolasi berbasis Unicode. Namun banyak server MySQL lama/host bersama masih
menyalakan default lain (mis. latin1), lalu tabel/kolom baru warisi default yang berbeda. Dampaknya:
- Sort/compare berbeda: ORDER BY berubah antara lingkungan.
- Emoji/karakter non-BMP rusak: Selain data korup, indeks bisa gagal bila panjang byte tak cukup.
- Join error: “Illegal mix of collations” saat menggabungkan kolom dengan kolasi tak seragam.
// config/database.php (cuplikan)
'mysql' => [
'driver' => 'mysql',
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci', // Pertimbangkan 'utf8mb4_0900_ai_ci' di MySQL 8+
'engine' => null,
'strict' => true,
// 'options' => [PDO::MYSQL_ATTR_INIT_COMMAND => "SET time_zone = '+00:00'"],
],
-- Audit cepat kolasi di server
SHOW VARIABLES LIKE 'character_set_%';
SHOW VARIABLES LIKE 'collation%';
-- Tabel/kolom yang kolasinya berbeda
SELECT t.TABLE_SCHEMA, t.TABLE_NAME, t.TABLE_COLLATION
FROM information_schema.TABLES t
WHERE t.TABLE_SCHEMA = DATABASE()
ORDER BY t.TABLE_COLLATION;
Rekomendasi: Standardisasi pada level database dan override per tabel bila perlu:
// Migration contoh
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title')
->charset('utf8mb4')
->collation('utf8mb4_unicode_ci');
$table->text('body');
$table->timestampsTz(); // simpan UTC, tampilkan sesuai zona di UI
});
2) Panjang String & Batas Indeks: 191 vs 255
Di MySQL lama (batas indeks 767 byte), utf8mb4 membuat indeks pada VARCHAR(255) gagal. Solusi historis: Schema::defaultStringLength(191).
Ini bekerja, tetapi menyembunyikan masalah lingkungan dan menurunkan ketelitian indeks.
- MySQL 5.7+/8: Dengan InnoDB modern (row format DYNAMIC), batas indeks efektif 3072 byte—hindari workaround global.
- Prefer refactor: Upgrade server/engine/row format daripada memendekkan semua kolom secara buta.
- Kasus unik: Untuk kolom unik email/slug, batasi panjang kolom secara semantik (mis. 200) bila Anda benar-benar butuh.
// Hindari: di AppServiceProvider men-set defaultStringLength(191)
// Gunakan design yang jelas: batasi panjang kolom yang memang perlu
$table->string('slug', 200)->unique();
3) SQL Modes & Strict Mode: Gagal Cepat Lebih Murah
Tanpa strict mode, MySQL bisa memotong nilai diam-diam (truncation) atau mengkonversi nilai tak valid. Ini berbahaya karena
bug terlihat sangat terlambat. Laravel defaultnya strict: true—pertahankan demikian dan sinkronkan daftar mode di server.
- ONLY_FULL_GROUP_BY: Paksa agregasi benar. Perbaiki query, jangan matikan.
- NO_ZERO_DATE/NO_ZERO_IN_DATE: Cegah tanggal semu
0000-00-00. - ERROR_FOR_DIVISION_BY_ZERO: Lebih baik meledak daripada hasil kacau.
-- Lihat SQL_MODE
SELECT @@GLOBAL.sql_mode, @@SESSION.sql_mode;
4) NULL vs String Kosong: Selaraskan dengan Validasi
Default kolom sering nullable tanpa alasan. Padahal pada domain tertentu, “tidak diisi” berbeda makna dengan “diisi tapi kosong”.
Pastikan migrasi dan rules Laravel konsisten agar analitik tidak bias.
// Migration: eksplisitkan nullability
$table->string('middle_name')->nullable(); // benar-benar boleh kosong
$table->string('phone')->nullable(false); // wajib diisi
// Form request
'phone' => ['required','string','max:32']
5) Timestamps & Timezone: Simpan UTC, Tampilkan Lokal
Default timestamps() tidak menyertakan zona. Simpan UTC di DB dan konversikan di aplikasi/klien.
Hindari campuran timezone antar layanan.
// Migration: gunakan tipe bertzona bila tersedia
$table->timestampsTz(); // created_at/updated_at dengan zona
$table->softDeletesTz(); // deleted_at dengan zona
// AppServiceProvider (opsional): set session time_zone
use Illuminate\Support\Facades\DB;
public function boot(): void
{
DB::listen(function () {}); // agar koneksi terinisiasi
DB::unprepared("SET time_zone = '+00:00'"); // simpan UTC
}
Catatan: Pastikan server MySQL punya tabel zona waktu lengkap jika menggunakan nama zona.
6) Engine, Row Format, dan Stabilitas Indeks
InnoDB adalah pilihan aman. Di server lama, pastikan row format DYNAMIC agar indeks panjang (utf8mb4) aman.
-- Cek engine/row format
SELECT TABLE_NAME, ENGINE, ROW_FORMAT
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE();
// Migration: set per tabel bila perlu
Schema::create('users', function (Blueprint $table) {
$table->engine = 'InnoDB';
$table->id();
$table->string('email')->unique();
$table->timestamps();
});
7) Tipe Numerik & Uang: Hindari Float untuk Nilai Finansial
- Gunakan DECIMAL:
decimal(18,2)atau sesuai kebutuhan agar akurat. - Unsigned untuk ID/Hitung ke atas: Hemat rentang dan konsisten dengan relasi.
- Skala besar: Gunakan
bigIncrements/unsignedBigIntegersejak awal untuk tabel yang akan tumbuh cepat.
// Migration
$table->decimal('amount', 18, 2)->default(0);
$table->unsignedBigInteger('user_id');
8) Kolom JSON: Kekuatan & Perbedaan MySQL vs MariaDB
Di MySQL 5.7+, JSON punya validasi dan operator bawaan. Di MariaDB, tipe JSON hanyalah alias LONGTEXT.
Ketahui platform Anda sebelum mengandalkan fungsi lanjutan.
// Query builder
User::whereJsonContains('meta->roles', 'admin')->get();
// Index pada path JSON (MySQL): gunakan kolom terhitung
ALTER TABLE users
ADD COLUMN role_first VARCHAR(32)
GENERATED ALWAYS AS (json_unquote(json_extract(meta, '$.roles[0]'))) STORED,
ADD INDEX idx_role_first (role_first);
9) Boolean & Enum: Tegaskan Intent
- Boolean = TINYINT(1): Tetap angka. Dokumentasikan maknanya dan gunakan cast Eloquent.
- Enum: Di MySQL 8+ bisa kombinasikan dengan CHECK untuk validasi tambahan, atau gunakan tabel referensi.
// Model cast
protected $casts = [
'is_active' => 'boolean',
];
10) Ordering, Collation-Sensitive LIKE, dan Pencarian
Kolasi memengaruhi perbandingan/penyortiran. Case-insensitive mungkin bagus untuk pencarian, tetapi bisa
menabrak kebutuhan case-sensitive tertentu. Terapkan kolasi pada level kolom atau query saat diperlukan.
// Case-sensitive exact match
User::whereRaw("BINARY username = ?", [$input])->first();
// Pakai kolasi berbeda di query
Post::orderByRaw("title COLLATE utf8mb4_0900_as_cs ASC")->get();
11) Migrations & Konvensi: Lebih Eksplisit, Lebih Sedikit Kejutan
- Eksplisitkan panjang dan nullability: Hindari default yang kabur.
- Gunakan
timestampsTz/softDeletesTz: Seragamkan waktu di UTC. - Index yang bermakna: Nama indeks, urutan kolom, dan panjang kolom yang realistis.
- Hindari hack global: Seperti
defaultStringLength(191)kecuali benar-benar diperlukan untuk kompatibilitas sementara.
// Contoh migration terstandar
Schema::create('orders', function (Blueprint $table) {
$table->bigIncrements('id');
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('number', 40)->unique();
$table->decimal('total', 18, 2)->default(0);
$table->json('meta')->nullable();
$table->timestampsTz();
$table->index(['user_id', 'created_at']);
});
12) Observability & Audit: Kenali Lingkungan Anda
-- Temukan kolom dengan collation berbeda dari default DB
SELECT c.TABLE_NAME, c.COLUMN_NAME, c.COLLATION_NAME
FROM information_schema.COLUMNS c
WHERE c.TABLE_SCHEMA = DATABASE()
AND (c.COLLATION_NAME IS NOT NULL)
ORDER BY c.COLLATION_NAME;
-- Cari indeks yang berisiko (panjang besar pada utf8mb4)
SELECT TABLE_NAME, INDEX_NAME, SUM(CARDINALITY) AS card
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
GROUP BY TABLE_NAME, INDEX_NAME
ORDER BY card ASC;
// Cek nilai runtime di Laravel (Tinker)
DB::select('select @@version as v, @@sql_mode as m, @@time_zone as tz');
13) Checklist Produksi
- [ ] DB, tabel, dan kolom memakai
utf8mb4+ kolasi seragam. - [ ] Strict mode aktif;
ONLY_FULL_GROUP_BYtidak dinonaktifkan demi kenyamanan. - [ ] Timestamps disimpan UTC; UI mengonversi ke zona pengguna.
- [ ] Kolom finansial memakai
decimal; tidak adafloatuntuk uang. - [ ] Panjang kolom dan indeks dievaluasi semantik; tidak mengandalkan hack global.
- [ ] Perbedaan MySQL vs MariaDB diakomodasi (terutama JSON & kolasi).
- [ ] Migrations eksplisit; tidak ada kolom nullable tanpa alasan.
- [ ] Audit Dev/Stage/Prod memiliki konfigurasi yang konsisten.
Penutup
Default nyaman di awal, tetapi mahal di akhir. Dengan menstandardisasi charset/collation, mengaktifkan strict mode,
menyelaraskan timezone, memilih tipe data yang tepat, dan menulis migrasi eksplisit, Anda mengurangi kelas problem
“aneh di produksi”. Perlakukan database sebagai bagian dari desain sistem—bukan sekadar tempat menyimpan data.