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:

php
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:

bash
php artisan db:table comments

Output yang Dihasilkan:

text
  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 SQLHelper di BlueprintPerilaku 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:

php
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:

php
echo DB::select('PRAGMA foreign_keys')[0]->foreign_keys, "\n";

Output yang Dihasilkan:

text
1

Nilai 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:

php
$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:

text
Sebelum hapus: 2 komentar
Sesudah hapus user: 0 komentar

Satu 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:

php
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:

text
QueryException: FOREIGN KEY constraint failed
code SQLSTATE: 23000

SQLite 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:

php
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.