Tutorial Indexing Laravel 12 & Filament: Membangun Website Desa Digital dengan Optimasi Database

Bagian 1: Pendahuluan - Apa Itu Table Indexing dan Mengapa Penting

Halo teman-teman developer! Saya Angga Risky Setiawan, founder dari BuildWithAngga. Sudah lebih dari beberapa tahun saya berkecimpung di dunia web development dan mengajar ribuan developer Indonesia melalui platform BuildWithAngga. Salah satu hal yang sering saya temui adalah banyak developer yang sudah jago bikin fitur, sudah paham Laravel, sudah bisa CRUD dengan lancar, tapi masih mengabaikan satu aspek krusial yaitu optimasi database.

Nah, kali ini saya ingin mengajak kita semua untuk belajar sesuatu yang mungkin terdengar teknis dan membosankan, tapi percaya deh, ini adalah skill yang akan membedakan developer biasa dengan developer yang benar-benar paham cara membangun aplikasi yang scalable. Kita akan membahas tentang Table Indexing menggunakan studi kasus yang sangat relevan yaitu Website Desa Digital.

Kenapa Website Desa Digital? Karena ini adalah proyek nyata yang saat ini sedang banyak dikembangkan di Indonesia. Bayangkan sebuah desa dengan populasi 5.000 hingga 10.000 warga, setiap warga punya data kependudukan, setiap hari ada puluhan pengajuan surat, ada ratusan pengumuman yang harus ditampilkan, dan semua data ini harus bisa diakses dengan cepat oleh petugas desa maupun warga. Tanpa optimasi yang tepat, website akan terasa lambat dan memberikan pengalaman yang buruk bagi pengguna.


Memahami Table Indexing dengan Analogi Sederhana

Sebelum kita masuk ke teknis, saya ingin menjelaskan konsep table indexing dengan analogi yang mudah dipahami. Bayangkan kita sedang berada di perpustakaan besar yang memiliki 100.000 buku. Kita ingin mencari buku dengan judul "Erta Panduan Laravel untuk Pemula". Tanpa sistem katalog atau daftar isi, apa yang harus kita lakukan? Ya, kita harus menelusuri rak demi rak, buku demi buku, sampai menemukan judul yang kita cari. Proses ini bisa memakan waktu berjam-jam bahkan berhari-hari.

Sekarang bayangkan perpustakaan yang sama tapi memiliki sistem katalog digital. Kita cukup ketik judul buku, dan sistem langsung memberitahu bahwa buku tersebut ada di rak nomor 47, baris ke-3, posisi ke-5. Dalam hitungan detik, kita sudah bisa menemukan buku yang dicari. Sistem katalog inilah yang disebut sebagai index dalam konteks database.

Dalam database MySQL, tanpa index, setiap kali kita melakukan query pencarian, MySQL harus melakukan yang namanya full table scan. Artinya, MySQL akan memeriksa setiap baris data satu per satu dari awal sampai akhir untuk menemukan data yang cocok dengan kriteria pencarian kita. Untuk tabel dengan 100 baris, mungkin tidak terasa bedanya. Tapi untuk tabel dengan 100.000 atau bahkan 1.000.000 baris? Perbedaannya sangat signifikan.

Dengan index, MySQL membuat struktur data khusus yang memungkinkan pencarian dilakukan dengan jauh lebih efisien. Index biasanya menggunakan struktur data B-Tree yang memungkinkan pencarian dengan kompleksitas O(log n) dibandingkan O(n) untuk full table scan. Dalam bahasa sederhana, pencarian yang tadinya memakan waktu 500 milidetik bisa turun menjadi hanya 5 milidetik. Itu peningkatan performa hingga 100 kali lipat!


Kapan Harus Menggunakan Index

Tidak semua kolom perlu di-index. Membuat index secara berlebihan justru bisa memberikan dampak negatif karena index juga membutuhkan ruang penyimpanan dan memperlambat operasi INSERT, UPDATE, dan DELETE. Jadi, kita perlu bijak dalam menentukan kolom mana yang perlu di-index.

Index sangat berguna ketika diterapkan pada kolom yang sering digunakan dalam WHERE clause. Misalnya, jika kita sering mencari warga berdasarkan NIK, maka kolom NIK adalah kandidat yang tepat untuk di-index. Query seperti SELECT * FROM villagers WHERE nik = '3201234567890123' akan sangat terbantu dengan adanya index pada kolom NIK.

Index juga sangat bermanfaat untuk kolom yang digunakan dalam JOIN operation. Ketika kita menggabungkan data dari tabel villagers dengan tabel documents berdasarkan villager_id, index pada kolom tersebut akan mempercepat proses penggabungan data secara signifikan.

Kolom yang sering digunakan dalam ORDER BY juga sebaiknya di-index. Bayangkan kita ingin menampilkan daftar pengumuman yang diurutkan berdasarkan tanggal publikasi terbaru. Tanpa index pada kolom published_at, MySQL harus mengurutkan semua data terlebih dahulu sebelum menampilkan hasilnya. Dengan index, data sudah tersimpan dalam urutan yang tepat sehingga proses sorting menjadi jauh lebih cepat.

Hal yang sama berlaku untuk kolom yang digunakan dalam GROUP BY. Ketika kita ingin menghitung jumlah dokumen berdasarkan status, index pada kolom status akan membantu MySQL mengelompokkan data dengan lebih efisien.


Kapan Tidak Perlu Menggunakan Index

Ada beberapa situasi di mana membuat index justru tidak diperlukan atau bahkan kontraproduktif. Untuk tabel berukuran kecil dengan jumlah baris di bawah 1.000, overhead dari penggunaan index seringkali lebih besar daripada manfaatnya. MySQL bisa melakukan full table scan dengan sangat cepat untuk tabel kecil.

Kolom yang jarang di-query juga tidak perlu di-index. Misalnya, kolom notes atau description yang biasanya hanya ditampilkan saat detail data dibuka, tapi tidak pernah digunakan untuk pencarian atau filtering. Membuat index pada kolom seperti ini hanya akan membuang ruang penyimpanan.

Kolom dengan variasi nilai yang sangat sedikit juga bukan kandidat yang baik untuk index. Contohnya, kolom gender yang hanya memiliki dua nilai yaitu male dan female. Jika distribusi datanya merata, index pada kolom ini tidak akan memberikan peningkatan performa yang signifikan karena MySQL tetap harus memproses sekitar 50% dari total data.

Kolom yang sering di-update juga perlu dipertimbangkan dengan hati-hati. Setiap kali nilai kolom yang di-index berubah, MySQL harus memperbarui struktur index-nya. Untuk kolom yang nilainya berubah-ubah setiap saat, overhead dari pembaruan index bisa menjadi beban tersendiri.


Studi Kasus: Website Desa Digital

Sekarang mari kita lihat proyek yang akan kita bangun bersama. Website Desa Digital adalah sistem informasi terpadu untuk mengelola berbagai aspek administrasi desa. Sistem ini akan melayani ribuan warga dan digunakan setiap hari oleh petugas desa untuk berbagai keperluan administratif.

Fitur Data Kependudukan menjadi fondasi utama dari sistem ini. Kita akan menyimpan data seluruh warga desa termasuk NIK, nama lengkap, tempat tanggal lahir, jenis kelamin, agama, pendidikan, pekerjaan, status perkawinan, dan informasi lainnya. Data ini juga akan terhubung dengan data Kartu Keluarga sehingga kita bisa melihat struktur keluarga dari setiap warga. Bayangkan dengan 5.000 warga dan 1.000 kepala keluarga, pencarian data harus bisa dilakukan dengan cepat agar petugas desa tidak perlu menunggu lama.

Fitur Layanan Surat-Menyurat akan menangani berbagai jenis pengajuan surat seperti Surat Keterangan Domisili, Surat Keterangan Tidak Mampu (SKTM), Surat Pengantar KTP, Surat Keterangan Usaha, dan berbagai jenis surat lainnya. Setiap pengajuan akan memiliki status yang bisa dilacak mulai dari pending, sedang diproses, selesai, atau ditolak. Dengan ratusan pengajuan setiap bulannya, sistem harus mampu memfilter dan mencari data dengan responsif.

Fitur Pengumuman dan Berita Desa akan menjadi sarana komunikasi antara pemerintah desa dengan warga. Pengumuman bisa dikategorikan berdasarkan jenisnya dan bisa dijadwalkan waktu publikasinya. Warga harus bisa melihat pengumuman terbaru dengan cepat tanpa harus menunggu loading yang lama.

Fitur Laporan dan Statistik akan menyajikan berbagai data agregat seperti jumlah warga berdasarkan jenis kelamin, kelompok umur, tingkat pendidikan, jenis pekerjaan, dan sebagainya. Query untuk laporan biasanya melibatkan operasi GROUP BY dan aggregate function yang akan sangat terbantu dengan index yang tepat.


Tech Stack yang Akan Digunakan

Untuk membangun Website Desa Digital ini, kita akan menggunakan kombinasi teknologi yang powerful dan modern. Laravel 12 akan menjadi framework utama yang menangani backend aplikasi. Laravel menyediakan fitur migration yang memudahkan kita dalam membuat dan mengelola struktur database termasuk pembuatan index.

Filament 3 akan kita gunakan untuk membangun admin panel dengan cepat dan elegan. Filament menyediakan komponen-komponen siap pakai untuk CRUD operation, pencarian, filtering, dan sorting yang akan langsung merasakan manfaat dari index yang kita buat.

MySQL menjadi pilihan database karena popularitasnya dan dukungannya yang sangat baik untuk berbagai jenis index. MySQL juga memiliki tools bawaan seperti EXPLAIN yang akan membantu kita menganalisis apakah query sudah menggunakan index dengan optimal.

Laravel Telescope akan menjadi senjata rahasia kita untuk monitoring dan debugging. Dengan Telescope, kita bisa melihat setiap query yang dijalankan beserta waktu eksekusinya. Ini akan sangat membantu saat kita melakukan benchmark untuk membandingkan performa sebelum dan sesudah penerapan index.

Apa yang Akan Kita Pelajari

Sepanjang tutorial ini, kita akan mempelajari banyak hal yang langsung bisa diterapkan di proyek nyata. Kita akan mulai dari setup project Laravel dan konfigurasi database, kemudian merancang struktur tabel dengan relationship yang tepat.

Kita akan membahas secara mendalam berbagai jenis index mulai dari Primary Index, Unique Index, Regular Index, hingga Composite Index. Setiap jenis index memiliki karakteristik dan use case yang berbeda, dan kita akan belajar kapan harus menggunakan yang mana.

Kita juga akan belajar cara generate data dummy dalam jumlah besar menggunakan Factory dan Seeder. Ini penting karena dampak dari index baru terasa signifikan ketika kita bekerja dengan data dalam jumlah ribuan atau jutaan baris.

Yang paling menarik, kita akan melakukan benchmark nyata untuk membuktikan peningkatan performa. Kita akan mencatat waktu eksekusi query sebelum dan sesudah penerapan index, dan saya yakin hasilnya akan membuat kalian kagum dengan betapa besar perbedaannya.

Bagian 2: Setup Project Laravel dan Konfigurasi Database

Sekarang saatnya kita mulai coding! Di bagian ini, kita akan menyiapkan fondasi proyek Website Desa Digital dengan menginstall Laravel terbaru dan mengkonfigurasi koneksi ke database MySQL. Pastikan teman-teman sudah memiliki PHP versi 8.2 atau lebih baru, Composer, dan MySQL yang sudah terinstall di komputer masing-masing.

Mari kita buka terminal dan masuk ke direktori di mana kita ingin menyimpan proyek ini. Saya biasanya menyimpan semua proyek di folder ~/Projects agar rapi dan mudah ditemukan. Jalankan perintah berikut untuk membuat proyek Laravel baru dengan nama desa-digital.

composer create-project laravel/laravel desa-digital

Proses ini akan memakan waktu beberapa menit tergantung kecepatan internet karena Composer akan mengunduh Laravel beserta semua dependensinya. Setelah selesai, masuk ke direktori proyek yang baru dibuat.

cd desa-digital

Sebelum kita mengkonfigurasi Laravel, kita perlu membuat database terlebih dahulu di MySQL. Buka terminal baru atau tab baru, kemudian masuk ke MySQL command line dengan perintah berikut.

mysql -u root -p

Masukkan password MySQL kalian, kemudian buat database baru dengan nama desa_digital. Saya menggunakan underscore sebagai pemisah kata karena ini adalah konvensi yang umum digunakan untuk penamaan database.

CREATE DATABASE desa_digital CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

Kita menggunakan character set utf8mb4 dan collation utf8mb4_unicode_ci agar database bisa menyimpan karakter Unicode dengan lengkap termasuk emoji dan karakter khusus bahasa Indonesia. Setelah database berhasil dibuat, keluar dari MySQL command line dengan mengetik exit.

Sekarang kita kembali ke proyek Laravel untuk mengkonfigurasi koneksi database. Buka file .env yang ada di root direktori proyek menggunakan text editor favorit kalian. File ini berisi konfigurasi environment yang bersifat sensitif dan tidak boleh di-commit ke repository publik.

Cari bagian konfigurasi database dan ubah sesuai dengan pengaturan MySQL di komputer kalian.

APP_NAME="Desa Digital"
APP_ENV=local
APP_KEY=base64:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
APP_DEBUG=true
APP_TIMEZONE=Asia/Jakarta
APP_URL=http://localhost:8000

APP_LOCALE=id
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=id_ID

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=desa_digital
DB_USERNAME=root
DB_PASSWORD=password_mysql_kalian

Ada beberapa konfigurasi penting yang saya tambahkan di sini. APP_TIMEZONE saya set ke Asia/Jakarta agar semua timestamp yang disimpan di database menggunakan zona waktu Indonesia Barat. Ini penting untuk aplikasi yang melayani pengguna di Indonesia agar tidak terjadi kebingungan terkait waktu.

APP_LOCALE saya set ke id agar pesan-pesan default Laravel ditampilkan dalam bahasa Indonesia. APP_FAKER_LOCALE saya set ke id_ID agar nanti ketika kita generate data dummy menggunakan Faker, data yang dihasilkan akan relevan dengan konteks Indonesia seperti nama orang Indonesia, alamat di Indonesia, dan sebagainya.

Selanjutnya kita perlu memastikan konfigurasi timezone juga diterapkan di level aplikasi. Buka file config/app.php dan pastikan pengaturan timezone sudah sesuai.

<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Application Timezone
    |--------------------------------------------------------------------------
    */

    'timezone' => env('APP_TIMEZONE', 'Asia/Jakarta'),

    /*
    |--------------------------------------------------------------------------
    | Application Locale Configuration
    |--------------------------------------------------------------------------
    */

    'locale' => env('APP_LOCALE', 'id'),

    'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),

    'faker_locale' => env('APP_FAKER_LOCALE', 'id_ID'),

    // ... konfigurasi lainnya

];

Untuk memastikan koneksi database sudah benar, jalankan perintah migrasi bawaan Laravel. Laravel sudah menyertakan beberapa file migration default untuk tabel users, password reset tokens, sessions, cache, dan jobs.

php artisan migrate

Jika koneksi berhasil, kalian akan melihat output yang menunjukkan migration berhasil dijalankan. Kalau ada error, biasanya penyebabnya adalah kesalahan pada username, password, atau nama database di file .env. Pastikan MySQL service sudah berjalan dan kredensial yang dimasukkan sudah benar.

Sekarang mari kita verifikasi bahwa tabel-tabel sudah terbuat di database. Masuk kembali ke MySQL command line dan jalankan perintah berikut.

mysql -u root -p

USE desa_digital;
SHOW TABLES;

Kalian akan melihat daftar tabel yang sudah dibuat oleh Laravel termasuk users, password_reset_tokens, sessions, cache, cache_locks, jobs, job_batches, failed_jobs, dan migrations. Tabel migrations adalah tabel khusus yang digunakan Laravel untuk mencatat migration mana saja yang sudah dijalankan.

Sebelum kita lanjut ke bagian berikutnya, ada baiknya kita test menjalankan development server untuk memastikan Laravel sudah terinstall dengan benar. Jalankan perintah berikut di terminal.

php artisan serve

Buka browser dan akses http://localhost:8000. Kalian akan melihat halaman welcome Laravel yang menandakan instalasi berhasil. Tekan Ctrl+C di terminal untuk menghentikan development server.

Satu hal lagi yang saya rekomendasikan adalah menginstall Laravel IDE Helper untuk membantu autocomplete di code editor. Package ini sangat berguna terutama saat kita bekerja dengan Model dan Facade.

composer require --dev barryvdh/laravel-ide-helper

Setelah terinstall, generate file helper dengan perintah berikut.

php artisan ide-helper:generate
php artisan ide-helper:models --nowrite

Dengan setup ini, proyek Laravel kita sudah siap untuk tahap selanjutnya yaitu membuat struktur database dengan migration dan model untuk Website Desa Digital.

Bagian 3: Membuat Migration dan Model dengan Relationship

Di bagian ini kita akan merancang struktur database untuk Website Desa Digital. Kita akan membuat lima tabel utama yaitu families untuk data keluarga, villagers untuk data warga, document_types untuk jenis surat, documents untuk surat yang diajukan, dan announcements untuk pengumuman desa. Setiap tabel akan memiliki model dengan relationship yang saling terhubung.

Mari kita mulai dengan membuat migration dan model untuk tabel families. Tabel ini akan menyimpan data Kartu Keluarga yang menjadi pengelompokan utama data warga. Jalankan perintah artisan berikut untuk membuat migration sekaligus model.

php artisan make:model Family -m

Flag -m akan otomatis membuatkan file migration bersamaan dengan model. Buka file migration yang baru dibuat di folder database/migrations dengan nama yang diawali timestamp dan diakhiri create_families_table.php.

<?php

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('families', function (Blueprint $table) {
            $table->id();
            $table->string('family_card_number', 16)->unique(); // Nomor KK 16 digit
            $table->string('head_of_family'); // Nama kepala keluarga
            $table->text('address'); // Alamat lengkap
            $table->string('rt', 3); // RT
            $table->string('rw', 3); // RW
            $table->string('postal_code', 5)->nullable(); // Kode pos
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('families');
    }
};

Perhatikan bahwa saya menggunakan unique() pada kolom family_card_number karena setiap nomor KK pasti unik dan tidak boleh ada duplikat. Ini juga secara otomatis akan membuat unique index pada kolom tersebut. Sekarang buka file model app/Models/Family.php dan tambahkan properti fillable serta relationship.

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;

class Family extends Model
{
    use HasFactory;

    protected $fillable = [
        'family_card_number',
        'head_of_family',
        'address',
        'rt',
        'rw',
        'postal_code',
    ];

    /**
     * Relasi ke anggota keluarga (warga)
     */
    public function villagers(): HasMany
    {
        return $this->hasMany(Villager::class);
    }

    /**
     * Mendapatkan alamat lengkap dengan RT/RW
     */
    public function getFullAddressAttribute(): string
    {
        return "{$this->address}, RT {$this->rt}/RW {$this->rw}";
    }
}

Selanjutnya kita buat migration dan model untuk tabel villagers yang menyimpan data warga. Tabel ini memiliki foreign key ke tabel families.

php artisan make:model Villager -m

Buka file migration untuk tabel villagers dan isi dengan struktur berikut.

<?php

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('villagers', function (Blueprint $table) {
            $table->id();
            $table->foreignId('family_id')->constrained()->cascadeOnDelete();
            $table->string('nik', 16)->unique(); // NIK 16 digit
            $table->string('name');
            $table->string('birth_place');
            $table->date('birth_date');
            $table->enum('gender', ['male', 'female']);
            $table->string('religion');
            $table->string('education');
            $table->string('occupation');
            $table->string('marital_status');
            $table->enum('blood_type', ['A', 'B', 'AB', 'O'])->nullable();
            $table->boolean('is_head_of_family')->default(false);
            $table->string('phone', 15)->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('villagers');
    }
};

Pada migration ini, saya menggunakan foreignId('family_id')->constrained() yang akan membuat foreign key constraint ke tabel families sekaligus membuat index pada kolom tersebut. Method cascadeOnDelete() memastikan ketika sebuah keluarga dihapus, semua data warga yang terkait juga akan terhapus. Kolom NIK juga dibuat unique karena setiap warga memiliki NIK yang berbeda.

Sekarang buka model app/Models/Villager.php dan lengkapi dengan fillable, casts, dan relationship.

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;

class Villager extends Model
{
    use HasFactory;

    protected $fillable = [
        'family_id',
        'nik',
        'name',
        'birth_place',
        'birth_date',
        'gender',
        'religion',
        'education',
        'occupation',
        'marital_status',
        'blood_type',
        'is_head_of_family',
        'phone',
    ];

    protected $casts = [
        'birth_date' => 'date',
        'is_head_of_family' => 'boolean',
    ];

    /**
     * Relasi ke keluarga
     */
    public function family(): BelongsTo
    {
        return $this->belongsTo(Family::class);
    }

    /**
     * Relasi ke dokumen yang diajukan
     */
    public function documents(): HasMany
    {
        return $this->hasMany(Document::class);
    }

    /**
     * Mendapatkan umur warga
     */
    public function getAgeAttribute(): int
    {
        return $this->birth_date->age;
    }

    /**
     * Mendapatkan tempat tanggal lahir
     */
    public function getBirthInfoAttribute(): string
    {
        return "{$this->birth_place}, {$this->birth_date->format('d F Y')}";
    }
}

Lanjut ke tabel document_types untuk menyimpan jenis-jenis surat yang tersedia di desa.

php artisan make:model DocumentType -m

Isi file migration dengan struktur berikut.

<?php

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('document_types', function (Blueprint $table) {
            $table->id();
            $table->string('name'); // Nama jenis surat
            $table->string('code', 20)->unique(); // Kode surat (SKD, SKTM, dll)
            $table->text('description')->nullable(); // Deskripsi surat
            $table->json('requirements')->nullable(); // Persyaratan dalam format JSON
            $table->boolean('is_active')->default(true); // Status aktif
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('document_types');
    }
};

Kolom requirements menggunakan tipe JSON untuk menyimpan daftar persyaratan yang fleksibel. Ini memungkinkan setiap jenis surat memiliki persyaratan yang berbeda-beda tanpa perlu membuat tabel tambahan. Buka model app/Models/DocumentType.php dan lengkapi.

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;

class DocumentType extends Model
{
    use HasFactory;

    protected $fillable = [
        'name',
        'code',
        'description',
        'requirements',
        'is_active',
    ];

    protected $casts = [
        'requirements' => 'array',
        'is_active' => 'boolean',
    ];

    /**
     * Relasi ke dokumen
     */
    public function documents(): HasMany
    {
        return $this->hasMany(Document::class);
    }

    /**
     * Scope untuk jenis dokumen yang aktif
     */
    public function scopeActive($query)
    {
        return $query->where('is_active', true);
    }
}

Sekarang kita buat tabel documents untuk menyimpan surat-surat yang diajukan oleh warga.

php artisan make:model Document -m

Isi file migration dengan struktur berikut.

<?php

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('documents', function (Blueprint $table) {
            $table->id();
            $table->foreignId('villager_id')->constrained()->cascadeOnDelete();
            $table->foreignId('document_type_id')->constrained()->cascadeOnDelete();
            $table->string('document_number')->nullable(); // Nomor surat setelah selesai
            $table->text('purpose'); // Tujuan/keperluan pengajuan
            $table->enum('status', ['pending', 'processing', 'completed', 'rejected'])->default('pending');
            $table->text('notes')->nullable(); // Catatan dari petugas
            $table->timestamp('completed_at')->nullable(); // Waktu selesai
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('documents');
    }
};

Tabel ini memiliki dua foreign key yaitu ke tabel villagers dan document_types. Kolom status menggunakan enum untuk membatasi nilai yang valid. Buka model app/Models/Document.php dan lengkapi.

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;

class Document extends Model
{
    use HasFactory;

    protected $fillable = [
        'villager_id',
        'document_type_id',
        'document_number',
        'purpose',
        'status',
        'notes',
        'completed_at',
    ];

    protected $casts = [
        'completed_at' => 'datetime',
    ];

    /**
     * Relasi ke warga yang mengajukan
     */
    public function villager(): BelongsTo
    {
        return $this->belongsTo(Villager::class);
    }

    /**
     * Relasi ke jenis dokumen
     */
    public function documentType(): BelongsTo
    {
        return $this->belongsTo(DocumentType::class);
    }

    /**
     * Scope untuk filter berdasarkan status
     */
    public function scopeStatus($query, string $status)
    {
        return $query->where('status', $status);
    }

    /**
     * Scope untuk dokumen pending
     */
    public function scopePending($query)
    {
        return $query->where('status', 'pending');
    }

    /**
     * Scope untuk dokumen yang sudah selesai
     */
    public function scopeCompleted($query)
    {
        return $query->where('status', 'completed');
    }

    /**
     * Cek apakah dokumen masih bisa diedit
     */
    public function isEditable(): bool
    {
        return in_array($this->status, ['pending', 'processing']);
    }
}

Terakhir, kita buat tabel announcements untuk pengumuman desa.

php artisan make:model Announcement -m

Isi file migration dengan struktur berikut.

<?php

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('announcements', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->string('slug')->unique();
            $table->longText('content');
            $table->string('category')->nullable(); // Kategori pengumuman
            $table->boolean('is_published')->default(false);
            $table->timestamp('published_at')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('announcements');
    }
};

Kolom slug dibuat unique agar setiap pengumuman memiliki URL yang unik. Kolom is_published dan published_at digunakan untuk mengatur kapan pengumuman ditampilkan ke publik. Buka model app/Models/Announcement.php dan lengkapi.

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Support\\Str;

class Announcement extends Model
{
    use HasFactory;

    protected $fillable = [
        'title',
        'slug',
        'content',
        'category',
        'is_published',
        'published_at',
    ];

    protected $casts = [
        'is_published' => 'boolean',
        'published_at' => 'datetime',
    ];

    /**
     * Auto generate slug dari title
     */
    protected static function boot()
    {
        parent::boot();

        static::creating(function ($announcement) {
            if (empty($announcement->slug)) {
                $announcement->slug = Str::slug($announcement->title);
            }
        });
    }

    /**
     * Scope untuk pengumuman yang sudah dipublikasikan
     */
    public function scopePublished($query)
    {
        return $query->where('is_published', true)
                     ->whereNotNull('published_at')
                     ->where('published_at', '<=', now());
    }

    /**
     * Scope untuk filter berdasarkan kategori
     */
    public function scopeCategory($query, string $category)
    {
        return $query->where('category', $category);
    }

    /**
     * Mendapatkan excerpt dari content
     */
    public function getExcerptAttribute(): string
    {
        return Str::limit(strip_tags($this->content), 150);
    }
}

Setelah semua migration dan model selesai dibuat, jalankan migration untuk membuat tabel-tabel di database.

php artisan migrate

Kalian akan melihat output yang menunjukkan semua tabel berhasil dibuat. Untuk memverifikasi struktur yang sudah dibuat, kalian bisa masuk ke MySQL dan melihat daftar tabel.

USE desa_digital;
SHOW TABLES;

Kalian akan melihat tabel-tabel baru yaitu families, villagers, document_types, documents, dan announcements sudah terbuat bersama dengan tabel-tabel bawaan Laravel. Untuk melihat struktur kolom dari sebuah tabel, gunakan perintah DESCRIBE.

DESCRIBE villagers;

Dengan ini kita sudah memiliki struktur database yang solid untuk Website Desa Digital. Di bagian selanjutnya, kita akan menambahkan index pada kolom-kolom yang strategis untuk mengoptimasi performa query.

Bagian 4: Memahami dan Menerapkan Table Indexing

Sekarang kita masuk ke inti dari tutorial ini yaitu implementasi table indexing. Di bagian sebelumnya kita sudah membuat struktur database, tapi belum secara eksplisit menambahkan index untuk optimasi query. Memang benar beberapa index sudah otomatis terbuat seperti primary key dan unique constraint, namun masih banyak kolom lain yang perlu di-index berdasarkan pola query yang akan sering digunakan.

Sebelum kita mulai coding, saya ingin menjelaskan lebih dalam tentang berbagai jenis index yang tersedia di MySQL dan bagaimana Laravel menyediakan method untuk membuatnya.

Primary Index adalah index yang otomatis dibuat pada kolom primary key. Di Laravel, ketika kita menggunakan $table->id(), secara otomatis kolom id akan menjadi primary key dengan auto increment dan memiliki primary index. Index ini memastikan setiap baris data memiliki identifier unik dan pencarian berdasarkan id akan sangat cepat. Kita tidak perlu melakukan apapun untuk membuat primary index karena Laravel sudah mengurusnya.

Unique Index adalah index yang memastikan nilai pada kolom tersebut tidak boleh duplikat. Selain menjaga integritas data, unique index juga mempercepat pencarian karena MySQL tahu bahwa hanya ada satu baris yang cocok dengan nilai tertentu. Di Laravel, kita bisa membuat unique index dengan method unique() pada kolom atau $table->unique('nama_kolom') secara terpisah. Contoh penggunaan yang sudah kita terapkan adalah pada kolom NIK dan nomor KK yang memang harus unik untuk setiap warga dan keluarga.

Regular Index atau sering disebut juga Secondary Index adalah index biasa yang mempercepat pencarian tanpa constraint keunikan. Index ini cocok untuk kolom yang sering digunakan dalam WHERE clause tapi nilainya bisa duplikat. Contohnya kolom name yang mungkin ada beberapa warga dengan nama sama, atau kolom status yang nilainya terbatas tapi sering difilter. Di Laravel, kita menggunakan method index() untuk membuat regular index.

Composite Index atau Compound Index adalah index yang mencakup lebih dari satu kolom. Index ini sangat berguna ketika query sering menggunakan multiple kolom dalam WHERE clause secara bersamaan. Urutan kolom dalam composite index sangat penting karena MySQL menggunakan index dari kiri ke kanan. Misalnya composite index pada kolom (gender, marital_status) akan efektif untuk query WHERE gender = 'male' AND marital_status = 'married' atau WHERE gender = 'male' saja, tapi tidak efektif untuk query WHERE marital_status = 'married' saja tanpa filter gender.

Foreign Key Index secara otomatis dibuat ketika kita menggunakan foreignId()->constrained() di Laravel. Ini karena foreign key membutuhkan index agar proses validasi referential integrity bisa dilakukan dengan cepat. Jadi ketika kita membuat foreign key ke tabel lain, kita tidak perlu membuat index tambahan pada kolom tersebut.

Sekarang mari kita buat migration baru khusus untuk menambahkan index pada tabel-tabel yang sudah ada. Saya sengaja memisahkan migration index dari migration pembuatan tabel agar lebih mudah dipahami dan juga sebagai best practice ketika kita ingin menambahkan index ke database yang sudah berjalan di production.

php artisan make:migration add_indexes_to_tables

Buka file migration yang baru dibuat dan kita akan menambahkan berbagai index secara sistematis. Saya akan menjelaskan setiap index yang dibuat beserta alasan mengapa index tersebut diperlukan.

<?php

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{
    public function up(): void
    {
        // Index untuk tabel villagers
        Schema::table('villagers', function (Blueprint $table) {
            // Index pada kolom name untuk fitur pencarian warga
            // Query: SELECT * FROM villagers WHERE name LIKE '%Ahmad%'
            // Tanpa index, MySQL harus scan seluruh tabel untuk mencari nama
            $table->index('name', 'idx_villagers_name');

            // Index pada kolom birth_date untuk filter dan sorting berdasarkan tanggal lahir
            // Query: SELECT * FROM villagers WHERE birth_date BETWEEN '1990-01-01' AND '2000-12-31'
            // Query: SELECT * FROM villagers ORDER BY birth_date DESC
            $table->index('birth_date', 'idx_villagers_birth_date');

            // Composite index pada gender dan marital_status
            // Query: SELECT * FROM villagers WHERE gender = 'male' AND marital_status = 'married'
            // Composite index efektif karena kedua kolom ini sering difilter bersamaan
            // untuk keperluan statistik dan laporan demografi
            $table->index(['gender', 'marital_status'], 'idx_villagers_gender_marital');

            // Index pada occupation untuk statistik pekerjaan warga
            // Query: SELECT occupation, COUNT(*) FROM villagers GROUP BY occupation
            $table->index('occupation', 'idx_villagers_occupation');
        });

        // Index untuk tabel documents
        Schema::table('documents', function (Blueprint $table) {
            // Index pada kolom status sangat penting karena query filter status
            // akan sangat sering digunakan oleh petugas desa
            // Query: SELECT * FROM documents WHERE status = 'pending'
            // Query: SELECT COUNT(*) FROM documents WHERE status = 'processing'
            $table->index('status', 'idx_documents_status');

            // Composite index pada villager_id dan document_type_id
            // Query: SELECT * FROM documents WHERE villager_id = 1 AND document_type_id = 2
            // Berguna untuk melihat riwayat pengajuan surat tertentu dari seorang warga
            $table->index(['villager_id', 'document_type_id'], 'idx_documents_villager_doctype');

            // Index pada created_at untuk sorting dan filter berdasarkan waktu pengajuan
            // Query: SELECT * FROM documents WHERE created_at >= '2024-01-01' ORDER BY created_at DESC
            $table->index('created_at', 'idx_documents_created_at');

            // Composite index pada status dan created_at untuk query yang sangat umum
            // Query: SELECT * FROM documents WHERE status = 'pending' ORDER BY created_at ASC
            // Ini membantu menampilkan antrian dokumen yang perlu diproses
            $table->index(['status', 'created_at'], 'idx_documents_status_created');
        });

        // Index untuk tabel announcements
        Schema::table('announcements', function (Blueprint $table) {
            // Composite index pada is_published dan published_at
            // Query: SELECT * FROM announcements WHERE is_published = 1 AND published_at <= NOW()
            // Ini adalah query paling umum untuk menampilkan pengumuman yang sudah dipublikasikan
            $table->index(['is_published', 'published_at'], 'idx_announcements_published');

            // Index pada category untuk filter pengumuman berdasarkan kategori
            // Query: SELECT * FROM announcements WHERE category = 'kesehatan'
            $table->index('category', 'idx_announcements_category');
        });

        // Index untuk tabel families
        Schema::table('families', function (Blueprint $table) {
            // Index pada head_of_family untuk pencarian berdasarkan nama kepala keluarga
            // Query: SELECT * FROM families WHERE head_of_family LIKE '%Budi%'
            $table->index('head_of_family', 'idx_families_head');

            // Composite index pada rt dan rw untuk filter berdasarkan wilayah
            // Query: SELECT * FROM families WHERE rt = '001' AND rw = '005'
            // Berguna untuk menampilkan data keluarga per wilayah RT/RW
            $table->index(['rt', 'rw'], 'idx_families_rt_rw');
        });
    }

    public function down(): void
    {
        // Hapus index dari tabel villagers
        Schema::table('villagers', function (Blueprint $table) {
            $table->dropIndex('idx_villagers_name');
            $table->dropIndex('idx_villagers_birth_date');
            $table->dropIndex('idx_villagers_gender_marital');
            $table->dropIndex('idx_villagers_occupation');
        });

        // Hapus index dari tabel documents
        Schema::table('documents', function (Blueprint $table) {
            $table->dropIndex('idx_documents_status');
            $table->dropIndex('idx_documents_villager_doctype');
            $table->dropIndex('idx_documents_created_at');
            $table->dropIndex('idx_documents_status_created');
        });

        // Hapus index dari tabel announcements
        Schema::table('announcements', function (Blueprint $table) {
            $table->dropIndex('idx_announcements_published');
            $table->dropIndex('idx_announcements_category');
        });

        // Hapus index dari tabel families
        Schema::table('families', function (Blueprint $table) {
            $table->dropIndex('idx_families_head');
            $table->dropIndex('idx_families_rt_rw');
        });
    }
};

Mari saya jelaskan beberapa keputusan desain yang penting dalam migration di atas.

Pertama, saya memberikan nama eksplisit pada setiap index menggunakan parameter kedua pada method index(). Penamaan menggunakan format idx_namatabel_namakolom memudahkan kita untuk mengidentifikasi index saat melakukan debugging atau maintenance. Jika kita tidak memberikan nama, Laravel akan generate nama otomatis yang kadang terlalu panjang dan sulit dibaca.

Kedua, pada method down() saya menghapus semua index yang dibuat di method up(). Ini penting agar migration bisa di-rollback dengan benar. Method dropIndex() menerima nama index sebagai parameter, itulah mengapa penamaan index secara eksplisit sangat berguna.

Ketiga, perhatikan bahwa saya membuat beberapa composite index seperti idx_villagers_gender_marital dan idx_documents_status_created. Composite index ini didesain berdasarkan pola query yang akan sering digunakan. Misalnya untuk laporan demografi, petugas desa pasti sering memfilter warga berdasarkan jenis kelamin dan status perkawinan secara bersamaan. Dengan composite index, query seperti ini akan jauh lebih cepat dibanding menggunakan dua index terpisah.

Keempat, pada tabel documents saya membuat dua index yang melibatkan kolom status yaitu idx_documents_status dan idx_documents_status_created. Index pertama untuk query yang hanya filter berdasarkan status, sedangkan index kedua untuk query yang filter status sekaligus sorting berdasarkan created_at. MySQL akan memilih index yang paling efisien berdasarkan query yang dijalankan.

Sekarang jalankan migration untuk membuat semua index.

php artisan migrate

Setelah migration berhasil, kita bisa memverifikasi index yang sudah dibuat menggunakan query SQL langsung ke MySQL. Masuk ke MySQL command line dan jalankan perintah SHOW INDEX untuk melihat daftar index pada sebuah tabel.

USE desa_digital;
SHOW INDEX FROM villagers;

Output dari perintah ini akan menampilkan informasi detail tentang setiap index yang ada pada tabel villagers. Kolom-kolom penting yang perlu diperhatikan adalah Key_name yang menunjukkan nama index, Column_name yang menunjukkan kolom yang di-index, Non_unique yang bernilai 0 untuk unique index dan 1 untuk regular index, serta Seq_in_index yang menunjukkan urutan kolom dalam composite index.

SHOW INDEX FROM villagers;

Hasilnya akan seperti berikut.

+------------+------------+-----------------------------+--------------+----------------+
| Table      | Non_unique | Key_name                    | Seq_in_index | Column_name    |
+------------+------------+-----------------------------+--------------+----------------+
| villagers  |          0 | PRIMARY                     |            1 | id             |
| villagers  |          0 | villagers_nik_unique        |            1 | nik            |
| villagers  |          1 | villagers_family_id_foreign |            1 | family_id      |
| villagers  |          1 | idx_villagers_name          |            1 | name           |
| villagers  |          1 | idx_villagers_birth_date    |            1 | birth_date     |
| villagers  |          1 | idx_villagers_gender_marital|            1 | gender         |
| villagers  |          1 | idx_villagers_gender_marital|            2 | marital_status |
| villagers  |          1 | idx_villagers_occupation    |            1 | occupation     |
+------------+------------+-----------------------------+--------------+----------------+

Perhatikan bahwa composite index idx_villagers_gender_marital muncul dua kali dengan Seq_in_index yang berbeda. Ini menunjukkan bahwa index tersebut mencakup dua kolom dengan gender di posisi pertama dan marital_status di posisi kedua. Urutan ini penting dan harus sesuai dengan pola query yang akan digunakan.

Mari kita juga lihat index pada tabel documents yang lebih kompleks.

SHOW INDEX FROM documents;

+------------+------------+------------------------------+--------------+-----------------+
| Table      | Non_unique | Key_name                     | Seq_in_index | Column_name     |
+------------+------------+------------------------------+--------------+-----------------+
| documents  |          0 | PRIMARY                      |            1 | id              |
| documents  |          1 | documents_villager_id_foreign|            1 | villager_id     |
| documents  |          1 | documents_document_type_id...|            1 | document_type_id|
| documents  |          1 | idx_documents_status         |            1 | status          |
| documents  |          1 | idx_documents_villager_doctype|           1 | villager_id     |
| documents  |          1 | idx_documents_villager_doctype|           2 | document_type_id|
| documents  |          1 | idx_documents_created_at     |            1 | created_at      |
| documents  |          1 | idx_documents_status_created |            1 | status          |
| documents  |          1 | idx_documents_status_created |            2 | created_at      |
+------------+------------+------------------------------+--------------+-----------------+

Kalian mungkin bertanya, bukankah ada redundansi antara documents_villager_id_foreign dan idx_documents_villager_doctype yang sama-sama mencakup kolom villager_id? Jawabannya adalah composite index idx_documents_villager_doctype tetap berguna karena bisa melayani query yang filter berdasarkan kedua kolom sekaligus dengan lebih efisien. MySQL cukup pintar untuk menggunakan index yang paling optimal berdasarkan kondisi query.

Selain SHOW INDEX, kita juga bisa menggunakan query EXPLAIN untuk melihat apakah sebuah query menggunakan index atau tidak. EXPLAIN adalah tools yang sangat powerful untuk menganalisis query plan yang akan digunakan MySQL.

EXPLAIN SELECT * FROM villagers WHERE nik = '3201234567890123';

Output EXPLAIN memiliki beberapa kolom penting. Kolom type menunjukkan tipe akses yang digunakan, dengan urutan dari yang terbaik ke terburuk adalah system, const, eq_ref, ref, range, index, dan ALL. Jika nilainya ALL, berarti MySQL melakukan full table scan yang sangat lambat. Kolom possible_keys menunjukkan index yang mungkin bisa digunakan, sedangkan kolom key menunjukkan index yang benar-benar dipilih oleh MySQL. Kolom rows menunjukkan estimasi jumlah baris yang perlu diperiksa.

Untuk query pencarian berdasarkan NIK yang unique, hasil EXPLAIN seharusnya menunjukkan type = const dan key = villagers_nik_unique. Ini adalah hasil yang optimal karena MySQL langsung tahu bahwa hanya ada satu baris yang cocok.

Mari kita bandingkan dengan query yang menggunakan composite index.

EXPLAIN SELECT * FROM villagers WHERE gender = 'male' AND marital_status = 'married';

Hasil EXPLAIN untuk query ini seharusnya menunjukkan key = idx_villagers_gender_marital dengan type = ref. Ini menandakan bahwa MySQL menggunakan composite index yang kita buat untuk menemukan baris-baris yang cocok dengan kedua kondisi tersebut.

Sekarang coba query yang hanya filter berdasarkan marital_status saja tanpa gender.

EXPLAIN SELECT * FROM villagers WHERE marital_status = 'married';

Untuk query ini, MySQL kemungkinan tidak akan menggunakan composite index idx_villagers_gender_marital karena kolom marital_status berada di posisi kedua dalam index. Ingat bahwa composite index hanya efektif jika query mencakup kolom dari posisi paling kiri. Ini adalah alasan mengapa desain composite index harus mempertimbangkan pola query yang akan digunakan.

Kita juga bisa menjalankan EXPLAIN dari Laravel menggunakan method DB facade.

<?php

use Illuminate\\Support\\Facades\\DB;

// Di dalam controller atau artisan command
$explanation = DB::select('EXPLAIN SELECT * FROM villagers WHERE nik = ?', ['3201234567890123']);

foreach ($explanation as $row) {
    echo "Type: {$row->type}\\n";
    echo "Possible Keys: {$row->possible_keys}\\n";
    echo "Key: {$row->key}\\n";
    echo "Rows: {$row->rows}\\n";
}

Code di atas menjalankan EXPLAIN query dan menampilkan informasi penting dari hasilnya. Kolom type dengan nilai const atau ref menandakan penggunaan index yang efisien. Kolom key menunjukkan nama index yang dipilih MySQL. Kolom rows menunjukkan estimasi jumlah baris yang perlu diperiksa, semakin kecil semakin baik.

Dengan pemahaman mendalam tentang berbagai jenis index dan cara kerjanya, kita sekarang siap untuk membuat data testing dalam jumlah besar dan membuktikan dampak nyata dari index yang sudah kita buat. Di bagian selanjutnya, kita akan membuat Factory dan Seeder untuk generate ribuan data dummy.

Bagian 5: Membuat Seeder dengan Ribuan Data untuk Testing

Di bagian ini kita akan membuat data dummy dalam jumlah besar untuk menguji efektivitas index yang sudah kita buat. Testing dengan data yang sedikit tidak akan menunjukkan perbedaan performa yang signifikan. Kita perlu ribuan bahkan puluhan ribu baris data untuk benar-benar merasakan dampak dari indexing. Target kita adalah membuat 1.000 keluarga, 5.000 warga, 10.000 dokumen, dan 100 pengumuman.

Laravel menyediakan fitur Factory yang memudahkan kita membuat data dummy dengan struktur yang realistis. Factory menggunakan library Faker yang bisa generate berbagai jenis data palsu seperti nama, alamat, tanggal, dan sebagainya. Karena kita sudah mengatur APP_FAKER_LOCALE ke id_ID di file .env, Faker akan menghasilkan data yang relevan dengan konteks Indonesia.

Mari kita mulai dengan membuat Factory untuk model Family. Jalankan perintah artisan berikut.

php artisan make:factory FamilyFactory

Buka file database/factories/FamilyFactory.php yang baru dibuat dan isi dengan kode berikut.

<?php

namespace Database\\Factories;

use App\\Models\\Family;
use Illuminate\\Database\\Eloquent\\Factories\\Factory;

class FamilyFactory extends Factory
{
    protected $model = Family::class;

    /**
     * Daftar kota di Indonesia untuk variasi data
     */
    private array $cities = [
        'Jakarta', 'Surabaya', 'Bandung', 'Medan', 'Semarang',
        'Makassar', 'Palembang', 'Tangerang', 'Depok', 'Bekasi',
        'Bogor', 'Malang', 'Yogyakarta', 'Solo', 'Denpasar',
        'Banjarmasin', 'Pontianak', 'Samarinda', 'Padang', 'Pekanbaru'
    ];

    /**
     * Daftar nama jalan umum di Indonesia
     */
    private array $streets = [
        'Jl. Merdeka', 'Jl. Sudirman', 'Jl. Gatot Subroto', 'Jl. Ahmad Yani',
        'Jl. Diponegoro', 'Jl. Imam Bonjol', 'Jl. Kartini', 'Jl. Pahlawan',
        'Jl. Veteran', 'Jl. Pemuda', 'Jl. Asia Afrika', 'Jl. Thamrin',
        'Jl. Mangga', 'Jl. Melati', 'Jl. Mawar', 'Jl. Kenanga',
        'Jl. Flamboyan', 'Jl. Anggrek', 'Jl. Cempaka', 'Jl. Dahlia'
    ];

    public function definition(): array
    {
        // Generate nomor KK dengan format yang valid (16 digit)
        // Format: PPKKCC-DDMMYY-XXXX
        // PP = Kode Provinsi, KK = Kode Kabupaten, CC = Kode Kecamatan
        // DDMMYY = Tanggal pembuatan, XXXX = Nomor urut
        $provinceCode = str_pad($this->faker->numberBetween(11, 94), 2, '0', STR_PAD_LEFT);
        $regencyCode = str_pad($this->faker->numberBetween(1, 99), 2, '0', STR_PAD_LEFT);
        $districtCode = str_pad($this->faker->numberBetween(1, 99), 2, '0', STR_PAD_LEFT);
        $dateCode = $this->faker->date('dmy');
        $sequenceCode = str_pad($this->faker->numberBetween(1, 9999), 4, '0', STR_PAD_LEFT);

        $familyCardNumber = $provinceCode . $regencyCode . $districtCode . $dateCode;
        // Pastikan total 16 digit
        $familyCardNumber = substr($familyCardNumber, 0, 12) . $sequenceCode;

        // Generate alamat yang realistis
        $street = $this->faker->randomElement($this->streets);
        $houseNumber = $this->faker->numberBetween(1, 200);
        $city = $this->faker->randomElement($this->cities);
        $address = "{$street} No. {$houseNumber}, {$city}";

        return [
            'family_card_number' => $familyCardNumber,
            'head_of_family' => $this->faker->name(),
            'address' => $address,
            'rt' => str_pad($this->faker->numberBetween(1, 20), 3, '0', STR_PAD_LEFT),
            'rw' => str_pad($this->faker->numberBetween(1, 10), 3, '0', STR_PAD_LEFT),
            'postal_code' => $this->faker->postcode(),
        ];
    }
}

Saya ingin menjelaskan beberapa bagian penting dari Factory di atas. Properti $cities dan $streets adalah array yang berisi daftar kota dan nama jalan di Indonesia. Dengan menggunakan $this->faker->randomElement(), setiap keluarga akan mendapatkan alamat yang berbeda-beda dari daftar ini. Ini membuat data terasa lebih realistis dibanding menggunakan alamat random dari Faker yang formatnya tidak sesuai dengan Indonesia.

Untuk nomor KK, saya membuat format yang menyerupai struktur NIK Indonesia meskipun tidak persis sama. Yang penting adalah nomor tersebut terdiri dari 16 digit dan unik untuk setiap keluarga. Fungsi str_pad() digunakan untuk memastikan setiap bagian memiliki jumlah digit yang tepat dengan menambahkan angka 0 di depan jika diperlukan.

Sekarang kita buat Factory untuk model Villager yang lebih kompleks karena harus terhubung dengan Family dan memiliki banyak kolom.

php artisan make:factory VillagerFactory

Buka file database/factories/VillagerFactory.php dan isi dengan kode berikut.

<?php

namespace Database\\Factories;

use App\\Models\\Family;
use App\\Models\\Villager;
use Illuminate\\Database\\Eloquent\\Factories\\Factory;

class VillagerFactory extends Factory
{
    protected $model = Villager::class;

    /**
     * Daftar kota kelahiran di Indonesia
     */
    private array $birthPlaces = [
        'Jakarta', 'Surabaya', 'Bandung', 'Medan', 'Semarang',
        'Makassar', 'Palembang', 'Tangerang', 'Depok', 'Bekasi',
        'Bogor', 'Malang', 'Yogyakarta', 'Solo', 'Denpasar',
        'Banjarmasin', 'Pontianak', 'Samarinda', 'Padang', 'Pekanbaru',
        'Manado', 'Jayapura', 'Ambon', 'Kupang', 'Mataram',
        'Bengkulu', 'Jambi', 'Lampung', 'Palangkaraya', 'Kendari'
    ];

    /**
     * Daftar agama di Indonesia
     */
    private array $religions = [
        'Islam', 'Kristen', 'Katolik', 'Hindu', 'Buddha', 'Konghucu'
    ];

    /**
     * Daftar tingkat pendidikan
     */
    private array $educations = [
        'Tidak/Belum Sekolah', 'SD/Sederajat', 'SMP/Sederajat',
        'SMA/Sederajat', 'D1', 'D2', 'D3', 'D4/S1', 'S2', 'S3'
    ];

    /**
     * Daftar pekerjaan umum
     */
    private array $occupations = [
        'Petani', 'Pedagang', 'PNS', 'Karyawan Swasta', 'Wiraswasta',
        'Buruh', 'Nelayan', 'Guru', 'Dokter', 'Perawat',
        'TNI/Polri', 'Ibu Rumah Tangga', 'Pelajar/Mahasiswa', 'Tidak Bekerja',
        'Pensiunan', 'Sopir', 'Tukang', 'Montir', 'Pengacara', 'Akuntan'
    ];

    /**
     * Daftar status perkawinan
     */
    private array $maritalStatuses = [
        'Belum Kawin', 'Kawin', 'Cerai Hidup', 'Cerai Mati'
    ];

    /**
     * Daftar nama depan pria Indonesia
     */
    private array $maleFirstNames = [
        'Ahmad', 'Muhammad', 'Budi', 'Andi', 'Dedi', 'Eko', 'Agus',
        'Hendra', 'Irwan', 'Joko', 'Bambang', 'Cahyo', 'Dimas', 'Fajar',
        'Gilang', 'Hadi', 'Ivan', 'Johan', 'Kurniawan', 'Lukman',
        'Maulana', 'Nugroho', 'Oscar', 'Putra', 'Rizky', 'Surya', 'Teguh'
    ];

    /**
     * Daftar nama depan wanita Indonesia
     */
    private array $femaleFirstNames = [
        'Siti', 'Sri', 'Dewi', 'Ani', 'Rina', 'Yuni', 'Ratna',
        'Wati', 'Lestari', 'Indah', 'Putri', 'Nur', 'Fitri', 'Maya',
        'Nia', 'Dian', 'Eka', 'Wulan', 'Ayu', 'Mega', 'Citra',
        'Bunga', 'Kartika', 'Laras', 'Mentari', 'Nadia', 'Sari'
    ];

    /**
     * Daftar nama belakang Indonesia
     */
    private array $lastNames = [
        'Pratama', 'Saputra', 'Wijaya', 'Hidayat', 'Santoso', 'Susanto',
        'Setiawan', 'Permana', 'Kusuma', 'Nugraha', 'Ramadhan', 'Putra',
        'Wibowo', 'Siregar', 'Nasution', 'Lubis', 'Hutapea', 'Siahaan',
        'Panjaitan', 'Situmorang', 'Hasibuan', 'Harahap', 'Simbolon'
    ];

    public function definition(): array
    {
        // Tentukan gender terlebih dahulu untuk menentukan nama
        $gender = $this->faker->randomElement(['male', 'female']);

        // Generate nama berdasarkan gender
        if ($gender === 'male') {
            $firstName = $this->faker->randomElement($this->maleFirstNames);
        } else {
            $firstName = $this->faker->randomElement($this->femaleFirstNames);
        }
        $lastName = $this->faker->randomElement($this->lastNames);
        $fullName = "{$firstName} {$lastName}";

        // Generate NIK dengan format yang valid (16 digit)
        // Format: PPKKCC-DDMMYY-XXXX
        // Untuk wanita, tanggal ditambah 40
        $provinceCode = str_pad($this->faker->numberBetween(11, 94), 2, '0', STR_PAD_LEFT);
        $regencyCode = str_pad($this->faker->numberBetween(1, 99), 2, '0', STR_PAD_LEFT);
        $districtCode = str_pad($this->faker->numberBetween(1, 99), 2, '0', STR_PAD_LEFT);

        $birthDate = $this->faker->dateTimeBetween('-70 years', '-17 years');
        $day = (int) $birthDate->format('d');
        if ($gender === 'female') {
            $day += 40; // Konvensi NIK Indonesia untuk wanita
        }
        $dateCode = str_pad($day, 2, '0', STR_PAD_LEFT) . $birthDate->format('my');
        $sequenceCode = str_pad($this->faker->numberBetween(1, 9999), 4, '0', STR_PAD_LEFT);

        $nik = $provinceCode . $regencyCode . $districtCode . $dateCode . $sequenceCode;

        // Tentukan status perkawinan berdasarkan umur
        $age = (int) $birthDate->diff(now())->y;
        if ($age < 20) {
            $maritalStatus = 'Belum Kawin';
        } else {
            $maritalStatus = $this->faker->randomElement($this->maritalStatuses);
        }

        // Tentukan pendidikan berdasarkan umur
        if ($age < 7) {
            $education = 'Tidak/Belum Sekolah';
        } elseif ($age < 13) {
            $education = $this->faker->randomElement(['Tidak/Belum Sekolah', 'SD/Sederajat']);
        } elseif ($age < 16) {
            $education = $this->faker->randomElement(['SD/Sederajat', 'SMP/Sederajat']);
        } elseif ($age < 19) {
            $education = $this->faker->randomElement(['SMP/Sederajat', 'SMA/Sederajat']);
        } else {
            $education = $this->faker->randomElement($this->educations);
        }

        // Tentukan pekerjaan berdasarkan umur dan status
        if ($age < 17) {
            $occupation = 'Pelajar/Mahasiswa';
        } elseif ($age > 60) {
            $occupation = $this->faker->randomElement(['Pensiunan', 'Tidak Bekerja', 'Petani', 'Pedagang']);
        } else {
            $occupation = $this->faker->randomElement($this->occupations);
        }

        return [
            'family_id' => Family::factory(),
            'nik' => $nik,
            'name' => $fullName,
            'birth_place' => $this->faker->randomElement($this->birthPlaces),
            'birth_date' => $birthDate->format('Y-m-d'),
            'gender' => $gender,
            'religion' => $this->faker->randomElement($this->religions),
            'education' => $education,
            'occupation' => $occupation,
            'marital_status' => $maritalStatus,
            'blood_type' => $this->faker->optional(0.7)->randomElement(['A', 'B', 'AB', 'O']),
            'is_head_of_family' => false,
            'phone' => $this->faker->optional(0.6)->numerify('08##########'),
        ];
    }

    /**
     * State untuk kepala keluarga
     */
    public function asHeadOfFamily(): static
    {
        return $this->state(fn (array $attributes) => [
            'is_head_of_family' => true,
            'gender' => 'male',
            'marital_status' => 'Kawin',
        ]);
    }
}

Factory untuk Villager ini lebih kompleks karena saya ingin data yang dihasilkan benar-benar realistis dan konsisten. Saya menjelaskan beberapa logika penting di dalamnya.

Pertama, untuk nama warga, saya membuat daftar nama depan yang berbeda untuk pria dan wanita. Method definition() menentukan gender terlebih dahulu, kemudian memilih nama depan yang sesuai. Ini membuat data terasa lebih natural karena tidak akan ada warga pria dengan nama Siti atau wanita dengan nama Budi.

Kedua, untuk NIK, saya mengikuti konvensi Indonesia di mana tanggal lahir wanita ditambah 40 pada digit ke-7 dan ke-8. Jadi jika seorang wanita lahir tanggal 15, maka di NIK akan tertulis 55. Detail seperti ini membuat data dummy kita lebih mendekati kondisi nyata.

Ketiga, saya membuat logika yang menghubungkan umur dengan status perkawinan, pendidikan, dan pekerjaan. Tidak masuk akal jika ada anak berumur 10 tahun dengan status Cerai Hidup atau pendidikan S3. Dengan logika kondisional ini, data yang dihasilkan akan lebih konsisten dan realistis.

Keempat, method asHeadOfFamily() adalah state yang bisa digunakan untuk membuat warga sebagai kepala keluarga. State ini memastikan kepala keluarga berjenis kelamin pria dan berstatus Kawin.

Selanjutnya kita buat Factory untuk DocumentType.

php artisan make:factory DocumentTypeFactory

<?php

namespace Database\\Factories;

use App\\Models\\DocumentType;
use Illuminate\\Database\\Eloquent\\Factories\\Factory;

class DocumentTypeFactory extends Factory
{
    protected $model = DocumentType::class;

    /**
     * Daftar jenis surat yang umum di desa
     */
    private array $documentTypes = [
        [
            'name' => 'Surat Keterangan Domisili',
            'code' => 'SKD',
            'description' => 'Surat keterangan yang menyatakan seseorang berdomisili di wilayah desa',
            'requirements' => ['KTP', 'Kartu Keluarga', 'Pas Foto 3x4']
        ],
        [
            'name' => 'Surat Keterangan Tidak Mampu',
            'code' => 'SKTM',
            'description' => 'Surat keterangan untuk warga yang kurang mampu secara ekonomi',
            'requirements' => ['KTP', 'Kartu Keluarga', 'Surat Pengantar RT/RW']
        ],
        [
            'name' => 'Surat Pengantar KTP',
            'code' => 'SP-KTP',
            'description' => 'Surat pengantar untuk pembuatan atau perpanjangan KTP',
            'requirements' => ['Kartu Keluarga', 'Pas Foto 3x4', 'KTP Lama (jika perpanjangan)']
        ],
        [
            'name' => 'Surat Keterangan Usaha',
            'code' => 'SKU',
            'description' => 'Surat keterangan untuk keperluan usaha atau bisnis',
            'requirements' => ['KTP', 'Kartu Keluarga', 'Bukti Usaha']
        ],
        [
            'name' => 'Surat Keterangan Kelahiran',
            'code' => 'SKL',
            'description' => 'Surat keterangan untuk pencatatan kelahiran',
            'requirements' => ['KTP Orang Tua', 'Kartu Keluarga', 'Surat Keterangan dari Bidan/RS']
        ],
        [
            'name' => 'Surat Keterangan Kematian',
            'code' => 'SKM',
            'description' => 'Surat keterangan untuk pencatatan kematian',
            'requirements' => ['KTP Almarhum', 'Kartu Keluarga', 'Surat Keterangan dari RS/Dokter']
        ],
        [
            'name' => 'Surat Pengantar Nikah',
            'code' => 'SPN',
            'description' => 'Surat pengantar untuk keperluan pernikahan',
            'requirements' => ['KTP', 'Kartu Keluarga', 'Akta Kelahiran', 'Pas Foto']
        ],
        [
            'name' => 'Surat Keterangan Pindah',
            'code' => 'SKP',
            'description' => 'Surat keterangan untuk warga yang akan pindah domisili',
            'requirements' => ['KTP', 'Kartu Keluarga', 'Surat Pengantar RT/RW']
        ],
        [
            'name' => 'Surat Keterangan Catatan Kepolisian',
            'code' => 'SKCK',
            'description' => 'Surat pengantar untuk pembuatan SKCK',
            'requirements' => ['KTP', 'Kartu Keluarga', 'Pas Foto 4x6']
        ],
        [
            'name' => 'Surat Keterangan Izin Keramaian',
            'code' => 'SKIK',
            'description' => 'Surat izin untuk mengadakan acara atau keramaian',
            'requirements' => ['KTP', 'Surat Permohonan', 'Denah Lokasi']
        ],
    ];

    private static int $typeIndex = 0;

    public function definition(): array
    {
        // Gunakan data dari daftar jika masih ada
        if (self::$typeIndex < count($this->documentTypes)) {
            $type = $this->documentTypes[self::$typeIndex];
            self::$typeIndex++;

            return [
                'name' => $type['name'],
                'code' => $type['code'],
                'description' => $type['description'],
                'requirements' => $type['requirements'],
                'is_active' => true,
            ];
        }

        // Jika sudah habis, generate random
        return [
            'name' => 'Surat ' . $this->faker->words(3, true),
            'code' => strtoupper($this->faker->lexify('S??')),
            'description' => $this->faker->sentence(),
            'requirements' => ['KTP', 'Kartu Keluarga'],
            'is_active' => $this->faker->boolean(90),
        ];
    }
}

Factory DocumentType ini sedikit berbeda karena saya menggunakan daftar jenis surat yang sudah ditentukan. Variabel static $typeIndex digunakan untuk mengambil data secara berurutan dari array $documentTypes. Ini memastikan 10 jenis surat pertama yang dibuat adalah jenis surat yang umum digunakan di desa dengan data yang lengkap dan realistis.

Sekarang kita buat Factory untuk Document.

php artisan make:factory DocumentFactory

<?php

namespace Database\\Factories;

use App\\Models\\Document;
use App\\Models\\DocumentType;
use App\\Models\\Villager;
use Illuminate\\Database\\Eloquent\\Factories\\Factory;

class DocumentFactory extends Factory
{
    protected $model = Document::class;

    /**
     * Daftar keperluan pengajuan surat
     */
    private array $purposes = [
        'Untuk keperluan melamar pekerjaan',
        'Untuk keperluan pendaftaran sekolah anak',
        'Untuk keperluan pengajuan kredit bank',
        'Untuk keperluan pembuatan paspor',
        'Untuk keperluan pendaftaran BPJS',
        'Untuk keperluan klaim asuransi',
        'Untuk keperluan pendaftaran kuliah',
        'Untuk keperluan administrasi kantor',
        'Untuk keperluan pengajuan bantuan sosial',
        'Untuk keperluan pembuatan SIM',
        'Untuk keperluan pendaftaran CPNS',
        'Untuk keperluan pengajuan KPR',
        'Untuk keperluan beasiswa',
        'Untuk keperluan visa',
        'Untuk keperluan administrasi pernikahan',
    ];

    /**
     * Daftar catatan dari petugas
     */
    private array $notes = [
        'Dokumen sudah lengkap',
        'Menunggu verifikasi data',
        'Perlu tambahan dokumen pendukung',
        'Sedang dalam proses tanda tangan kepala desa',
        'Data sudah diverifikasi',
        'Mohon melengkapi fotokopi KK',
        'Proses selesai, silakan diambil',
        'Ditolak karena data tidak valid',
        'Menunggu jadwal pelayanan',
        null,
    ];

    public function definition(): array
    {
        $status = $this->faker->randomElement(['pending', 'processing', 'completed', 'rejected']);

        // Tentukan completed_at berdasarkan status
        $completedAt = null;
        if ($status === 'completed') {
            $completedAt = $this->faker->dateTimeBetween('-30 days', 'now');
        }

        // Generate nomor surat jika sudah selesai
        $documentNumber = null;
        if ($status === 'completed') {
            $documentNumber = sprintf(
                '%s/%03d/%s/%s',
                $this->faker->randomElement(['SKD', 'SKTM', 'SKU', 'SKL']),
                $this->faker->numberBetween(1, 999),
                $this->faker->randomElement(['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI', 'XII']),
                date('Y')
            );
        }

        return [
            'villager_id' => Villager::factory(),
            'document_type_id' => DocumentType::factory(),
            'document_number' => $documentNumber,
            'purpose' => $this->faker->randomElement($this->purposes),
            'status' => $status,
            'notes' => $this->faker->randomElement($this->notes),
            'completed_at' => $completedAt,
            'created_at' => $this->faker->dateTimeBetween('-90 days', 'now'),
        ];
    }

    /**
     * State untuk dokumen pending
     */
    public function pending(): static
    {
        return $this->state(fn (array $attributes) => [
            'status' => 'pending',
            'document_number' => null,
            'completed_at' => null,
            'notes' => 'Menunggu verifikasi data',
        ]);
    }

    /**
     * State untuk dokumen completed
     */
    public function completed(): static
    {
        return $this->state(fn (array $attributes) => [
            'status' => 'completed',
            'completed_at' => $this->faker->dateTimeBetween('-30 days', 'now'),
            'notes' => 'Dokumen sudah selesai diproses',
        ]);
    }
}

Factory Document ini memiliki logika yang memastikan konsistensi data. Jika status dokumen adalah completed, maka kolom completed_at dan document_number akan terisi. Sebaliknya, jika status masih pending atau processing, kedua kolom tersebut akan bernilai null. Method pending() dan completed() adalah state yang bisa digunakan untuk membuat dokumen dengan status tertentu.

Terakhir, kita buat Factory untuk Announcement.

php artisan make:factory AnnouncementFactory

<?php

namespace Database\\Factories;

use App\\Models\\Announcement;
use Illuminate\\Database\\Eloquent\\Factories\\Factory;
use Illuminate\\Support\\Str;

class AnnouncementFactory extends Factory
{
    protected $model = Announcement::class;

    /**
     * Daftar kategori pengumuman
     */
    private array $categories = [
        'Umum', 'Kesehatan', 'Pendidikan', 'Sosial', 'Keamanan',
        'Lingkungan', 'Pembangunan', 'Kegiatan', 'Bantuan', 'Informasi'
    ];

    /**
     * Daftar judul pengumuman yang realistis
     */
    private array $titles = [
        'Jadwal Posyandu Bulan Ini',
        'Pengumuman Pembagian Bantuan Sosial',
        'Himbauan Kebersihan Lingkungan',
        'Jadwal Vaksinasi COVID-19',
        'Pengumuman Pemilihan Kepala Desa',
        'Informasi Pembayaran PBB',
        'Jadwal Kerja Bakti Desa',
        'Pengumuman Pendaftaran UMKM',
        'Informasi Bantuan Benih Pertanian',
        'Jadwal Penyuluhan Kesehatan',
        'Pengumuman Lomba Desa Bersih',
        'Informasi Pelatihan Keterampilan',
        'Jadwal Pengajian Rutin',
        'Pengumuman Gotong Royong',
        'Informasi Pendaftaran BLT',
        'Jadwal Rapat Warga RT/RW',
        'Pengumuman Perbaikan Jalan Desa',
        'Informasi Program KB',
        'Jadwal Senam Sehat',
        'Pengumuman Pembagian Sembako',
    ];

    public function definition(): array
    {
        $title = $this->faker->randomElement($this->titles) . ' - ' . $this->faker->monthName() . ' ' . date('Y');
        $isPublished = $this->faker->boolean(80);

        return [
            'title' => $title,
            'slug' => Str::slug($title) . '-' . $this->faker->unique()->numberBetween(1000, 9999),
            'content' => $this->generateContent(),
            'category' => $this->faker->randomElement($this->categories),
            'is_published' => $isPublished,
            'published_at' => $isPublished ? $this->faker->dateTimeBetween('-60 days', 'now') : null,
        ];
    }

    /**
     * Generate konten pengumuman yang realistis
     */
    private function generateContent(): string
    {
        $paragraphs = [];

        // Paragraf pembuka
        $paragraphs[] = "Dengan hormat,\\n\\nBersama ini kami sampaikan kepada seluruh warga Desa bahwa " .
                        strtolower($this->faker->sentence(10));

        // Paragraf isi
        for ($i = 0; $i < $this->faker->numberBetween(2, 4); $i++) {
            $paragraphs[] = $this->faker->paragraph(4);
        }

        // Paragraf penutup
        $paragraphs[] = "Demikian pengumuman ini kami sampaikan. Atas perhatian dan kerjasamanya, kami ucapkan terima kasih.\\n\\n" .
                        "Hormat kami,\\nKepala Desa";

        return implode("\\n\\n", $paragraphs);
    }

    /**
     * State untuk pengumuman yang sudah dipublikasikan
     */
    public function published(): static
    {
        return $this->state(fn (array $attributes) => [
            'is_published' => true,
            'published_at' => $this->faker->dateTimeBetween('-30 days', 'now'),
        ]);
    }

    /**
     * State untuk pengumuman draft
     */
    public function draft(): static
    {
        return $this->state(fn (array $attributes) => [
            'is_published' => false,
            'published_at' => null,
        ]);
    }
}

Factory Announcement memiliki method generateContent() yang membuat konten pengumuman dengan struktur yang realistis. Ada paragraf pembuka dengan salam, paragraf isi, dan paragraf penutup dengan tanda tangan kepala desa. Ini membuat data dummy terlihat seperti pengumuman desa yang sesungguhnya.

Sekarang kita buat DatabaseSeeder yang akan menjalankan semua Factory dengan jumlah data yang besar. Buka file database/seeders/DatabaseSeeder.php dan ubah isinya.

<?php

namespace Database\\Seeders;

use App\\Models\\Announcement;
use App\\Models\\Document;
use App\\Models\\DocumentType;
use App\\Models\\Family;
use App\\Models\\Villager;
use Illuminate\\Database\\Seeder;
use Illuminate\\Support\\Facades\\DB;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        // Disable foreign key checks untuk mempercepat seeding
        DB::statement('SET FOREIGN_KEY_CHECKS=0;');

        // Truncate semua tabel
        Document::truncate();
        Villager::truncate();
        Family::truncate();
        DocumentType::truncate();
        Announcement::truncate();

        // Enable kembali foreign key checks
        DB::statement('SET FOREIGN_KEY_CHECKS=1;');

        $this->command->info('Memulai proses seeding...');
        $this->command->newLine();

        // Buat jenis dokumen terlebih dahulu (10 jenis)
        $this->command->info('Membuat 10 jenis dokumen...');
        $documentTypes = DocumentType::factory(10)->create();
        $this->command->info('✓ Jenis dokumen berhasil dibuat');
        $this->command->newLine();

        // Buat keluarga dan warga
        $this->command->info('Membuat 1.000 keluarga dan 5.000 warga...');
        $bar = $this->command->getOutput()->createProgressBar(1000);
        $bar->start();

        // Kita akan membuat 1000 keluarga dengan rata-rata 5 warga per keluarga
        $families = collect();
        $villagersData = collect();

        for ($i = 0; $i < 1000; $i++) {
            // Buat keluarga
            $family = Family::factory()->create();
            $families->push($family);

            // Buat kepala keluarga
            Villager::factory()
                ->asHeadOfFamily()
                ->create(['family_id' => $family->id]);

            // Buat anggota keluarga lainnya (3-6 orang)
            $memberCount = rand(3, 6);
            Villager::factory($memberCount)
                ->create(['family_id' => $family->id]);

            $bar->advance();
        }

        $bar->finish();
        $this->command->newLine(2);
        $this->command->info('✓ Keluarga dan warga berhasil dibuat');
        $this->command->info('  Total keluarga: ' . Family::count());
        $this->command->info('  Total warga: ' . Villager::count());
        $this->command->newLine();

        // Ambil semua villager IDs untuk digunakan saat membuat dokumen
        $villagerIds = Villager::pluck('id')->toArray();
        $documentTypeIds = $documentTypes->pluck('id')->toArray();

        // Buat dokumen dalam batch untuk efisiensi
        $this->command->info('Membuat 10.000 dokumen...');
        $bar = $this->command->getOutput()->createProgressBar(100);
        $bar->start();

        // Buat 10.000 dokumen dalam 100 batch (100 dokumen per batch)
        for ($batch = 0; $batch < 100; $batch++) {
            $documents = [];

            for ($i = 0; $i < 100; $i++) {
                $status = ['pending', 'processing', 'completed', 'rejected'][rand(0, 3)];
                $createdAt = now()->subDays(rand(1, 90));

                $documents[] = [
                    'villager_id' => $villagerIds[array_rand($villagerIds)],
                    'document_type_id' => $documentTypeIds[array_rand($documentTypeIds)],
                    'document_number' => $status === 'completed' ? 'DOC/' . str_pad($batch * 100 + $i + 1, 6, '0', STR_PAD_LEFT) . '/' . date('Y') : null,
                    'purpose' => $this->getRandomPurpose(),
                    'status' => $status,
                    'notes' => rand(0, 1) ? 'Catatan untuk dokumen ini' : null,
                    'completed_at' => $status === 'completed' ? $createdAt->addDays(rand(1, 7)) : null,
                    'created_at' => $createdAt,
                    'updated_at' => $createdAt,
                ];
            }

            // Bulk insert untuk efisiensi
            Document::insert($documents);
            $bar->advance();
        }

        $bar->finish();
        $this->command->newLine(2);
        $this->command->info('✓ Dokumen berhasil dibuat');
        $this->command->info('  Total dokumen: ' . Document::count());
        $this->command->newLine();

        // Buat pengumuman
        $this->command->info('Membuat 100 pengumuman...');
        Announcement::factory(100)->create();
        $this->command->info('✓ Pengumuman berhasil dibuat');
        $this->command->info('  Total pengumuman: ' . Announcement::count());
        $this->command->newLine();

        // Tampilkan ringkasan
        $this->command->newLine();
        $this->command->info('========================================');
        $this->command->info('SEEDING SELESAI!');
        $this->command->info('========================================');
        $this->command->info('Total Keluarga    : ' . number_format(Family::count()));
        $this->command->info('Total Warga       : ' . number_format(Villager::count()));
        $this->command->info('Total Jenis Surat : ' . number_format(DocumentType::count()));
        $this->command->info('Total Dokumen     : ' . number_format(Document::count()));
        $this->command->info('Total Pengumuman  : ' . number_format(Announcement::count()));
        $this->command->info('========================================');
    }

    /**
     * Mendapatkan purpose random untuk dokumen
     */
    private function getRandomPurpose(): string
    {
        $purposes = [
            'Untuk keperluan melamar pekerjaan',
            'Untuk keperluan pendaftaran sekolah',
            'Untuk keperluan pengajuan kredit',
            'Untuk keperluan pembuatan paspor',
            'Untuk keperluan pendaftaran BPJS',
            'Untuk keperluan administrasi',
            'Untuk keperluan beasiswa',
            'Untuk keperluan visa',
        ];

        return $purposes[array_rand($purposes)];
    }
}

DatabaseSeeder ini saya desain dengan beberapa teknik untuk optimasi performa. Pertama, saya menggunakan DB::statement('SET FOREIGN_KEY_CHECKS=0') untuk menonaktifkan pengecekan foreign key saat truncate tabel. Ini mempercepat proses pengosongan tabel sebelum seeding dimulai.

Kedua, untuk pembuatan dokumen, saya menggunakan teknik bulk insert dengan method Document::insert() alih-alih Document::create() dalam loop. Method insert() menjalankan satu query INSERT dengan multiple values, sedangkan create() menjalankan query INSERT terpisah untuk setiap record. Untuk 10.000 dokumen, perbedaannya sangat signifikan.

Ketiga, saya menggunakan progress bar dengan $this->command->getOutput()->createProgressBar() agar kita bisa memonitor progress seeding yang memakan waktu cukup lama. Ini memberikan feedback visual sehingga kita tahu proses masih berjalan dan tidak hang.

Keempat, pembuatan keluarga dan warga dilakukan secara terstruktur. Setiap keluarga memiliki satu kepala keluarga yang dibuat dengan state asHeadOfFamily(), kemudian ditambah 3-6 anggota keluarga lainnya. Ini menghasilkan total sekitar 5.000 warga yang terdistribusi ke 1.000 keluarga.

Sekarang jalankan seeder dengan perintah berikut.

php artisan db:seed

Proses ini akan memakan waktu beberapa menit tergantung spesifikasi komputer. Kalian akan melihat progress bar dan ringkasan di akhir proses. Setelah selesai, kita memiliki database dengan ribuan data yang siap digunakan untuk testing performa index di bagian selanjutnya.

Untuk memverifikasi jumlah data, kalian bisa masuk ke MySQL dan menjalankan query COUNT.

USE desa_digital;

SELECT 'families' as tabel, COUNT(*) as jumlah FROM families
UNION ALL
SELECT 'villagers', COUNT(*) FROM villagers
UNION ALL
SELECT 'document_types', COUNT(*) FROM document_types
UNION ALL
SELECT 'documents', COUNT(*) FROM documents
UNION ALL
SELECT 'announcements', COUNT(*) FROM announcements;

Hasil query akan menampilkan jumlah record di setiap tabel. Dengan data sebanyak ini, kita akan bisa melihat perbedaan performa yang signifikan antara query dengan index dan tanpa index.

Bagian 6: Install Laravel Telescope untuk Monitoring Query

Sekarang kita sudah punya database dengan ribuan data, saatnya memasang tools untuk memonitor performa query. Laravel Telescope adalah debugging assistant yang dikembangkan oleh tim Laravel sendiri. Telescope merekam berbagai aktivitas aplikasi termasuk request HTTP, exceptions, log entries, database queries, queued jobs, mail, notifications, cache operations, dan masih banyak lagi. Untuk tutorial ini, kita akan fokus pada fitur query monitoring yang sangat berguna untuk menganalisis performa database.

Mari kita install Telescope menggunakan Composer. Jalankan perintah berikut di terminal.

composer require laravel/telescope

Setelah package terinstall, kita perlu mempublikasikan assets dan konfigurasi Telescope. Jalankan perintah berikut.

php artisan telescope:install

Perintah ini akan membuat beberapa file penting. File config/telescope.php berisi semua konfigurasi Telescope. File app/Providers/TelescopeServiceProvider.php adalah service provider yang mengatur bagaimana Telescope bekerja. Selain itu, perintah ini juga membuat migration untuk tabel-tabel yang dibutuhkan Telescope untuk menyimpan data monitoring.

Sekarang jalankan migration untuk membuat tabel-tabel Telescope.

php artisan migrate

Telescope membutuhkan beberapa tabel untuk menyimpan data monitoring seperti telescope_entries, telescope_entries_tags, dan telescope_monitoring. Tabel-tabel ini akan menyimpan semua aktivitas yang direkam oleh Telescope.

Sebelum kita menggunakan Telescope, mari kita pahami dan sesuaikan konfigurasinya. Buka file config/telescope.php dan perhatikan beberapa pengaturan penting.

<?php

use Laravel\\Telescope\\Http\\Middleware\\Authorize;
use Laravel\\Telescope\\Watchers;

return [

    /*
    |--------------------------------------------------------------------------
    | Telescope Domain
    |--------------------------------------------------------------------------
    |
    | This is the subdomain where Telescope will be accessible from. If this
    | setting is null, Telescope will reside under the same domain as the
    | application. Otherwise, this value will serve as the subdomain.
    |
    */

    'domain' => env('TELESCOPE_DOMAIN'),

    /*
    |--------------------------------------------------------------------------
    | Telescope Path
    |--------------------------------------------------------------------------
    |
    | This is the URI path where Telescope will be accessible from. Feel free
    | to change this path to anything you like. Note that the URI will not
    | affect the paths of its internal API that aren't exposed to users.
    |
    */

    'path' => env('TELESCOPE_PATH', 'telescope'),

    /*
    |--------------------------------------------------------------------------
    | Telescope Storage Driver
    |--------------------------------------------------------------------------
    |
    | This configuration options determines the storage driver that will
    | be used to store Telescope's data. In addition, you may set any
    | custom options as needed by the particular driver you choose.
    |
    */

    'driver' => env('TELESCOPE_DRIVER', 'database'),

    'storage' => [
        'database' => [
            'connection' => env('DB_CONNECTION', 'mysql'),
            'chunk' => 1000,
        ],
    ],

    /*
    |--------------------------------------------------------------------------
    | Telescope Master Switch
    |--------------------------------------------------------------------------
    |
    | This option may be used to disable all Telescope watchers regardless
    | of their individual configuration, which simply provides a single
    | and convenient way to enable or disable Telescope data recording.
    |
    */

    'enabled' => env('TELESCOPE_ENABLED', true),

    /*
    |--------------------------------------------------------------------------
    | Telescope Route Middleware
    |--------------------------------------------------------------------------
    |
    | These middleware will be assigned to every Telescope route, giving you
    | the chance to add your own middleware to this list or change any of
    | the existing middleware. Or, you can simply stick with this list.
    |
    */

    'middleware' => [
        'web',
        Authorize::class,
    ],

    /*
    |--------------------------------------------------------------------------
    | Allowed / Ignored Paths & Commands
    |--------------------------------------------------------------------------
    |
    | The following array lists the URI paths and Artisan commands that will
    | not be watched by Telescope. In addition to this list, some Laravel
    | commands, like migrations and queue commands, are always ignored.
    |
    */

    'only_paths' => [
        // 'api/*'
    ],

    'ignore_paths' => [
        'livewire*',
        'nova-api*',
        'pulse*',
    ],

    'ignore_commands' => [
        //
    ],

    /*
    |--------------------------------------------------------------------------
    | Telescope Watchers
    |--------------------------------------------------------------------------
    |
    | The following array lists the "watchers" that will be registered with
    | Telescope. The watchers gather the application's profile data when
    | a request or task is executed. Feel free to customize this list.
    |
    */

    'watchers' => [
        Watchers\\BatchWatcher::class => env('TELESCOPE_BATCH_WATCHER', true),

        Watchers\\CacheWatcher::class => [
            'enabled' => env('TELESCOPE_CACHE_WATCHER', true),
            'hidden' => [],
        ],

        Watchers\\ClientRequestWatcher::class => env('TELESCOPE_CLIENT_REQUEST_WATCHER', true),

        Watchers\\CommandWatcher::class => [
            'enabled' => env('TELESCOPE_COMMAND_WATCHER', true),
            'ignore' => [],
        ],

        Watchers\\DumpWatcher::class => [
            'enabled' => env('TELESCOPE_DUMP_WATCHER', true),
            'always' => false,
        ],

        Watchers\\EventWatcher::class => [
            'enabled' => env('TELESCOPE_EVENT_WATCHER', true),
            'ignore' => [],
        ],

        Watchers\\ExceptionWatcher::class => env('TELESCOPE_EXCEPTION_WATCHER', true),

        Watchers\\GateWatcher::class => [
            'enabled' => env('TELESCOPE_GATE_WATCHER', true),
            'ignore_abilities' => [],
            'ignore_packages' => true,
            'ignore_paths' => [],
        ],

        Watchers\\JobWatcher::class => env('TELESCOPE_JOB_WATCHER', true),

        Watchers\\LogWatcher::class => [
            'enabled' => env('TELESCOPE_LOG_WATCHER', true),
            'level' => 'error',
        ],

        Watchers\\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true),

        Watchers\\ModelWatcher::class => [
            'enabled' => env('TELESCOPE_MODEL_WATCHER', true),
            'events' => ['eloquent.*'],
            'hydrations' => true,
        ],

        Watchers\\NotificationWatcher::class => env('TELESCOPE_NOTIFICATION_WATCHER', true),

        // Query Watcher - ini yang paling penting untuk tutorial kita
        Watchers\\QueryWatcher::class => [
            'enabled' => env('TELESCOPE_QUERY_WATCHER', true),
            'ignore_packages' => true,
            'ignore_paths' => [],
            'slow' => 100, // Query dianggap lambat jika lebih dari 100ms
        ],

        Watchers\\RedisWatcher::class => env('TELESCOPE_REDIS_WATCHER', true),

        Watchers\\RequestWatcher::class => [
            'enabled' => env('TELESCOPE_REQUEST_WATCHER', true),
            'size_limit' => env('TELESCOPE_RESPONSE_SIZE_LIMIT', 64),
            'ignore_http_methods' => [],
            'ignore_status_codes' => [],
        ],

        Watchers\\ScheduleWatcher::class => env('TELESCOPE_SCHEDULE_WATCHER', true),

        Watchers\\ViewWatcher::class => env('TELESCOPE_VIEW_WATCHER', true),
    ],
];

Saya ingin menjelaskan beberapa konfigurasi yang paling relevan untuk keperluan monitoring query.

Konfigurasi path menentukan URL di mana dashboard Telescope bisa diakses. Secara default nilainya adalah telescope, jadi kita bisa mengakses dashboard di http://localhost:8000/telescope. Kalian bisa mengubah ini sesuai kebutuhan, misalnya menjadi debug atau monitoring.

Konfigurasi enabled adalah master switch untuk mengaktifkan atau menonaktifkan Telescope secara keseluruhan. Di production, biasanya kita set ini ke false melalui environment variable TELESCOPE_ENABLED untuk menghindari overhead performa dan masalah keamanan.

Bagian watchers adalah array yang mendefinisikan komponen apa saja yang akan dimonitor oleh Telescope. Yang paling penting untuk kita adalah QueryWatcher yang merekam semua query database. Perhatikan konfigurasi slow yang bernilai 100. Ini berarti query yang memakan waktu lebih dari 100 milidetik akan ditandai sebagai slow query. Kalian bisa menyesuaikan nilai ini sesuai standar performa aplikasi kalian.

Sekarang buka file app/Providers/TelescopeServiceProvider.php untuk melihat bagaimana Telescope menentukan siapa yang boleh mengakses dashboard.

<?php

namespace App\\Providers;

use Illuminate\\Support\\Facades\\Gate;
use Laravel\\Telescope\\IncomingEntry;
use Laravel\\Telescope\\Telescope;
use Laravel\\Telescope\\TelescopeApplicationServiceProvider;

class TelescopeServiceProvider extends TelescopeApplicationServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        // Telescope::night();

        $this->hideSensitiveRequestDetails();

        // Filter entries yang akan direkam
        // Untuk development, kita rekam semua
        // Untuk production, bisa difilter untuk mengurangi data
        Telescope::filter(function (IncomingEntry $entry) {
            if ($this->app->environment('local')) {
                return true;
            }

            return $entry->isReportableException() ||
                   $entry->isFailedRequest() ||
                   $entry->isFailedJob() ||
                   $entry->isScheduledTask() ||
                   $entry->hasMonitoredTag();
        });
    }

    /**
     * Prevent sensitive request details from being logged by Telescope.
     */
    protected function hideSensitiveRequestDetails(): void
    {
        if ($this->app->environment('local')) {
            return;
        }

        Telescope::hideRequestParameters(['_token']);

        Telescope::hideRequestHeaders([
            'cookie',
            'x-csrf-token',
            'x-xsrf-token',
        ]);
    }

    /**
     * Register the Telescope gate.
     *
     * This gate determines who can access Telescope in non-local environments.
     */
    protected function gate(): void
    {
        Gate::define('viewTelescope', function ($user) {
            return in_array($user->email, [
                '[email protected]',
                // Tambahkan email admin lainnya di sini
            ]);
        });
    }
}

Method gate() mendefinisikan siapa yang boleh mengakses Telescope di environment non-local. Secara default, hanya user dengan email tertentu yang bisa mengakses. Untuk development, gate ini tidak berlaku sehingga siapapun bisa mengakses dashboard. Kalian bisa menambahkan email admin di array tersebut untuk environment staging atau production.

Method filter() menentukan entry mana yang akan direkam. Di environment local, semua entry direkam. Di environment lain, hanya exception, failed request, failed job, scheduled task, dan entry dengan tag tertentu yang direkam. Ini untuk mengurangi volume data di production.

Sekarang pastikan Telescope diaktifkan di file .env.

TELESCOPE_ENABLED=true
TELESCOPE_QUERY_WATCHER=true

Variable TELESCOPE_QUERY_WATCHER secara spesifik mengaktifkan monitoring query database. Pastikan ini bernilai true agar kita bisa melihat semua query yang dijalankan.

Jalankan development server untuk mengakses dashboard Telescope.

php artisan serve

Buka browser dan akses http://localhost:8000/telescope. Kalian akan melihat dashboard Telescope dengan berbagai menu di sidebar. Menu yang paling penting untuk kita adalah Queries yang menampilkan semua query database yang direkam.

Sebelum ada data query yang terekam, dashboard akan kosong. Mari kita buat beberapa request untuk menghasilkan query. Buka tab browser baru dan akses route default Laravel atau buat route sederhana untuk testing. Kita akan membuat route khusus untuk testing query.

Buka file routes/web.php dan tambahkan route berikut.

<?php

use App\\Models\\Villager;
use App\\Models\\Document;
use App\\Models\\Announcement;
use Illuminate\\Support\\Facades\\Route;

Route::get('/', function () {
    return view('welcome');
});

// Route untuk testing query monitoring
Route::get('/test-queries', function () {
    $results = [];

    // Query 1: Cari warga berdasarkan NIK
    $startTime = microtime(true);
    $villager = Villager::where('nik', '3201234567890123')->first();
    $results['find_by_nik'] = [
        'query' => "Villager::where('nik', '...')->first()",
        'time' => round((microtime(true) - $startTime) * 1000, 2) . ' ms',
        'result' => $villager ? 'Found' : 'Not found'
    ];

    // Query 2: Cari warga berdasarkan nama (LIKE)
    $startTime = microtime(true);
    $villagers = Villager::where('name', 'like', '%Ahmad%')->get();
    $results['search_by_name'] = [
        'query' => "Villager::where('name', 'like', '%Ahmad%')->get()",
        'time' => round((microtime(true) - $startTime) * 1000, 2) . ' ms',
        'result' => $villagers->count() . ' records found'
    ];

    // Query 3: Filter dokumen berdasarkan status
    $startTime = microtime(true);
    $pendingCount = Document::where('status', 'pending')->count();
    $results['count_pending_documents'] = [
        'query' => "Document::where('status', 'pending')->count()",
        'time' => round((microtime(true) - $startTime) * 1000, 2) . ' ms',
        'result' => $pendingCount . ' pending documents'
    ];

    // Query 4: Query dengan multiple kondisi (composite index)
    $startTime = microtime(true);
    $filtered = Villager::where('gender', 'male')
                        ->where('marital_status', 'Kawin')
                        ->get();
    $results['composite_filter'] = [
        'query' => "Villager::where('gender', 'male')->where('marital_status', 'Kawin')->get()",
        'time' => round((microtime(true) - $startTime) * 1000, 2) . ' ms',
        'result' => $filtered->count() . ' records found'
    ];

    // Query 5: Query dengan JOIN (eager loading)
    $startTime = microtime(true);
    $documents = Document::with(['villager', 'documentType'])
                         ->where('status', 'completed')
                         ->limit(100)
                         ->get();
    $results['eager_loading'] = [
        'query' => "Document::with(['villager', 'documentType'])->where('status', 'completed')->limit(100)->get()",
        'time' => round((microtime(true) - $startTime) * 1000, 2) . ' ms',
        'result' => $documents->count() . ' documents with relations'
    ];

    // Query 6: Pengumuman yang dipublikasikan
    $startTime = microtime(true);
    $announcements = Announcement::where('is_published', true)
                                 ->whereNotNull('published_at')
                                 ->where('published_at', '<=', now())
                                 ->orderBy('published_at', 'desc')
                                 ->get();
    $results['published_announcements'] = [
        'query' => "Announcement::published()->orderBy('published_at', 'desc')->get()",
        'time' => round((microtime(true) - $startTime) * 1000, 2) . ' ms',
        'result' => $announcements->count() . ' published announcements'
    ];

    return response()->json([
        'message' => 'Query test completed. Check Telescope for detailed query information.',
        'results' => $results,
        'telescope_url' => url('/telescope/queries')
    ], 200, [], JSON_PRETTY_PRINT);
});

Route /test-queries ini menjalankan berbagai jenis query dan mencatat waktu eksekusinya. Response berupa JSON yang menampilkan setiap query beserta waktu eksekusi dan hasilnya. Yang lebih penting, semua query ini akan terekam di Telescope dengan detail yang lebih lengkap.

Akses http://localhost:8000/test-queries di browser. Kalian akan melihat response JSON dengan hasil benchmark sederhana. Sekarang kembali ke dashboard Telescope di http://localhost:8000/telescope/queries.

Di halaman Queries, kalian akan melihat daftar semua query yang baru saja dijalankan. Setiap entry menampilkan informasi penting yang perlu dipahami.

Kolom pertama menampilkan query SQL yang sebenarnya dijalankan ke database. Ini adalah query mentah setelah Laravel mengkonversi Eloquent method menjadi SQL. Dengan melihat query SQL ini, kita bisa memahami apa yang sebenarnya terjadi di balik layar.

Kolom Time menampilkan waktu eksekusi query dalam milidetik. Query yang lambat akan ditandai dengan warna merah atau orange. Threshold slow query ditentukan oleh konfigurasi slow di QueryWatcher yang sudah kita bahas sebelumnya.

Kolom Connection menunjukkan koneksi database yang digunakan. Untuk aplikasi dengan multiple database connection, informasi ini sangat berguna.

Jika kalian klik salah satu entry query, akan muncul detail lengkap termasuk file dan line number yang memicu query tersebut. Ini sangat membantu untuk tracking di mana sebuah query dipanggil dalam kode kita. Kalian juga bisa melihat binding parameters yang digunakan dalam query.

Telescope juga memiliki fitur filter yang sangat berguna. Di bagian atas halaman Queries, ada input untuk filter berdasarkan waktu eksekusi. Kalian bisa memasukkan angka minimum untuk melihat hanya query yang lebih lambat dari nilai tersebut. Misalnya, masukkan 50 untuk melihat query yang memakan waktu lebih dari 50ms.

Selain monitoring query, Telescope juga merekam informasi tentang request HTTP. Buka menu Requests di sidebar untuk melihat semua request yang masuk ke aplikasi. Setiap request menampilkan URL, method, status code, dan waktu response. Klik salah satu request untuk melihat detail termasuk semua query yang dijalankan selama request tersebut.

Fitur ini sangat powerful untuk debugging N+1 query problem. N+1 problem terjadi ketika kita melakukan query di dalam loop, menghasilkan banyak query terpisah yang seharusnya bisa digabung menjadi satu query dengan eager loading. Di detail request, kalian bisa melihat apakah ada query yang berulang dengan pattern yang sama.

Untuk membersihkan data Telescope yang sudah lama, kalian bisa menggunakan perintah prune.

php artisan telescope:prune

Secara default, perintah ini menghapus entry yang lebih lama dari 24 jam. Kalian bisa menentukan periode dengan option --hours.

php artisan telescope:prune --hours=48

Untuk menghapus semua data Telescope, gunakan perintah clear.

php artisan telescope:clear

Sekarang kita sudah memiliki tools yang powerful untuk memonitor query database. Di bagian selanjutnya, kita akan melakukan benchmark sistematis untuk membandingkan performa query dengan dan tanpa index, dan menggunakan Telescope untuk memverifikasi hasilnya.

Bagian 7: Testing dan Perbandingan Performa Query

Di bagian ini kita akan membuktikan secara nyata dampak dari indexing terhadap performa query. Kita akan membuat Artisan command khusus untuk menjalankan benchmark yang sistematis, membandingkan waktu eksekusi sebelum dan sesudah index diterapkan, dan menggunakan EXPLAIN untuk menganalisis query plan yang digunakan MySQL.

Sebelum kita mulai benchmark, ada satu hal penting yang perlu dipahami. Kita sudah menerapkan index di bagian sebelumnya, jadi untuk melihat perbedaan performa, kita perlu membandingkan dengan kondisi tanpa index. Caranya adalah dengan membuat migration untuk menghapus index sementara, menjalankan benchmark, kemudian mengembalikan index dan menjalankan benchmark lagi.

Mari kita buat Artisan command untuk benchmark. Jalankan perintah berikut.

php artisan make:command BenchmarkQueries

Buka file app/Console/Commands/BenchmarkQueries.php dan isi dengan kode berikut.

<?php

namespace App\\Console\\Commands;

use App\\Models\\Announcement;
use App\\Models\\Document;
use App\\Models\\Family;
use App\\Models\\Villager;
use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\DB;

class BenchmarkQueries extends Command
{
    protected $signature = 'benchmark:queries {--iterations=10 : Number of iterations for each query}';

    protected $description = 'Benchmark database queries to measure index performance';

    private array $results = [];

    public function handle(): int
    {
        $iterations = (int) $this->option('iterations');

        $this->info('');
        $this->info('╔════════════════════════════════════════════════════════════╗');
        $this->info('║         DATABASE QUERY BENCHMARK - DESA DIGITAL            ║');
        $this->info('╠════════════════════════════════════════════════════════════╣');
        $this->info("║  Iterations per query: {$iterations}                                   ║");
        $this->info("║  Total villagers: " . str_pad(number_format(Villager::count()), 8) . "                             ║");
        $this->info("║  Total documents: " . str_pad(number_format(Document::count()), 8) . "                             ║");
        $this->info('╚════════════════════════════════════════════════════════════╝');
        $this->info('');

        // Ambil sample data untuk testing
        $sampleNik = Villager::inRandomOrder()->first()?->nik ?? '3201234567890123';
        $sampleFamilyCard = Family::inRandomOrder()->first()?->family_card_number ?? '3201234567890123';

        $this->info("Sample NIK: {$sampleNik}");
        $this->info("Sample Family Card: {$sampleFamilyCard}");
        $this->info('');

        // Jalankan benchmark untuk setiap skenario
        $this->runBenchmark(
            'Pencarian Warga berdasarkan NIK (Unique Index)',
            fn() => Villager::where('nik', $sampleNik)->first(),
            $iterations
        );

        $this->runBenchmark(
            'Pencarian Warga berdasarkan Nama (LIKE query)',
            fn() => Villager::where('name', 'like', '%Ahmad%')->get(),
            $iterations
        );

        $this->runBenchmark(
            'Pencarian Keluarga berdasarkan Nomor KK',
            fn() => Family::where('family_card_number', $sampleFamilyCard)->first(),
            $iterations
        );

        $this->runBenchmark(
            'Filter Dokumen berdasarkan Status (pending)',
            fn() => Document::where('status', 'pending')->count(),
            $iterations
        );

        $this->runBenchmark(
            'Filter Dokumen berdasarkan Status (completed)',
            fn() => Document::where('status', 'completed')->get(),
            $iterations
        );

        $this->runBenchmark(
            'Filter Warga: Gender + Marital Status (Composite)',
            fn() => Villager::where('gender', 'male')->where('marital_status', 'Kawin')->get(),
            $iterations
        );

        $this->runBenchmark(
            'Filter Warga: Hanya Marital Status',
            fn() => Villager::where('marital_status', 'Belum Kawin')->get(),
            $iterations
        );

        $this->runBenchmark(
            'Filter Keluarga berdasarkan RT dan RW (Composite)',
            fn() => Family::where('rt', '001')->where('rw', '001')->get(),
            $iterations
        );

        $this->runBenchmark(
            'Dokumen dengan Eager Loading (villager + documentType)',
            fn() => Document::with(['villager', 'documentType'])->where('status', 'pending')->limit(100)->get(),
            $iterations
        );

        $this->runBenchmark(
            'Pengumuman Published dengan Order By',
            fn() => Announcement::where('is_published', true)->whereNotNull('published_at')->orderBy('published_at', 'desc')->get(),
            $iterations
        );

        $this->runBenchmark(
            'Count Warga berdasarkan Pekerjaan (GROUP BY)',
            fn() => Villager::select('occupation', DB::raw('count(*) as total'))->groupBy('occupation')->get(),
            $iterations
        );

        $this->runBenchmark(
            'Warga dengan Sorting berdasarkan Tanggal Lahir',
            fn() => Villager::orderBy('birth_date', 'desc')->limit(100)->get(),
            $iterations
        );

        // Tampilkan hasil dalam tabel
        $this->displayResults();

        // Jalankan EXPLAIN analysis
        $this->info('');
        $this->analyzeWithExplain($sampleNik, $sampleFamilyCard);

        return Command::SUCCESS;
    }

    private function runBenchmark(string $name, callable $query, int $iterations): void
    {
        $this->info("Running: {$name}");
        $bar = $this->output->createProgressBar($iterations);
        $bar->start();

        $times = [];

        // Warm up query (tidak dihitung)
        $query();

        for ($i = 0; $i < $iterations; $i++) {
            // Clear query cache untuk hasil yang akurat
            DB::connection()->flushQueryLog();

            $startTime = microtime(true);
            $result = $query();
            $endTime = microtime(true);

            $times[] = ($endTime - $startTime) * 1000; // Convert to milliseconds
            $bar->advance();
        }

        $bar->finish();
        $this->info('');

        // Hitung statistik
        $avgTime = array_sum($times) / count($times);
        $minTime = min($times);
        $maxTime = max($times);

        $this->results[] = [
            'name' => $name,
            'avg' => round($avgTime, 2),
            'min' => round($minTime, 2),
            'max' => round($maxTime, 2),
        ];
    }

    private function displayResults(): void
    {
        $this->info('');
        $this->info('╔══════════════════════════════════════════════════════════════════════════════╗');
        $this->info('║                           BENCHMARK RESULTS                                  ║');
        $this->info('╠══════════════════════════════════════════════════════════════════════════════╣');

        $this->table(
            ['Query', 'Avg (ms)', 'Min (ms)', 'Max (ms)', 'Status'],
            collect($this->results)->map(function ($result) {
                $status = $result['avg'] < 10 ? '✓ Excellent' :
                         ($result['avg'] < 50 ? '● Good' :
                         ($result['avg'] < 100 ? '◐ Moderate' : '✗ Slow'));

                return [
                    substr($result['name'], 0, 45) . (strlen($result['name']) > 45 ? '...' : ''),
                    $result['avg'],
                    $result['min'],
                    $result['max'],
                    $status,
                ];
            })->toArray()
        );

        $this->info('');
        $this->info('Legend: ✓ Excellent (<10ms) | ● Good (<50ms) | ◐ Moderate (<100ms) | ✗ Slow (≥100ms)');
    }

    private function analyzeWithExplain(string $sampleNik, string $sampleFamilyCard): void
    {
        $this->info('');
        $this->info('╔══════════════════════════════════════════════════════════════════════════════╗');
        $this->info('║                         EXPLAIN ANALYSIS                                     ║');
        $this->info('╚══════════════════════════════════════════════════════════════════════════════╝');
        $this->info('');

        $queries = [
            [
                'name' => 'Pencarian NIK (Unique Index)',
                'sql' => "EXPLAIN SELECT * FROM villagers WHERE nik = '{$sampleNik}'"
            ],
            [
                'name' => 'Pencarian Nama (Regular Index)',
                'sql' => "EXPLAIN SELECT * FROM villagers WHERE name LIKE '%Ahmad%'"
            ],
            [
                'name' => 'Filter Status Dokumen',
                'sql' => "EXPLAIN SELECT * FROM documents WHERE status = 'pending'"
            ],
            [
                'name' => 'Composite Index (gender + marital_status)',
                'sql' => "EXPLAIN SELECT * FROM villagers WHERE gender = 'male' AND marital_status = 'Kawin'"
            ],
            [
                'name' => 'Hanya Marital Status (tanpa gender)',
                'sql' => "EXPLAIN SELECT * FROM villagers WHERE marital_status = 'Belum Kawin'"
            ],
            [
                'name' => 'Composite Index RT + RW',
                'sql' => "EXPLAIN SELECT * FROM families WHERE rt = '001' AND rw = '001'"
            ],
        ];

        foreach ($queries as $query) {
            $this->info("► {$query['name']}");
            $this->info("  SQL: " . str_replace('EXPLAIN ', '', $query['sql']));

            $result = DB::select($query['sql']);

            if (!empty($result)) {
                $explain = $result[0];

                $type = $explain->type ?? 'N/A';
                $possibleKeys = $explain->possible_keys ?? 'NULL';
                $key = $explain->key ?? 'NULL';
                $rows = $explain->rows ?? 'N/A';
                $extra = $explain->Extra ?? '';

                // Interpretasi type
                $typeInterpretation = match($type) {
                    'const', 'eq_ref' => '(Excellent - Direct lookup)',
                    'ref' => '(Good - Index lookup)',
                    'range' => '(Good - Index range scan)',
                    'index' => '(Moderate - Full index scan)',
                    'ALL' => '(Poor - Full table scan!)',
                    default => ''
                };

                $this->info("  ┌─────────────────────────────────────────────────────");
                $this->info("  │ Type         : {$type} {$typeInterpretation}");
                $this->info("  │ Possible Keys: {$possibleKeys}");
                $this->info("  │ Key Used     : {$key}");
                $this->info("  │ Rows Examined: {$rows}");
                if ($extra) {
                    $this->info("  │ Extra        : {$extra}");
                }
                $this->info("  └─────────────────────────────────────────────────────");
            }

            $this->info('');
        }

        $this->info('');
        $this->info('EXPLAIN Type Reference (Best to Worst):');
        $this->info('  1. system/const : Single row lookup (primary/unique key)');
        $this->info('  2. eq_ref       : One row per combination (JOIN with unique key)');
        $this->info('  3. ref          : Multiple rows via index lookup');
        $this->info('  4. range        : Index range scan (BETWEEN, <, >, IN)');
        $this->info('  5. index        : Full index scan (better than ALL)');
        $this->info('  6. ALL          : Full table scan (worst - no index used!)');
    }
}

Command ini melakukan banyak hal yang perlu saya jelaskan secara detail.

Method handle() adalah entry point yang dipanggil saat command dijalankan. Di awal, command menampilkan informasi tentang jumlah data yang ada di database untuk memberikan konteks. Kemudian mengambil sample NIK dan nomor KK secara random dari database untuk digunakan dalam benchmark pencarian.

Method runBenchmark() adalah inti dari proses benchmark. Method ini menerima nama benchmark, callable query, dan jumlah iterasi. Pertama, method menjalankan query sekali sebagai warm up untuk menghindari cold cache yang bisa mempengaruhi hasil. Kemudian query dijalankan sebanyak iterasi yang ditentukan, setiap kali mencatat waktu eksekusi menggunakan microtime(true). Hasil akhir menghitung rata-rata, minimum, dan maksimum waktu eksekusi.

Setiap benchmark memiliki tujuan spesifik untuk menguji jenis index tertentu. Benchmark pencarian NIK menguji unique index. Benchmark pencarian nama dengan LIKE menguji regular index pada kolom name. Benchmark filter gender dan marital_status menguji composite index. Yang menarik adalah benchmark yang hanya filter marital_status tanpa gender, ini akan menunjukkan bahwa composite index tidak efektif jika kolom pertama tidak digunakan dalam WHERE clause.

Method analyzeWithExplain() menjalankan EXPLAIN pada berbagai query dan menampilkan hasilnya dengan interpretasi yang mudah dipahami. Kolom type dari EXPLAIN adalah indikator utama efisiensi query. Nilai const atau eq_ref menunjukkan query sangat efisien dengan direct lookup. Nilai ref menunjukkan index digunakan untuk mencari multiple rows. Nilai ALL adalah yang terburuk karena menandakan full table scan tanpa index.

Sekarang jalankan benchmark dengan perintah berikut.

php artisan benchmark:queries --iterations=10

Output akan menampilkan progress setiap benchmark dan hasil akhir dalam format tabel yang mudah dibaca. Kalian akan melihat waktu rata-rata, minimum, dan maksimum untuk setiap query, serta status performa dari Excellent hingga Slow.

Untuk melihat perbedaan dramatis antara dengan dan tanpa index, mari kita buat migration untuk menghapus index sementara.

php artisan make:migration drop_indexes_for_benchmark

Buka file migration yang baru dibuat dan isi dengan kode berikut.

<?php

use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;

return new class extends Migration
{
    public function up(): void
    {
        // Hapus index dari tabel villagers
        Schema::table('villagers', function (Blueprint $table) {
            $table->dropIndex('idx_villagers_name');
            $table->dropIndex('idx_villagers_birth_date');
            $table->dropIndex('idx_villagers_gender_marital');
            $table->dropIndex('idx_villagers_occupation');
        });

        // Hapus index dari tabel documents
        Schema::table('documents', function (Blueprint $table) {
            $table->dropIndex('idx_documents_status');
            $table->dropIndex('idx_documents_villager_doctype');
            $table->dropIndex('idx_documents_created_at');
            $table->dropIndex('idx_documents_status_created');
        });

        // Hapus index dari tabel announcements
        Schema::table('announcements', function (Blueprint $table) {
            $table->dropIndex('idx_announcements_published');
            $table->dropIndex('idx_announcements_category');
        });

        // Hapus index dari tabel families
        Schema::table('families', function (Blueprint $table) {
            $table->dropIndex('idx_families_head');
            $table->dropIndex('idx_families_rt_rw');
        });
    }

    public function down(): void
    {
        // Kembalikan index ke tabel villagers
        Schema::table('villagers', function (Blueprint $table) {
            $table->index('name', 'idx_villagers_name');
            $table->index('birth_date', 'idx_villagers_birth_date');
            $table->index(['gender', 'marital_status'], 'idx_villagers_gender_marital');
            $table->index('occupation', 'idx_villagers_occupation');
        });

        // Kembalikan index ke tabel documents
        Schema::table('documents', function (Blueprint $table) {
            $table->index('status', 'idx_documents_status');
            $table->index(['villager_id', 'document_type_id'], 'idx_documents_villager_doctype');
            $table->index('created_at', 'idx_documents_created_at');
            $table->index(['status', 'created_at'], 'idx_documents_status_created');
        });

        // Kembalikan index ke tabel announcements
        Schema::table('announcements', function (Blueprint $table) {
            $table->index(['is_published', 'published_at'], 'idx_announcements_published');
            $table->index('category', 'idx_announcements_category');
        });

        // Kembalikan index ke tabel families
        Schema::table('families', function (Blueprint $table) {
            $table->index('head_of_family', 'idx_families_head');
            $table->index(['rt', 'rw'], 'idx_families_rt_rw');
        });
    }
};

Migration ini memiliki method up() yang menghapus semua index dan method down() yang mengembalikan semua index. Dengan design seperti ini, kita bisa dengan mudah toggle index on/off menggunakan perintah migrate dan rollback.

Sekarang mari kita lakukan benchmark perbandingan. Pertama, jalankan benchmark dengan index yang sudah ada.

php artisan benchmark:queries --iterations=10

Catat hasilnya. Kemudian hapus index dengan menjalankan migration.

php artisan migrate

Jalankan benchmark lagi tanpa index.

php artisan benchmark:queries --iterations=10

Catat hasilnya untuk dibandingkan. Kalian akan melihat perbedaan waktu yang sangat signifikan, terutama pada query yang melibatkan filter status dokumen dan composite filter. Query yang tadinya hanya memakan waktu beberapa milidetik bisa melonjak menjadi ratusan milidetik.

Setelah selesai, kembalikan index dengan rollback migration.

php artisan migrate:rollback

Untuk memudahkan perbandingan, saya akan tunjukkan contoh hasil benchmark yang realistis berdasarkan data 5.000 warga dan 10.000 dokumen.

╔══════════════════════════════════════════════════════════════════════════════╗
║                    BENCHMARK COMPARISON RESULTS                              ║
╠══════════════════════════════════════════════════════════════════════════════╣
║ Query                                    │ Without Index │ With Index │ Gain ║
╠══════════════════════════════════════════════════════════════════════════════╣
║ Pencarian NIK (Unique)                   │    45.23 ms   │   1.85 ms  │ 96%  ║
║ Pencarian Nama (LIKE)                    │   156.78 ms   │  12.34 ms  │ 92%  ║
║ Filter Status Dokumen (pending)          │   234.56 ms   │   5.67 ms  │ 98%  ║
║ Filter Status Dokumen (completed)        │   289.12 ms   │   8.91 ms  │ 97%  ║
║ Composite: Gender + Marital Status       │   198.45 ms   │   7.23 ms  │ 96%  ║
║ Single: Hanya Marital Status             │   187.34 ms   │ 165.23 ms  │ 12%  ║
║ Composite: RT + RW                       │    67.89 ms   │   3.45 ms  │ 95%  ║
║ Eager Loading dengan Filter              │   312.45 ms   │  15.67 ms  │ 95%  ║
║ Pengumuman Published + Order By          │    23.45 ms   │   2.34 ms  │ 90%  ║
║ GROUP BY Occupation                      │   178.90 ms   │  45.67 ms  │ 74%  ║
║ ORDER BY Birth Date                      │   145.67 ms   │   8.90 ms  │ 94%  ║
╚══════════════════════════════════════════════════════════════════════════════╝

Perhatikan baris "Single: Hanya Marital Status" yang menunjukkan improvement hanya 12%. Ini membuktikan apa yang sudah saya jelaskan sebelumnya bahwa composite index (gender, marital_status) tidak efektif untuk query yang hanya filter berdasarkan kolom kedua tanpa kolom pertama. MySQL tidak bisa menggunakan composite index kecuali dimulai dari kolom paling kiri.

Sekarang mari kita buat command tambahan untuk menampilkan analisis EXPLAIN yang lebih detail dan interaktif.

php artisan make:command ExplainQuery

<?php

namespace App\\Console\\Commands;

use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\DB;

class ExplainQuery extends Command
{
    protected $signature = 'explain:query {sql : The SQL query to explain}';

    protected $description = 'Run EXPLAIN on a SQL query and display detailed analysis';

    public function handle(): int
    {
        $sql = $this->argument('sql');

        // Pastikan query dimulai dengan SELECT
        if (!str_starts_with(strtoupper(trim($sql)), 'SELECT')) {
            $this->error('Only SELECT queries can be explained.');
            return Command::FAILURE;
        }

        $this->info('');
        $this->info('Analyzing query:');
        $this->info($sql);
        $this->info('');

        try {
            // Jalankan EXPLAIN
            $explainResult = DB::select("EXPLAIN {$sql}");

            // Jalankan EXPLAIN ANALYZE jika MySQL 8.0+
            $analyzeResult = null;
            try {
                $analyzeResult = DB::select("EXPLAIN ANALYZE {$sql}");
            } catch (\\Exception $e) {
                // EXPLAIN ANALYZE tidak tersedia di versi MySQL yang lebih lama
            }

            $this->displayExplainResult($explainResult);

            if ($analyzeResult) {
                $this->info('');
                $this->info('EXPLAIN ANALYZE Result:');
                foreach ($analyzeResult as $row) {
                    $this->info($row->EXPLAIN ?? json_encode($row));
                }
            }

            // Jalankan query sebenarnya untuk mendapat waktu eksekusi
            $this->info('');
            $this->info('Actual Execution:');

            $startTime = microtime(true);
            $result = DB::select($sql);
            $endTime = microtime(true);

            $executionTime = round(($endTime - $startTime) * 1000, 2);
            $rowCount = count($result);

            $this->info("  Rows returned: {$rowCount}");
            $this->info("  Execution time: {$executionTime} ms");

            // Berikan rekomendasi
            $this->info('');
            $this->provideRecommendations($explainResult, $executionTime);

        } catch (\\Exception $e) {
            $this->error('Error: ' . $e->getMessage());
            return Command::FAILURE;
        }

        return Command::SUCCESS;
    }

    private function displayExplainResult(array $result): void
    {
        $this->info('EXPLAIN Result:');
        $this->info('');

        foreach ($result as $index => $row) {
            $this->info("Table #{$index}:");

            $data = [
                ['Property', 'Value', 'Interpretation'],
            ];

            // ID
            $data[] = ['id', $row->id ?? 'N/A', 'Query block identifier'];

            // Select Type
            $selectType = $row->select_type ?? 'N/A';
            $selectInterpretation = match($selectType) {
                'SIMPLE' => 'Simple query without subqueries or unions',
                'PRIMARY' => 'Outermost query in a complex query',
                'SUBQUERY' => 'First SELECT in a subquery',
                'DERIVED' => 'Derived table (subquery in FROM clause)',
                'UNION' => 'Second or later SELECT in a UNION',
                default => ''
            };
            $data[] = ['select_type', $selectType, $selectInterpretation];

            // Table
            $data[] = ['table', $row->table ?? 'N/A', 'Table being accessed'];

            // Type (most important!)
            $type = $row->type ?? 'N/A';
            $typeInterpretation = match($type) {
                'system' => '✓ BEST: Table has only one row',
                'const' => '✓ EXCELLENT: Single row via primary/unique key',
                'eq_ref' => '✓ VERY GOOD: One row per combination (unique key JOIN)',
                'ref' => '● GOOD: Multiple rows via non-unique index',
                'fulltext' => '● GOOD: Fulltext index used',
                'ref_or_null' => '● GOOD: Like ref, but also searches for NULL',
                'index_merge' => '◐ MODERATE: Multiple indexes merged',
                'range' => '◐ MODERATE: Index range scan',
                'index' => '◐ MODERATE: Full index scan',
                'ALL' => '✗ POOR: Full table scan - needs optimization!',
                default => 'Unknown'
            };
            $data[] = ['type', $type, $typeInterpretation];

            // Possible Keys
            $possibleKeys = $row->possible_keys ?? 'NULL';
            $data[] = ['possible_keys', $possibleKeys ?: 'NULL', 'Indexes that could be used'];

            // Key (actually used)
            $key = $row->key ?? 'NULL';
            $keyInterpretation = $key ? "Index '{$key}' is being used" : 'No index used!';
            $data[] = ['key', $key ?: 'NULL', $keyInterpretation];

            // Key Length
            $data[] = ['key_len', $row->key_len ?? 'N/A', 'Length of index used (bytes)'];

            // Ref
            $data[] = ['ref', $row->ref ?? 'N/A', 'Columns compared to index'];

            // Rows
            $rows = $row->rows ?? 'N/A';
            $data[] = ['rows', $rows, 'Estimated rows to examine'];

            // Filtered
            $filtered = $row->filtered ?? 'N/A';
            $data[] = ['filtered', $filtered . '%', 'Percentage of rows filtered by condition'];

            // Extra
            $extra = $row->Extra ?? '';
            $extraInterpretation = $this->interpretExtra($extra);
            $data[] = ['Extra', $extra ?: 'N/A', $extraInterpretation];

            $this->table(['Property', 'Value', 'Interpretation'], array_slice($data, 1));
            $this->info('');
        }
    }

    private function interpretExtra(string $extra): string
    {
        $interpretations = [];

        if (str_contains($extra, 'Using index')) {
            $interpretations[] = '✓ Covering index (data from index only)';
        }
        if (str_contains($extra, 'Using where')) {
            $interpretations[] = 'WHERE clause filters rows';
        }
        if (str_contains($extra, 'Using temporary')) {
            $interpretations[] = '⚠ Temporary table created';
        }
        if (str_contains($extra, 'Using filesort')) {
            $interpretations[] = '⚠ Extra sorting pass needed';
        }
        if (str_contains($extra, 'Using index condition')) {
            $interpretations[] = '✓ Index condition pushdown';
        }

        return implode('; ', $interpretations) ?: 'Standard execution';
    }

    private function provideRecommendations(array $explainResult, float $executionTime): void
    {
        $this->info('Recommendations:');

        $hasIssues = false;

        foreach ($explainResult as $row) {
            $type = $row->type ?? '';
            $key = $row->key ?? null;
            $extra = $row->Extra ?? '';
            $rows = $row->rows ?? 0;

            if ($type === 'ALL') {
                $hasIssues = true;
                $this->warn("  ⚠ Full table scan detected on '{$row->table}'. Consider adding an index.");
            }

            if (!$key && $row->possible_keys) {
                $hasIssues = true;
                $this->warn("  ⚠ Possible indexes exist but none used. Check query structure.");
            }

            if (str_contains($extra, 'Using filesort') && $rows > 1000) {
                $hasIssues = true;
                $this->warn("  ⚠ Filesort on large result set. Consider adding index for ORDER BY column.");
            }

            if (str_contains($extra, 'Using temporary')) {
                $hasIssues = true;
                $this->warn("  ⚠ Temporary table used. May impact performance on large datasets.");
            }
        }

        if ($executionTime > 100) {
            $hasIssues = true;
            $this->warn("  ⚠ Query execution time ({$executionTime}ms) exceeds 100ms threshold.");
        }

        if (!$hasIssues) {
            $this->info("  ✓ Query appears to be well optimized.");
        }
    }
}

Command explain:query ini memberikan analisis mendalam untuk setiap query SQL. Kalian bisa menggunakannya dengan cara berikut.

php artisan explain:query "SELECT * FROM villagers WHERE nik = '3201234567890123'"

php artisan explain:query "SELECT * FROM documents WHERE status = 'pending' ORDER BY created_at"

php artisan explain:query "SELECT * FROM villagers WHERE marital_status = 'Kawin'"

Output akan menampilkan setiap kolom dari EXPLAIN dengan interpretasi yang mudah dipahami, waktu eksekusi aktual, dan rekomendasi jika ada masalah performa yang terdeteksi.

Dengan tools benchmark dan explain yang sudah kita buat, sekarang kalian memiliki kemampuan untuk menganalisis performa query secara mendalam. Di bagian terakhir, kita akan mengimplementasikan Filament Admin Panel dan memanfaatkan index yang sudah dibuat untuk fitur pencarian dan filtering yang responsif.

Bagian 8: Implementasi Filament Admin Panel dan Penutup

Di bagian terakhir ini, kita akan mengimplementasikan admin panel menggunakan Filament untuk mengelola data Website Desa Digital. Yang menarik adalah kita akan melihat bagaimana index yang sudah dibuat memberikan dampak langsung pada fitur pencarian dan filtering di admin panel. Dengan ribuan data warga dan dokumen, perbedaan performa akan sangat terasa.

Mari kita mulai dengan menginstall Filament. Jalankan perintah berikut di terminal.

composer require filament/filament:"^3.2" -W

Flag -W memastikan Composer mengupdate dependensi yang diperlukan. Setelah package terinstall, jalankan perintah instalasi Filament.

php artisan filament:install --panels

Perintah ini akan membuat beberapa file penting. File app/Providers/Filament/AdminPanelProvider.php adalah service provider yang mengkonfigurasi admin panel. File-file di direktori app/Filament akan berisi Resources, Pages, dan Widgets yang kita buat.

Filament membutuhkan user untuk autentikasi. Karena kita belum memiliki user, mari buat satu user admin terlebih dahulu. Tambahkan kode berikut di DatabaseSeeder.php atau jalankan tinker.

php artisan tinker

\\App\\Models\\User::create([
    'name' => 'Admin Desa',
    'email' => '[email protected]',
    'password' => bcrypt('password123'),
]);

Sekarang kita bisa mengakses admin panel di http://localhost:8000/admin dan login dengan kredensial yang baru dibuat.

Mari kita buat Resource untuk mengelola data warga. Resource adalah komponen utama Filament yang menyediakan fitur CRUD lengkap.

php artisan make:filament-resource Villager --generate

Flag --generate akan otomatis membuat form dan table berdasarkan kolom di database. Namun, kita perlu menyesuaikan hasilnya agar lebih optimal dan memanfaatkan index yang sudah dibuat. Buka file app/Filament/Resources/VillagerResource.php dan ubah isinya menjadi seperti berikut.

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\VillagerResource\\Pages;
use App\\Models\\Family;
use App\\Models\\Villager;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Database\\Eloquent\\Builder;

class VillagerResource extends Resource
{
    protected static ?string $model = Villager::class;

    protected static ?string $navigationIcon = 'heroicon-o-users';

    protected static ?string $navigationLabel = 'Data Warga';

    protected static ?string $modelLabel = 'Warga';

    protected static ?string $pluralModelLabel = 'Data Warga';

    protected static ?int $navigationSort = 1;

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\Section::make('Data Pribadi')
                    ->description('Informasi identitas warga')
                    ->schema([
                        Forms\\Components\\Select::make('family_id')
                            ->label('Keluarga (KK)')
                            ->relationship('family', 'family_card_number')
                            ->searchable()
                            ->preload()
                            ->required()
                            ->createOptionForm([
                                Forms\\Components\\TextInput::make('family_card_number')
                                    ->label('Nomor KK')
                                    ->required()
                                    ->maxLength(16),
                                Forms\\Components\\TextInput::make('head_of_family')
                                    ->label('Kepala Keluarga')
                                    ->required(),
                                Forms\\Components\\Textarea::make('address')
                                    ->label('Alamat')
                                    ->required(),
                                Forms\\Components\\TextInput::make('rt')
                                    ->label('RT')
                                    ->required()
                                    ->maxLength(3),
                                Forms\\Components\\TextInput::make('rw')
                                    ->label('RW')
                                    ->required()
                                    ->maxLength(3),
                            ]),
                        Forms\\Components\\TextInput::make('nik')
                            ->label('NIK')
                            ->required()
                            ->maxLength(16)
                            ->unique(ignoreRecord: true),
                        Forms\\Components\\TextInput::make('name')
                            ->label('Nama Lengkap')
                            ->required()
                            ->maxLength(255),
                        Forms\\Components\\TextInput::make('birth_place')
                            ->label('Tempat Lahir')
                            ->required()
                            ->maxLength(255),
                        Forms\\Components\\DatePicker::make('birth_date')
                            ->label('Tanggal Lahir')
                            ->required()
                            ->maxDate(now()),
                    ])->columns(2),

                Forms\\Components\\Section::make('Informasi Tambahan')
                    ->schema([
                        Forms\\Components\\Select::make('gender')
                            ->label('Jenis Kelamin')
                            ->options([
                                'male' => 'Laki-laki',
                                'female' => 'Perempuan',
                            ])
                            ->required(),
                        Forms\\Components\\Select::make('religion')
                            ->label('Agama')
                            ->options([
                                'Islam' => 'Islam',
                                'Kristen' => 'Kristen',
                                'Katolik' => 'Katolik',
                                'Hindu' => 'Hindu',
                                'Buddha' => 'Buddha',
                                'Konghucu' => 'Konghucu',
                            ])
                            ->required(),
                        Forms\\Components\\Select::make('education')
                            ->label('Pendidikan')
                            ->options([
                                'Tidak/Belum Sekolah' => 'Tidak/Belum Sekolah',
                                'SD/Sederajat' => 'SD/Sederajat',
                                'SMP/Sederajat' => 'SMP/Sederajat',
                                'SMA/Sederajat' => 'SMA/Sederajat',
                                'D1' => 'D1',
                                'D2' => 'D2',
                                'D3' => 'D3',
                                'D4/S1' => 'D4/S1',
                                'S2' => 'S2',
                                'S3' => 'S3',
                            ])
                            ->required(),
                        Forms\\Components\\TextInput::make('occupation')
                            ->label('Pekerjaan')
                            ->required()
                            ->maxLength(255),
                        Forms\\Components\\Select::make('marital_status')
                            ->label('Status Perkawinan')
                            ->options([
                                'Belum Kawin' => 'Belum Kawin',
                                'Kawin' => 'Kawin',
                                'Cerai Hidup' => 'Cerai Hidup',
                                'Cerai Mati' => 'Cerai Mati',
                            ])
                            ->required(),
                        Forms\\Components\\Select::make('blood_type')
                            ->label('Golongan Darah')
                            ->options([
                                'A' => 'A',
                                'B' => 'B',
                                'AB' => 'AB',
                                'O' => 'O',
                            ]),
                        Forms\\Components\\Toggle::make('is_head_of_family')
                            ->label('Kepala Keluarga')
                            ->default(false),
                        Forms\\Components\\TextInput::make('phone')
                            ->label('No. Telepon')
                            ->tel()
                            ->maxLength(15),
                    ])->columns(2),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\TextColumn::make('nik')
                    ->label('NIK')
                    ->searchable()  // Memanfaatkan unique index pada NIK
                    ->sortable()
                    ->copyable()
                    ->copyMessage('NIK berhasil disalin'),
                Tables\\Columns\\TextColumn::make('name')
                    ->label('Nama')
                    ->searchable()  // Memanfaatkan index idx_villagers_name
                    ->sortable()
                    ->weight('bold'),
                Tables\\Columns\\TextColumn::make('family.family_card_number')
                    ->label('No. KK')
                    ->searchable()  // Memanfaatkan unique index pada family_card_number
                    ->sortable()
                    ->toggleable(),
                Tables\\Columns\\TextColumn::make('gender')
                    ->label('L/P')
                    ->badge()
                    ->formatStateUsing(fn (string $state): string => $state === 'male' ? 'L' : 'P')
                    ->color(fn (string $state): string => $state === 'male' ? 'info' : 'danger'),
                Tables\\Columns\\TextColumn::make('birth_place')
                    ->label('Tempat Lahir')
                    ->toggleable(isToggledHiddenByDefault: true),
                Tables\\Columns\\TextColumn::make('birth_date')
                    ->label('Tanggal Lahir')
                    ->date('d M Y')
                    ->sortable(),  // Memanfaatkan index idx_villagers_birth_date
                Tables\\Columns\\TextColumn::make('age')
                    ->label('Umur')
                    ->suffix(' tahun')
                    ->sortable(query: function (Builder $query, string $direction): Builder {
                        return $query->orderBy('birth_date', $direction === 'asc' ? 'desc' : 'asc');
                    }),
                Tables\\Columns\\TextColumn::make('occupation')
                    ->label('Pekerjaan')
                    ->searchable()  // Memanfaatkan index idx_villagers_occupation
                    ->toggleable(),
                Tables\\Columns\\TextColumn::make('marital_status')
                    ->label('Status')
                    ->badge()
                    ->color(fn (string $state): string => match ($state) {
                        'Belum Kawin' => 'gray',
                        'Kawin' => 'success',
                        'Cerai Hidup' => 'warning',
                        'Cerai Mati' => 'danger',
                        default => 'gray',
                    }),
                Tables\\Columns\\IconColumn::make('is_head_of_family')
                    ->label('KK')
                    ->boolean()
                    ->toggleable(),
                Tables\\Columns\\TextColumn::make('created_at')
                    ->label('Terdaftar')
                    ->dateTime('d M Y H:i')
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
            ])
            ->filters([
                // Filter gender - memanfaatkan composite index idx_villagers_gender_marital
                Tables\\Filters\\SelectFilter::make('gender')
                    ->label('Jenis Kelamin')
                    ->options([
                        'male' => 'Laki-laki',
                        'female' => 'Perempuan',
                    ]),
                // Filter marital_status - bagian dari composite index
                Tables\\Filters\\SelectFilter::make('marital_status')
                    ->label('Status Perkawinan')
                    ->options([
                        'Belum Kawin' => 'Belum Kawin',
                        'Kawin' => 'Kawin',
                        'Cerai Hidup' => 'Cerai Hidup',
                        'Cerai Mati' => 'Cerai Mati',
                    ]),
                // Filter kombinasi gender + marital_status untuk memanfaatkan composite index secara optimal
                Tables\\Filters\\Filter::make('demographic')
                    ->form([
                        Forms\\Components\\Select::make('gender')
                            ->label('Jenis Kelamin')
                            ->options([
                                'male' => 'Laki-laki',
                                'female' => 'Perempuan',
                            ]),
                        Forms\\Components\\Select::make('marital_status')
                            ->label('Status Perkawinan')
                            ->options([
                                'Belum Kawin' => 'Belum Kawin',
                                'Kawin' => 'Kawin',
                                'Cerai Hidup' => 'Cerai Hidup',
                                'Cerai Mati' => 'Cerai Mati',
                            ]),
                    ])
                    ->query(function (Builder $query, array $data): Builder {
                        return $query
                            ->when(
                                $data['gender'],
                                fn (Builder $query, $gender): Builder => $query->where('gender', $gender),
                            )
                            ->when(
                                $data['marital_status'],
                                fn (Builder $query, $status): Builder => $query->where('marital_status', $status),
                            );
                    })
                    ->indicateUsing(function (array $data): array {
                        $indicators = [];
                        if ($data['gender'] ?? null) {
                            $indicators['gender'] = 'Gender: ' . ($data['gender'] === 'male' ? 'Laki-laki' : 'Perempuan');
                        }
                        if ($data['marital_status'] ?? null) {
                            $indicators['marital_status'] = 'Status: ' . $data['marital_status'];
                        }
                        return $indicators;
                    }),
                // Filter kepala keluarga
                Tables\\Filters\\TernaryFilter::make('is_head_of_family')
                    ->label('Kepala Keluarga')
                    ->placeholder('Semua')
                    ->trueLabel('Hanya Kepala Keluarga')
                    ->falseLabel('Bukan Kepala Keluarga'),
            ])
            ->actions([
                Tables\\Actions\\ViewAction::make(),
                Tables\\Actions\\EditAction::make(),
                Tables\\Actions\\DeleteAction::make(),
            ])
            ->bulkActions([
                Tables\\Actions\\BulkActionGroup::make([
                    Tables\\Actions\\DeleteBulkAction::make(),
                ]),
            ])
            ->defaultSort('created_at', 'desc')
            ->striped()
            ->paginated([10, 25, 50, 100]);
    }

    public static function getRelations(): array
    {
        return [
            //
        ];
    }

    public static function getPages(): array
    {
        return [
            'index' => Pages\\ListVillagers::route('/'),
            'create' => Pages\\CreateVillager::route('/create'),
            'edit' => Pages\\EditVillager::route('/{record}/edit'),
        ];
    }

    public static function getNavigationBadge(): ?string
    {
        return static::getModel()::count();
    }

    public static function getNavigationBadgeColor(): ?string
    {
        return 'primary';
    }
}

Saya ingin menjelaskan beberapa bagian penting dari Resource ini yang berkaitan dengan optimasi index.

Pada bagian table(), perhatikan method searchable() yang diterapkan pada kolom nik, name, family.family_card_number, dan occupation. Setiap kolom ini memiliki index yang sudah kita buat sebelumnya. Ketika user mengetik di kolom pencarian, Filament akan menjalankan query dengan WHERE clause yang memanfaatkan index tersebut. Tanpa index, pencarian pada 5.000 data warga akan terasa lambat. Dengan index, hasilnya muncul hampir instan.

Filter demographic adalah contoh menarik bagaimana kita bisa memanfaatkan composite index idx_villagers_gender_marital. Filter ini memungkinkan user memilih gender dan marital_status secara bersamaan. Ketika kedua filter digunakan bersama dengan gender dipilih terlebih dahulu, MySQL akan menggunakan composite index secara optimal. Ini jauh lebih efisien dibanding dua filter terpisah yang masing-masing menjalankan query sendiri.

Sekarang mari kita buat Resource untuk Document.

php artisan make:filament-resource Document --generate

Buka file app/Filament/Resources/DocumentResource.php dan ubah isinya.

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\DocumentResource\\Pages;
use App\\Models\\Document;
use App\\Models\\DocumentType;
use App\\Models\\Villager;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Database\\Eloquent\\Builder;

class DocumentResource extends Resource
{
    protected static ?string $model = Document::class;

    protected static ?string $navigationIcon = 'heroicon-o-document-text';

    protected static ?string $navigationLabel = 'Pengajuan Surat';

    protected static ?string $modelLabel = 'Surat';

    protected static ?string $pluralModelLabel = 'Pengajuan Surat';

    protected static ?int $navigationSort = 2;

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\Section::make('Informasi Pengajuan')
                    ->schema([
                        Forms\\Components\\Select::make('villager_id')
                            ->label('Pemohon')
                            ->relationship('villager', 'name')
                            ->searchable(['name', 'nik'])  // Pencarian memanfaatkan index
                            ->preload()
                            ->required()
                            ->getOptionLabelFromRecordUsing(fn (Villager $record) => "{$record->name} ({$record->nik})"),
                        Forms\\Components\\Select::make('document_type_id')
                            ->label('Jenis Surat')
                            ->relationship('documentType', 'name')
                            ->searchable()
                            ->preload()
                            ->required()
                            ->reactive()
                            ->afterStateUpdated(function ($state, Forms\\Set $set) {
                                if ($state) {
                                    $docType = DocumentType::find($state);
                                    if ($docType && $docType->requirements) {
                                        $set('requirements_info', implode(', ', $docType->requirements));
                                    }
                                }
                            }),
                        Forms\\Components\\Placeholder::make('requirements_info')
                            ->label('Persyaratan')
                            ->content(fn ($get) => $get('requirements_info') ?? 'Pilih jenis surat untuk melihat persyaratan'),
                        Forms\\Components\\Textarea::make('purpose')
                            ->label('Keperluan')
                            ->required()
                            ->rows(3)
                            ->maxLength(1000),
                    ])->columns(2),

                Forms\\Components\\Section::make('Status Pengajuan')
                    ->schema([
                        Forms\\Components\\Select::make('status')
                            ->label('Status')
                            ->options([
                                'pending' => 'Menunggu',
                                'processing' => 'Diproses',
                                'completed' => 'Selesai',
                                'rejected' => 'Ditolak',
                            ])
                            ->required()
                            ->default('pending')
                            ->reactive(),
                        Forms\\Components\\TextInput::make('document_number')
                            ->label('Nomor Surat')
                            ->maxLength(255)
                            ->visible(fn ($get) => $get('status') === 'completed'),
                        Forms\\Components\\Textarea::make('notes')
                            ->label('Catatan')
                            ->rows(2)
                            ->maxLength(500),
                        Forms\\Components\\DateTimePicker::make('completed_at')
                            ->label('Tanggal Selesai')
                            ->visible(fn ($get) => $get('status') === 'completed'),
                    ])->columns(2),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\TextColumn::make('id')
                    ->label('ID')
                    ->sortable(),
                Tables\\Columns\\TextColumn::make('villager.name')
                    ->label('Pemohon')
                    ->searchable()  // Memanfaatkan index melalui relasi
                    ->sortable()
                    ->description(fn (Document $record): string => $record->villager?->nik ?? ''),
                Tables\\Columns\\TextColumn::make('documentType.name')
                    ->label('Jenis Surat')
                    ->searchable()
                    ->sortable()
                    ->badge()
                    ->color('gray'),
                Tables\\Columns\\TextColumn::make('purpose')
                    ->label('Keperluan')
                    ->limit(30)
                    ->tooltip(fn (Document $record): string => $record->purpose)
                    ->toggleable(),
                Tables\\Columns\\TextColumn::make('status')
                    ->label('Status')
                    ->badge()
                    ->color(fn (string $state): string => match ($state) {
                        'pending' => 'warning',
                        'processing' => 'info',
                        'completed' => 'success',
                        'rejected' => 'danger',
                        default => 'gray',
                    })
                    ->formatStateUsing(fn (string $state): string => match ($state) {
                        'pending' => 'Menunggu',
                        'processing' => 'Diproses',
                        'completed' => 'Selesai',
                        'rejected' => 'Ditolak',
                        default => $state,
                    }),
                Tables\\Columns\\TextColumn::make('document_number')
                    ->label('No. Surat')
                    ->placeholder('-')
                    ->toggleable(),
                Tables\\Columns\\TextColumn::make('created_at')
                    ->label('Tanggal Pengajuan')
                    ->dateTime('d M Y H:i')
                    ->sortable(),  // Memanfaatkan index idx_documents_created_at
                Tables\\Columns\\TextColumn::make('completed_at')
                    ->label('Tanggal Selesai')
                    ->dateTime('d M Y H:i')
                    ->placeholder('-')
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
            ])
            ->filters([
                // Filter status - memanfaatkan index idx_documents_status
                Tables\\Filters\\SelectFilter::make('status')
                    ->label('Status')
                    ->options([
                        'pending' => 'Menunggu',
                        'processing' => 'Diproses',
                        'completed' => 'Selesai',
                        'rejected' => 'Ditolak',
                    ])
                    ->multiple(),
                // Filter jenis dokumen
                Tables\\Filters\\SelectFilter::make('document_type_id')
                    ->label('Jenis Surat')
                    ->relationship('documentType', 'name')
                    ->searchable()
                    ->preload()
                    ->multiple(),
                // Filter berdasarkan tanggal - memanfaatkan index idx_documents_created_at
                Tables\\Filters\\Filter::make('created_at')
                    ->form([
                        Forms\\Components\\DatePicker::make('from')
                            ->label('Dari Tanggal'),
                        Forms\\Components\\DatePicker::make('until')
                            ->label('Sampai Tanggal'),
                    ])
                    ->query(function (Builder $query, array $data): Builder {
                        return $query
                            ->when(
                                $data['from'],
                                fn (Builder $query, $date): Builder => $query->whereDate('created_at', '>=', $date),
                            )
                            ->when(
                                $data['until'],
                                fn (Builder $query, $date): Builder => $query->whereDate('created_at', '<=', $date),
                            );
                    })
                    ->indicateUsing(function (array $data): array {
                        $indicators = [];
                        if ($data['from'] ?? null) {
                            $indicators['from'] = 'Dari: ' . \\Carbon\\Carbon::parse($data['from'])->format('d M Y');
                        }
                        if ($data['until'] ?? null) {
                            $indicators['until'] = 'Sampai: ' . \\Carbon\\Carbon::parse($data['until'])->format('d M Y');
                        }
                        return $indicators;
                    }),
            ])
            ->actions([
                Tables\\Actions\\Action::make('process')
                    ->label('Proses')
                    ->icon('heroicon-o-arrow-path')
                    ->color('info')
                    ->visible(fn (Document $record): bool => $record->status === 'pending')
                    ->action(fn (Document $record) => $record->update(['status' => 'processing'])),
                Tables\\Actions\\Action::make('complete')
                    ->label('Selesai')
                    ->icon('heroicon-o-check-circle')
                    ->color('success')
                    ->visible(fn (Document $record): bool => in_array($record->status, ['pending', 'processing']))
                    ->form([
                        Forms\\Components\\TextInput::make('document_number')
                            ->label('Nomor Surat')
                            ->required(),
                    ])
                    ->action(function (Document $record, array $data): void {
                        $record->update([
                            'status' => 'completed',
                            'document_number' => $data['document_number'],
                            'completed_at' => now(),
                        ]);
                    }),
                Tables\\Actions\\Action::make('reject')
                    ->label('Tolak')
                    ->icon('heroicon-o-x-circle')
                    ->color('danger')
                    ->visible(fn (Document $record): bool => in_array($record->status, ['pending', 'processing']))
                    ->requiresConfirmation()
                    ->form([
                        Forms\\Components\\Textarea::make('notes')
                            ->label('Alasan Penolakan')
                            ->required(),
                    ])
                    ->action(function (Document $record, array $data): void {
                        $record->update([
                            'status' => 'rejected',
                            'notes' => $data['notes'],
                        ]);
                    }),
                Tables\\Actions\\ViewAction::make(),
                Tables\\Actions\\EditAction::make(),
            ])
            ->bulkActions([
                Tables\\Actions\\BulkActionGroup::make([
                    Tables\\Actions\\DeleteBulkAction::make(),
                ]),
            ])
            ->defaultSort('created_at', 'desc')
            ->striped()
            ->poll('30s')  // Auto refresh setiap 30 detik
            ->paginated([10, 25, 50, 100]);
    }

    public static function getRelations(): array
    {
        return [
            //
        ];
    }

    public static function getPages(): array
    {
        return [
            'index' => Pages\\ListDocuments::route('/'),
            'create' => Pages\\CreateDocument::route('/create'),
            'edit' => Pages\\EditDocument::route('/{record}/edit'),
        ];
    }

    public static function getNavigationBadge(): ?string
    {
        // Query ini memanfaatkan index idx_documents_status
        return static::getModel()::where('status', 'pending')->count();
    }

    public static function getNavigationBadgeColor(): ?string
    {
        $pendingCount = static::getModel()::where('status', 'pending')->count();
        return $pendingCount > 10 ? 'danger' : 'warning';
    }
}

Pada DocumentResource, perhatikan method getNavigationBadge() yang menampilkan jumlah dokumen pending di navigation menu. Query where('status', 'pending')->count() ini dijalankan setiap kali halaman admin diakses. Dengan index idx_documents_status, query ini berjalan sangat cepat meskipun ada 10.000 dokumen di database.

Filter tanggal menggunakan whereDate('created_at', '>=', $date) yang memanfaatkan index idx_documents_created_at. Ini sangat berguna untuk mencari pengajuan dalam rentang waktu tertentu.

Sekarang mari kita buat Resource untuk Announcement.

php artisan make:filament-resource Announcement --generate

<?php

namespace App\\Filament\\Resources;

use App\\Filament\\Resources\\AnnouncementResource\\Pages;
use App\\Models\\Announcement;
use Filament\\Forms;
use Filament\\Forms\\Form;
use Filament\\Resources\\Resource;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Illuminate\\Database\\Eloquent\\Builder;
use Illuminate\\Support\\Str;

class AnnouncementResource extends Resource
{
    protected static ?string $model = Announcement::class;

    protected static ?string $navigationIcon = 'heroicon-o-megaphone';

    protected static ?string $navigationLabel = 'Pengumuman';

    protected static ?string $modelLabel = 'Pengumuman';

    protected static ?string $pluralModelLabel = 'Pengumuman';

    protected static ?int $navigationSort = 3;

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\\Components\\Section::make('Konten Pengumuman')
                    ->schema([
                        Forms\\Components\\TextInput::make('title')
                            ->label('Judul')
                            ->required()
                            ->maxLength(255)
                            ->live(onBlur: true)
                            ->afterStateUpdated(function (string $state, Forms\\Set $set) {
                                $set('slug', Str::slug($state));
                            }),
                        Forms\\Components\\TextInput::make('slug')
                            ->label('Slug')
                            ->required()
                            ->maxLength(255)
                            ->unique(ignoreRecord: true),
                        Forms\\Components\\Select::make('category')
                            ->label('Kategori')
                            ->options([
                                'Umum' => 'Umum',
                                'Kesehatan' => 'Kesehatan',
                                'Pendidikan' => 'Pendidikan',
                                'Sosial' => 'Sosial',
                                'Keamanan' => 'Keamanan',
                                'Lingkungan' => 'Lingkungan',
                                'Pembangunan' => 'Pembangunan',
                                'Kegiatan' => 'Kegiatan',
                                'Bantuan' => 'Bantuan',
                                'Informasi' => 'Informasi',
                            ])
                            ->searchable(),
                        Forms\\Components\\RichEditor::make('content')
                            ->label('Isi Pengumuman')
                            ->required()
                            ->columnSpanFull(),
                    ])->columns(2),

                Forms\\Components\\Section::make('Pengaturan Publikasi')
                    ->schema([
                        Forms\\Components\\Toggle::make('is_published')
                            ->label('Publikasikan')
                            ->default(false)
                            ->reactive(),
                        Forms\\Components\\DateTimePicker::make('published_at')
                            ->label('Tanggal Publikasi')
                            ->visible(fn ($get) => $get('is_published'))
                            ->default(now()),
                    ])->columns(2),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\\Columns\\TextColumn::make('title')
                    ->label('Judul')
                    ->searchable()
                    ->sortable()
                    ->limit(40)
                    ->tooltip(fn (Announcement $record): string => $record->title),
                Tables\\Columns\\TextColumn::make('category')
                    ->label('Kategori')
                    ->badge()
                    ->searchable()  // Memanfaatkan index idx_announcements_category
                    ->sortable(),
                Tables\\Columns\\IconColumn::make('is_published')
                    ->label('Status')
                    ->boolean()
                    ->trueIcon('heroicon-o-check-circle')
                    ->falseIcon('heroicon-o-clock')
                    ->trueColor('success')
                    ->falseColor('warning'),
                Tables\\Columns\\TextColumn::make('published_at')
                    ->label('Tanggal Publikasi')
                    ->dateTime('d M Y H:i')
                    ->sortable()  // Memanfaatkan composite index idx_announcements_published
                    ->placeholder('Belum dipublikasi'),
                Tables\\Columns\\TextColumn::make('created_at')
                    ->label('Dibuat')
                    ->dateTime('d M Y')
                    ->sortable()
                    ->toggleable(isToggledHiddenByDefault: true),
            ])
            ->filters([
                // Filter status publikasi - memanfaatkan composite index
                Tables\\Filters\\TernaryFilter::make('is_published')
                    ->label('Status Publikasi')
                    ->placeholder('Semua')
                    ->trueLabel('Sudah Dipublikasi')
                    ->falseLabel('Draft'),
                // Filter kategori - memanfaatkan index idx_announcements_category
                Tables\\Filters\\SelectFilter::make('category')
                    ->label('Kategori')
                    ->options([
                        'Umum' => 'Umum',
                        'Kesehatan' => 'Kesehatan',
                        'Pendidikan' => 'Pendidikan',
                        'Sosial' => 'Sosial',
                        'Keamanan' => 'Keamanan',
                        'Lingkungan' => 'Lingkungan',
                        'Pembangunan' => 'Pembangunan',
                        'Kegiatan' => 'Kegiatan',
                        'Bantuan' => 'Bantuan',
                        'Informasi' => 'Informasi',
                    ])
                    ->multiple(),
            ])
            ->actions([
                Tables\\Actions\\Action::make('publish')
                    ->label('Publikasikan')
                    ->icon('heroicon-o-arrow-up-circle')
                    ->color('success')
                    ->visible(fn (Announcement $record): bool => !$record->is_published)
                    ->action(fn (Announcement $record) => $record->update([
                        'is_published' => true,
                        'published_at' => now(),
                    ])),
                Tables\\Actions\\Action::make('unpublish')
                    ->label('Tarik')
                    ->icon('heroicon-o-arrow-down-circle')
                    ->color('warning')
                    ->visible(fn (Announcement $record): bool => $record->is_published)
                    ->requiresConfirmation()
                    ->action(fn (Announcement $record) => $record->update([
                        'is_published' => false,
                    ])),
                Tables\\Actions\\ViewAction::make(),
                Tables\\Actions\\EditAction::make(),
                Tables\\Actions\\DeleteAction::make(),
            ])
            ->bulkActions([
                Tables\\Actions\\BulkActionGroup::make([
                    Tables\\Actions\\DeleteBulkAction::make(),
                ]),
            ])
            ->defaultSort('created_at', 'desc')
            ->striped();
    }

    public static function getRelations(): array
    {
        return [
            //
        ];
    }

    public static function getPages(): array
    {
        return [
            'index' => Pages\\ListAnnouncements::route('/'),
            'create' => Pages\\CreateAnnouncement::route('/create'),
            'edit' => Pages\\EditAnnouncement::route('/{record}/edit'),
        ];
    }
}

Sekarang mari kita buat Dashboard Widget untuk menampilkan statistik. Widget ini akan menampilkan berbagai angka penting yang dihitung menggunakan query yang sudah dioptimasi dengan index.

php artisan make:filament-widget StatsOverview --stats-overview

Buka file app/Filament/Widgets/StatsOverview.php dan ubah isinya.

<?php

namespace App\\Filament\\Widgets;

use App\\Models\\Announcement;
use App\\Models\\Document;
use App\\Models\\Family;
use App\\Models\\Villager;
use Filament\\Widgets\\StatsOverviewWidget as BaseWidget;
use Filament\\Widgets\\StatsOverviewWidget\\Stat;

class StatsOverview extends BaseWidget
{
    protected static ?int $sort = 1;

    protected static ?string $pollingInterval = '30s';

    protected function getStats(): array
    {
        // Semua query di bawah ini memanfaatkan index yang sudah dibuat

        // Total warga - menggunakan primary index
        $totalVillagers = Villager::count();

        // Total keluarga - menggunakan primary index
        $totalFamilies = Family::count();

        // Dokumen pending - menggunakan index idx_documents_status
        $pendingDocuments = Document::where('status', 'pending')->count();

        // Dokumen selesai bulan ini - menggunakan composite index idx_documents_status_created
        $completedThisMonth = Document::where('status', 'completed')
            ->whereMonth('created_at', now()->month)
            ->whereYear('created_at', now()->year)
            ->count();

        // Pengumuman aktif - menggunakan composite index idx_announcements_published
        $activeAnnouncements = Announcement::where('is_published', true)
            ->whereNotNull('published_at')
            ->where('published_at', '<=', now())
            ->count();

        // Statistik warga berdasarkan gender - menggunakan composite index idx_villagers_gender_marital
        $maleCount = Villager::where('gender', 'male')->count();
        $femaleCount = Villager::where('gender', 'female')->count();

        // Rata-rata anggota per keluarga
        $avgMembersPerFamily = $totalFamilies > 0
            ? round($totalVillagers / $totalFamilies, 1)
            : 0;

        return [
            Stat::make('Total Warga', number_format($totalVillagers))
                ->description("L: {$maleCount} | P: {$femaleCount}")
                ->descriptionIcon('heroicon-o-users')
                ->color('primary')
                ->chart([7, 3, 4, 5, 6, 3, 5, 8]),

            Stat::make('Total Keluarga', number_format($totalFamilies))
                ->description("Rata-rata {$avgMembersPerFamily} anggota/KK")
                ->descriptionIcon('heroicon-o-home')
                ->color('success'),

            Stat::make('Dokumen Pending', number_format($pendingDocuments))
                ->description('Menunggu diproses')
                ->descriptionIcon('heroicon-o-clock')
                ->color($pendingDocuments > 50 ? 'danger' : 'warning'),

            Stat::make('Selesai Bulan Ini', number_format($completedThisMonth))
                ->description('Dokumen selesai')
                ->descriptionIcon('heroicon-o-check-circle')
                ->color('success'),

            Stat::make('Pengumuman Aktif', number_format($activeAnnouncements))
                ->description('Sedang dipublikasi')
                ->descriptionIcon('heroicon-o-megaphone')
                ->color('info'),
        ];
    }
}

Widget StatsOverview menampilkan lima statistik penting yang datanya diambil dengan query yang efisien. Setiap query memanfaatkan index yang sudah kita buat. Method count() pada query dengan WHERE clause akan sangat cepat berkat index, bahkan untuk puluhan ribu data.

Property $pollingInterval diset ke 30s yang berarti widget akan refresh otomatis setiap 30 detik. Ini memungkinkan dashboard selalu menampilkan data terkini tanpa perlu refresh manual. Karena query sudah dioptimasi dengan index, polling yang sering tidak akan membebani database.

Mari kita buat satu widget lagi untuk menampilkan dokumen pending terbaru.

php artisan make:filament-widget LatestPendingDocuments --table

<?php

namespace App\\Filament\\Widgets;

use App\\Models\\Document;
use Filament\\Tables;
use Filament\\Tables\\Table;
use Filament\\Widgets\\TableWidget as BaseWidget;

class LatestPendingDocuments extends BaseWidget
{
    protected static ?int $sort = 2;

    protected int|string|array $columnSpan = 'full';

    protected static ?string $heading = 'Dokumen Pending Terbaru';

    public function table(Table $table): Table
    {
        return $table
            ->query(
                // Query ini memanfaatkan composite index idx_documents_status_created
                Document::query()
                    ->where('status', 'pending')
                    ->orderBy('created_at', 'desc')
                    ->limit(10)
            )
            ->columns([
                Tables\\Columns\\TextColumn::make('villager.name')
                    ->label('Pemohon')
                    ->searchable(),
                Tables\\Columns\\TextColumn::make('documentType.name')
                    ->label('Jenis Surat')
                    ->badge(),
                Tables\\Columns\\TextColumn::make('purpose')
                    ->label('Keperluan')
                    ->limit(30),
                Tables\\Columns\\TextColumn::make('created_at')
                    ->label('Diajukan')
                    ->since()
                    ->sortable(),
            ])
            ->actions([
                Tables\\Actions\\Action::make('process')
                    ->label('Proses')
                    ->icon('heroicon-o-arrow-path')
                    ->color('info')
                    ->url(fn (Document $record): string => route('filament.admin.resources.documents.edit', $record)),
            ])
            ->paginated(false);
    }
}

Widget ini menampilkan 10 dokumen pending terbaru dengan query yang memanfaatkan composite index idx_documents_status_created. Kombinasi where('status', 'pending') dan orderBy('created_at', 'desc') adalah pattern yang sangat umum untuk antrian dokumen, dan composite index memastikan query ini berjalan dengan sangat efisien.

Sekarang jalankan development server dan akses admin panel.

php artisan serve

Buka http://localhost:8000/admin dan login dengan kredensial admin. Kalian akan melihat dashboard dengan statistik dan daftar dokumen pending. Coba gunakan fitur pencarian di halaman Data Warga dengan mengetik nama atau NIK. Perhatikan betapa cepatnya hasil muncul meskipun ada 5.000 data warga. Ini adalah bukti nyata bahwa index yang kita buat bekerja dengan baik.

Untuk memverifikasi bahwa index benar-benar digunakan, kalian bisa membuka Laravel Telescope di http://localhost:8000/telescope/queries dan melihat query yang dijalankan saat menggunakan fitur pencarian. Perhatikan waktu eksekusi yang sangat singkat untuk setiap query.

Setelah menyelesaikan seluruh tutorial ini, mari kita rangkum apa yang sudah kita pelajari bersama.

Kita sudah memahami konsep table indexing dengan analogi yang sederhana. Index adalah seperti daftar isi buku yang membantu kita menemukan informasi dengan cepat tanpa harus membuka halaman satu per satu. Dalam database, index membantu MySQL menemukan data tanpa harus melakukan full table scan.

Kita sudah mempelajari berbagai jenis index yaitu Primary Index yang otomatis dibuat pada kolom id, Unique Index untuk kolom dengan nilai unik seperti NIK dan nomor KK, Regular Index untuk kolom yang sering dicari seperti nama dan status, serta Composite Index untuk kombinasi kolom yang sering difilter bersamaan.

Kita sudah mempraktekkan cara menerapkan index di Laravel migration dengan method index(), unique(), dan penamaan index yang konsisten. Kita juga belajar bahwa foreign key otomatis membuat index.

Kita sudah membuat data testing dalam jumlah besar menggunakan Factory dan Seeder dengan teknik bulk insert yang efisien. Data yang realistis membantu kita melihat dampak nyata dari indexing.

Dengan Laravel Telescope yang sudah kita pasang, sekarang kalian memiliki kemampuan untuk memonitor setiap query yang berjalan di aplikasi. Setiap kali ada fitur yang terasa lambat, buka Telescope dan lihat query mana yang menjadi bottleneck. Dari situ kalian bisa menentukan apakah perlu menambah index baru atau mengoptimasi query yang ada.

Hasil benchmark yang sudah kita lakukan membuktikan bahwa index memberikan dampak yang luar biasa. Query pencarian NIK yang tadinya memakan waktu 45ms turun menjadi hanya 1.8ms. Filter dokumen berdasarkan status yang tadinya 234ms menjadi hanya 5.6ms. Ini bukan peningkatan kecil, ini adalah perbedaan antara aplikasi yang responsif dan aplikasi yang membuat user frustrasi menunggu.

Admin panel yang kita bangun dengan Filament adalah contoh nyata bagaimana index bekerja di aplikasi sebenarnya. Ketika petugas desa mencari warga berdasarkan nama atau NIK, hasilnya muncul dalam hitungan milidetik meskipun database berisi 5.000 data warga. Filter demografis yang menggabungkan gender dan status perkawinan juga berjalan sangat cepat berkat composite index yang tepat.

Tips Best Practices untuk Indexing

Berdasarkan pengalaman saya bekerja di berbagai proyek, ada beberapa prinsip penting yang perlu kalian pegang saat bekerja dengan index.

Pertama, hindari over-indexing. Setiap index yang kalian buat membutuhkan ruang penyimpanan tambahan di disk. Lebih penting lagi, setiap index memperlambat operasi INSERT, UPDATE, dan DELETE karena MySQL harus memperbarui struktur index setiap kali data berubah. Jadi buatlah index hanya pada kolom yang benar-benar sering di-query.

Kedua, selalu analisis query pattern sebelum membuat index. Gunakan Telescope atau MySQL Slow Query Log untuk melihat query mana yang paling sering dijalankan dan mana yang paling lambat. Fokuskan optimasi pada query yang high-frequency dan high-impact.

Ketiga, manfaatkan composite index dengan bijak. Ketika kalian sering menjalankan query dengan multiple WHERE clause, composite index bisa jauh lebih efisien dibanding multiple single-column index. Tapi ingat, urutan kolom dalam composite index sangat penting. Letakkan kolom yang paling sering difilter di posisi pertama.

Keempat, lakukan review index secara berkala. Seiring berkembangnya aplikasi, pola penggunaan bisa berubah. Index yang dulunya berguna mungkin sudah tidak relevan lagi. Hapus index yang tidak terpakai untuk menghemat resource.

Kelima, selalu verifikasi dengan EXPLAIN. Jangan berasumsi bahwa index pasti digunakan hanya karena sudah dibuat. Jalankan EXPLAIN pada query-query penting untuk memastikan MySQL benar-benar menggunakan index yang kalian harapkan.

Pengembangan Lebih Lanjut

Tutorial ini baru menyentuh permukaan dari topik optimasi database. Ada banyak teknik lanjutan yang bisa kalian eksplorasi untuk membawa performa aplikasi ke level berikutnya.

Full-Text Search adalah fitur MySQL yang memungkinkan pencarian teks dengan relevansi scoring. Berbeda dengan LIKE query yang sederhana, full-text search bisa menemukan hasil berdasarkan kemiripan kata dan memberikan ranking hasil pencarian. Ini sangat berguna untuk fitur search yang lebih canggih.

Query Caching dengan Redis bisa mempercepat query yang sering dijalankan dengan hasil yang relatif statis. Daripada menjalankan query yang sama berulang-ulang ke database, hasil query disimpan di memory dan bisa diambil dalam hitungan mikrodetik.

Database Read Replica adalah teknik scaling di mana query SELECT diarahkan ke server database terpisah yang merupakan replika dari server utama. Ini memungkinkan aplikasi menangani traffic yang jauh lebih tinggi tanpa membebani server database utama.

MySQL Slow Query Log adalah fitur bawaan MySQL yang mencatat semua query yang berjalan lebih lambat dari threshold tertentu. Di production environment, ini adalah tools yang sangat berharga untuk mengidentifikasi masalah performa.

Penutup dan Rekomendasi

Kalau kalian merasa tutorial ini bermanfaat dan ingin terus mengembangkan skill programming, saya mengajak kalian untuk bergabung dengan BuildWithAngga. Di platform kami, kalian akan mendapatkan akses selamanya ke ratusan jam materi pembelajaran berkualitas yang terus diupdate mengikuti perkembangan teknologi terbaru.

Setiap kelas di BuildWithAngga dirancang dengan pendekatan project-based, jadi kalian tidak hanya belajar teori tapi langsung mempraktekkan dengan membangun project nyata. Project-project ini bisa langsung kalian masukkan ke portfolio untuk menunjukkan kemampuan kalian ke calon employer atau client.

Yang membedakan BuildWithAngga adalah dukungan mentor yang siap membantu menjawab pertanyaan kalian. Stuck di suatu masalah? Tanyakan langsung dan dapatkan jawaban dari praktisi yang berpengalaman di industri. Ditambah komunitas sesama learner yang supportif, proses belajar jadi lebih menyenangkan dan tidak terasa sendirian.

Terima kasih sudah mengikuti tutorial ini dari awal sampai akhir! Perjalanan kita dari konsep dasar indexing sampai implementasi admin panel yang fully functional memang cukup panjang, tapi saya yakin sekarang kalian sudah memiliki pemahaman yang solid tentang bagaimana mengoptimasi database untuk aplikasi yang performant.

Skill optimasi database adalah salah satu hal yang membedakan developer junior dengan developer senior. Banyak orang bisa membuat aplikasi yang berfungsi, tapi tidak banyak yang bisa membuat aplikasi yang tetap cepat dan responsif ketika datanya sudah jutaan baris. Dengan pengetahuan yang kalian dapat hari ini, kalian sudah selangkah lebih maju.

Terus belajar, terus berlatih, dan yang paling penting terus building. Sampai jumpa di tutorial selanjutnya!