Bayangkan mencari satu kata di kamus 1000 halaman tanpa daftar isi atau urutan abjad — kita terpaksa membuka halaman satu per satu. Database mengalami hal serupa: tanpa index, query WHERE membaca setiap baris tabel, dan tabel berisi lima puluh ribu baris mulai terasa berat. Chapter ini membahas cara mendeklarasikan index dan constraint langsung di migration: index biasa, unique, composite index, dan primary key, lengkap dengan bukti galat nyata saat constraint dilanggar. Pembahasan tuning performa mendalam kita simpan untuk Section Performa dan Optimasi ; fokus kita sekarang adalah skema.

Apa Itu Index #

Index adalah struktur data tambahan yang menjaga salinan nilai kolom dalam keadaan terurut. Berkat urutan tersebut, database bisa melompat langsung ke nilai yang dicari alih-alih memindai seluruh tabel — pencarian jadi jauh lebih cepat, terutama saat tabel membesar. Struktur ini bukan hadiah gratis: setiap INSERT dan UPDATE harus turut memperbarui semua index pada tabelnya, sehingga index yang tidak pernah dipakai justru memperlambat penulisan data.

Menambahkan Index di Migration #

Index dideklarasikan di dalam Schema::create() atau Schema::table(). Migration berikut membuat tabel tasks dengan tiga jenis deklarasi sekaligus:

php
Schema::create('tasks', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id');
    $table->string('judul');
    $table->string('slug')->unique();
    $table->string('status', 20);
    $table->timestamps();
    $table->index('judul');
    $table->index(['status', 'created_at'], 'tasks_status_waktu_idx');
});

Baris ->unique() pada kolom slug membuat unique index — index sekaligus janji bahwa nilainya tidak boleh ganda. Baris $table->index('judul') membuat index biasa dengan nama otomatis tasks_judul_index, sedangkan $table->index(['status', 'created_at'], ...) membuat composite index dari dua kolom dengan nama kustom. Setelah php artisan migrate, db:table tasks memperlihatkan seluruh hasilnya:

bash
php artisan db:table tasks

Output yang Dihasilkan:

text
  main.tasks .................................................................  
  Columns .................................................................. 7  

  Column ................................................................ Type  
  id integer, autoincrement .......................................... integer  
  user_id integer .................................................... integer  
  judul varchar ...................................................... varchar  
  slug varchar ....................................................... varchar  
  status varchar ..................................................... varchar  
  created_at datetime, nullable ..................................... datetime  
  updated_at datetime, nullable ..................................... datetime  

  Index ......................................................................  
  primary id ......................................................... primary  
  tasks_judul_index judul ....................................................  
  tasks_slug_unique slug .............................................. unique  
  tasks_status_waktu_idx status, created_at ......................... compound  

Empat index terdaftar rapi: primary key bawaan id, index tunggal judul, unique index slug, dan composite index tasks_status_waktu_idx. Perhatikan juga bahwa kolom user_id tidak memiliki index apa pun — poin ini penting dan kita bahas lagi di bagian akhir chapter.

Unique Constraint Melindungi Data #

Unique index bekerja sebagai penjaga di level database. Mari kita buktikan dengan menyisipkan dua baris ber-slug sama melalui skrip uji:

php
DB::table('tasks')->insert([
    'user_id' => 1,
    'judul' => 'Belajar index',
    'slug' => 'belajar-index',
    'status' => 'todo',
    'created_at' => '2026-01-05 08:00:00',
    'updated_at' => '2026-01-05 08:00:00',
]);

DB::table('tasks')->insert([
    'user_id' => 1,
    'judul' => 'Duplikat slug',
    'slug' => 'belajar-index', // slug yang sama
    'status' => 'todo',
    'created_at' => '2026-01-05 08:10:00',
    'updated_at' => '2026-01-05 08:10:00',
]);

Output yang Dihasilkan:

text
QueryException: UNIQUE constraint failed: tasks.slug
code SQLSTATE: 23000

Insert kedua ditolak mentah-mentah oleh SQLite dengan kode SQLSTATE 23000 (integrity constraint violation). Validasi di aplikasi tetap berguna untuk pesan galat yang ramah pengguna, tetapi constraint database adalah benteng terakhir yang tidak bisa dilewati oleh bug, seeder, maupun query manual.

Composite Index dan Aturan Kolom Pertama #

Composite index ['status', 'created_at'] mengurutkan data berdasar status terlebih dahulu, lalu created_at di dalam tiap kelompok status. Konsekuensinya, index ini membantu query yang menyaring status — misalnya WHERE status = 'todo' ORDER BY created_at — tetapi tidak membantu query yang hanya menyaring created_at saja. Prinsip ini disebut aturan leftmost prefix: composite index hanya terpakai bila kolom paling kirinya ikut disaring. Pemakaian paling umum adalah tabel pivot yang memakai composite primary key:

php
Schema::create('pivot_tags', function (Blueprint $table) {
    $table->unsignedBigInteger('post_id');
    $table->unsignedBigInteger('tag_id');
    $table->primary(['post_id', 'tag_id']);
});

Pengujian di SQLite menghasilkan primary key ("post_id", "tag_id") di pernyataan CREATE TABLE-nya, dan penyisipan pasangan post_id/tag_id yang sama dua kali ditolak dengan galat UNIQUE constraint failed: pivot_tags.post_id, pivot_tags.tag_id. Daftar lengkap opsi pembuatan index tersedia di dokumentasi migrations , termasuk fullText() untuk pencarian teks penuh di MySQL dan PostgreSQL.

Melihat Index Bekerja #

SQLite menyediakan EXPLAIN QUERY PLAN untuk membuktikan index benar-benar terpakai. Kita siapkan tabel catatan berisi 100 baris berjudul Catatan 1 sampai Catatan 100, lalu jalankan query yang sama dua kali — sebelum ada index, dan setelah index ditambahkan lewat Schema::table():

php
function plan(string $sql): string
{
    return DB::select('EXPLAIN QUERY PLAN '.$sql)[0]->detail;
}

echo plan("SELECT * FROM catatan WHERE judul = 'Catatan 50'"), "\n";

Schema::table('catatan', function (Blueprint $table) {
    $table->index('judul');
});

echo plan("SELECT * FROM catatan WHERE judul = 'Catatan 50'"), "\n";

Mari kita telaah kode di atas: fungsi plan() menerima satu pernyataan SQL, mengawalinya dengan EXPLAIN QUERY PLAN, lalu mengambil kolom detail dari rencana eksekusi pertama yang dikembalikan SQLite.

Output yang Dihasilkan:

text
SCAN catatan
SEARCH catatan USING COVERING INDEX catatan_judul_index (judul=?)

Kata SCAN berarti setiap baris diperiksa satu per satu; setelah index dibuat, rencana berubah menjadi SEARCH ... USING INDEX yang melompat langsung ke baris cocok. Selisihnya kecil pada 100 baris, tetapi prinsip yang sama berlaku pada jutaan baris.

Kapan Index Dibutuhkan #

Pedoman praktisnya: berikan index pada kolom yang sering muncul di WHERE, ORDER BY, JOIN, atau GROUP BY. Untuk kolom relasi seperti user_id, kebutuhannya hampir pasti karena hampir semua query menyaring berdasar pemilik datanya. Ada perbedaan antar-driver yang patut dicatat: MySQL (InnoDB) otomatis membuat index untuk setiap kolom foreign key, sedangkan SQLite tidak — output db:table tasks di atas membuktikannya, tidak ada satu pun index pada user_id. Jadi bila kita mengandalkan SQLite lalu memindahkan aplikasi ke MySQL, perilakunya bisa berbeda; membuat index secara eksplisit menjaga perilaku tetap sama di kedua driver.

Index menjaga kecepatan pencarian, sementara constraint unique menjaga keunikan nilai. Satu jenis constraint lagi menyambungkan dua tabel sekaligus menjaga konsistensinya — foreign key, topik chapter berikutnya.