Aplikasi nyata hampir tidak pernah menyimpan data dalam satu tabel; posts milik users, comments menempel pada posts, dan seterusnya. Tanpa penjaga khusus, tidak ada yang mencegah baris komentar menunjuk pengguna yang tidak pernah ada — data yatim semacam ini perlahan merusak kepercayaan terhadap seluruh aplikasi. Foreign key menyelesaikan masalah itu di level database: setiap nilai kolom relasi wajib merujuk baris nyata di tabel lain, dan kita bebas memilih apa yang terjadi pada anak saat induknya dihapus. Chapter ini menguji semua janji tersebut langsung di SQLite, lengkap dengan galat aslinya.
Menghubungkan Tabel dengan foreignId()->constrained() #
Cara modern mendeklarasikan relasi adalah menggabungkan dua method. Migration berikut membuat tabel comments yang merujuk ke users:
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->text('isi');
$table->timestamps();
});Method foreignId('user_id') membuat kolom unsigned big integer bernama user_id. Method constrained() kemudian menambahkan constraint yang merujuk kolom id pada tabel users — nama tabel dan kolomnya ditebak dari konvensi penamaan, sehingga kita tidak perlu menulisnya manual. Perintah db:table comments setelah migrate memperlihatkan constraint yang terpasang:
php artisan db:table commentsOutput yang Dihasilkan:
Index ......................................................................
primary id ......................................................... primary
Foreign Key .......................................... On Update / On Delete
user_id references id on users ......................... no action / cascade Perhatikan kembali bagian Index: di SQLite, kolom foreign key tidak otomatis mendapat index — bukti yang sama dengan temuan kita di chapter Indexes dan Constraints
.
Opsi onDelete #
Perilaku saat baris induk dihapus ditentukan opsi onDelete. Tabel berikut merangkum pilihan yang tersedia:
| Aksi SQL | Helper di Blueprint | Perilaku saat induk dihapus |
|---|---|---|
cascade | ->cascadeOnDelete() | semua baris anak ikut dihapus |
restrict | ->restrictOnDelete() | penghapusan induk ditolak selama masih punya anak |
set null | ->nullOnDelete() | kolom relasi anak diisi NULL (kolom wajib ->nullable()) |
no action | ->noActionOnDelete() | mirip restrict, cek dilakukan di akhir pernyataan |
Kita uji nullOnDelete() pada tabel sementara reservations. Deklarasinya sebagai berikut:
Schema::create('reservations', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('kode');
$table->timestamps();
});SQLite menyusunnya menjadi foreign key("user_id") references "users"("id") on delete set null. Setelah kita sisipkan satu reservasi milik Budi lalu menghapus user Budi, kolom user_id reservasi tersebut berubah menjadi NULL — reservasinya tetap ada, tidak ikut lenyap. Pilihan opsi adalah keputusan bisnis: komentar boleh di-cascade bersama user, tetapi catatan transaksi keuangan sebaiknya restrict supaya historinya tidak pernah hilang diam-diam.
SQLite Menegakkan Foreign Key Secara Default #
Menariknya, SQLite sebenarnya mengabaikan foreign key kecuali pragma foreign_keys diaktifkan. Laravel memang melakukannya secara otomatis pada setiap koneksi, dan kita bisa membuktikannya:
echo DB::select('PRAGMA foreign_keys')[0]->foreign_keys, "\n";Output yang Dihasilkan:
1Nilai 1 berarti penegakan aktif — semua uji di bawah ini berlaku karena Laravel menyalakannya untuk kita tanpa konfigurasi tambahan.
Uji Cascade: Hapus User, Komentar Ikut Hilang #
Sekarang uji sesungguhnya untuk cascadeOnDelete(). Skrip berikut membuat user Budi, dua komentar miliknya, lalu menghapus Budi:
$budi = DB::table('users')->insertGetId([
'name' => 'Budi', 'email' => '[email protected]', 'password' => 'x',
]);
DB::table('comments')->insert([
['user_id' => $budi, 'isi' => 'Komentar pertama', 'created_at' => '2026-01-01 09:00:00', 'updated_at' => '2026-01-01 09:00:00'],
['user_id' => $budi, 'isi' => 'Komentar kedua', 'created_at' => '2026-01-02 09:00:00', 'updated_at' => '2026-01-02 09:00:00'],
]);
echo 'Sebelum hapus: ', DB::table('comments')->count(), " komentar\n";
DB::table('users')->where('id', $budi)->delete();
echo 'Sesudah hapus user: ', DB::table('comments')->count(), " komentar\n";Output yang Dihasilkan:
Sebelum hapus: 2 komentar
Sesudah hapus user: 0 komentarSatu perintah delete() pada tabel users membuat dua baris comments lenyap tanpa satu baris kode penghapus tambahan. Perlu dicatat bahwa cascade ini dikerjakan oleh database, bukan oleh PHP: apakah kita menghapus lewat Eloquent, query builder, atau database client sekalipun, hasilnya sama.
Pelanggaran Foreign Key #
Sisi lain constraint: penyisipan anak dengan induk fiktif wajib gagal. Mari kita coba menyisipkan komentar dengan user_id 999 yang tidak ada:
DB::table('comments')->insert([
'user_id' => 999,
'isi' => 'Komentar yatim',
'created_at' => '2026-01-03 09:00:00',
'updated_at' => '2026-01-03 09:00:00',
]);Output yang Dihasilkan:
QueryException: FOREIGN KEY constraint failed
code SQLSTATE: 23000SQLite menolak penyisipan dengan galat yang tegas. Bandingkan pesan ini dengan pelanggaran unique di chapter sebelumnya — keduanya berbagi kode SQLSTATE 23000, karena keduanya sama-sama pelanggaran integritas.
Sintaks Eksplisit dan Kolom Non-id #
constrained() mengandalkan konvensi. Saat relasi tidak mengikuti konvensi — misalnya merujuk kolom selain id — kita menulis constraint secara eksplisit:
Schema::create('invitations', function (Blueprint $table) {
$table->id();
$table->string('email');
$table->foreign('email')->references('email')->on('users');
$table->timestamps();
});Rantai foreign()->references()->on() menyebutkan kolom dan tabel sasaran secara gamblang. Pengujian di SQLite menghasilkan foreign key("email") references "users"("email") pada CREATE TABLE-nya; syaratnya satu, kolom yang dirujuk haruslah unique atau primary key. Bentuk eksplisit ini juga berguna bila nama kolom kita tidak cocok dengan konvensi foreignId, misalnya owner_id yang merujuk tabel users. Seluruh opsi relasi semacam ini dirinci di dokumentasi migrations
.
Skema kini lengkap: kolomnya bertipe tepat, terindeks, dan tersambung antar tabel. Tugas berikutnya adalah mengisinya — dari data master sampai data awal aplikasi — lewat database seeder, topik chapter berikutnya.