Fitur pencarian hampir selalu dibutuhkan di aplikasi web, entah untuk mencari artikel, produk, pesanan, atau data user di panel admin. Di Laravel, membuatnya cukup sederhana dengan Eloquent. Masalahnya, versi "asal jalan" sering menyimpan bug yang baru ketahuan belakangan: filter status ikut bocor, kata kunci hilang saat pindah halaman, atau query jadi lambat ketika data sudah puluhan ribu baris.
Artikel ini memandu Anda membuat fitur pencarian yang rapi dari nol: form, query di controller, pencarian di banyak kolom dengan aman, menjaga kata kunci saat pagination, mencari di tabel relasi, sampai opsi fulltext index dan Laravel Scout untuk data besar. Semua contoh berlaku untuk Laravel 10, 11, dan 12 dengan PHP 8.x.
Persiapan: Model, Route, dan Data Contoh
Sebagai contoh, kita pakai tabel posts dengan kolom title, content, status, dan category_id. Kalau Anda belum punya CRUD dasarnya, siapkan dulu model dan migration-nya. Berikut migration minimal yang dipakai sepanjang artikel:
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('category_id')->constrained();
$table->string('title');
$table->longText('content');
$table->string('status')->default('draft');
$table->timestamps();
});
Lalu daftarkan route di routes/web.php. Pencarian cukup memakai route index yang sama, tidak perlu route khusus:
use App\Http\Controllers\PostController;
Route::get('/artikel', [PostController::class, 'index'])->name('posts.index');
Langkah 1: Form Pencarian
Buat form sederhana dengan method GET agar kata kunci muncul di URL (bagus untuk berbagi & SEO):
<form action="/artikel" method="GET">
<input type="text" name="q" value="{{ request('q') }}" placeholder="Cari...">
<button type="submit">Cari</button>
</form>
Kenapa GET dan bukan POST? Pencarian tidak mengubah data apa pun, jadi secara konsep memang termasuk request baca. Dengan GET, URL hasilnya seperti /artikel?q=laravel bisa di-bookmark, dibagikan, dan tombol Back di browser bekerja normal. Form GET juga tidak memerlukan token @csrf. Kalau ingin memahami bedanya lebih dalam, baca perbedaan GET dan POST di PHP.
Versi yang sedikit lebih rapi memakai helper route() dan atribut type="search", sehingga di browser mobile keyboard menampilkan tombol cari dan ada ikon hapus di beberapa browser:
<form action="{{ route('posts.index') }}" method="GET" role="search">
<input type="search" name="q" value="{{ request('q') }}"
placeholder="Cari artikel..." maxlength="100" aria-label="Kata kunci">
<button type="submit">Cari</button>
@if (request()->filled('q'))
<a href="{{ route('posts.index') }}">Reset</a>
@endif
</form>
Perhatikan value="{{ request('q') }}". Bagian ini membuat kata kunci tetap tampil di kotak input setelah halaman dimuat ulang. Karena memakai {{ }}, Blade otomatis meng-escape isinya, jadi aman dari XSS walaupun user mengetik tag HTML.
Langkah 2: Query Pencarian di Controller
Ini versi paling dasar yang sering ditemukan di tutorial:
public function index(Request $request)
{
$query = Post::query();
if ($request->filled('q')) {
$keyword = $request->q;
$query->where('title', 'like', "%{$keyword}%")
->orWhere('content', 'like', "%{$keyword}%");
}
$posts = $query->latest()->paginate(10);
return view('posts.index', compact('posts'));
}
$request->filled('q') bernilai true hanya jika parameter q ada dan tidak kosong, jadi submit form tanpa isi tidak menjalankan pencarian. Tanda % di kiri dan kanan berarti "mengandung kata ini di posisi mana saja". Kode ini berjalan, tapi punya satu jebakan yang dibahas di langkah berikutnya.
Langkah 3: Pencarian di Banyak Kolom dengan Aman
Saat menggabungkan pencarian dengan filter lain, bungkus kondisi orWhere dalam grup agar logikanya tidak bocor:
$query->where(function ($q) use ($keyword) {
$q->where('title', 'like', "%{$keyword}%")
->orWhere('content', 'like', "%{$keyword}%");
});
Tanpa pembungkus function, kondisi orWhere bisa membatalkan filter status/kategori Anda. Supaya jelas, bandingkan SQL yang dihasilkan. Misalnya Anda hanya ingin menampilkan artikel berstatus published:
-- TANPA grup (salah)
select * from posts
where status = 'published' and title like '%laravel%' or content like '%laravel%'
-- DENGAN grup (benar)
select * from posts
where status = 'published' and (title like '%laravel%' or content like '%laravel%')
Di SQL, AND dievaluasi lebih dulu daripada OR. Pada versi tanpa grup, artikel draft yang kontennya mengandung kata "laravel" ikut tampil ke publik. Bug seperti ini jarang ketahuan saat testing karena datanya masih sedikit.
Sejak Laravel 10.47, ada cara yang lebih ringkas: method whereAny(). Method ini otomatis membungkus kondisinya dalam tanda kurung, jadi hasilnya sama dengan versi closure di atas:
$query->whereAny(['title', 'content'], 'like', "%{$keyword}%");
Pasangannya, whereAll(), mensyaratkan semua kolom cocok. Kalau proyek Anda masih di Laravel versi lebih lama, tetap gunakan closure.
Langkah 4: Pertahankan Kata Kunci Saat Pagination
Agar kata kunci tidak hilang saat pindah halaman, tambahkan query string ke link pagination:
{{ $posts->withQueryString()->links() }}
Tanpa withQueryString(), link halaman 2 hanya berisi ?page=2, sehingga user tiba-tiba melihat semua artikel, bukan hasil pencariannya. Anda juga bisa memanggilnya langsung di controller agar view tetap bersih:
$posts = $query->latest()->paginate(10)->withQueryString();
Alternatifnya, ->appends(['q' => $request->q]) hanya menambahkan parameter tertentu. Ini berguna kalau URL Anda membawa parameter lain yang tidak ingin ikut. Penjelasan konsep pagination dari sisi SQL bisa Anda baca di tutorial pagination PHP dan MySQL.
Langkah 5: Menampilkan Hasil
@forelse ($posts as $post)
<h3>{{ $post->title }}</h3>
@empty
<p>Tidak ada hasil untuk "{{ request('q') }}".</p>
@endforelse
Directive @forelse menggabungkan perulangan dan kondisi kosong sekaligus. Supaya lebih informatif, tampilkan juga jumlah hasil. Method total() tersedia pada hasil paginate():
@if (request()->filled('q'))
<p>Ditemukan {{ $posts->total() }} hasil untuk "{{ request('q') }}"</p>
@endif
Catatan: total() tidak ada pada simplePaginate() karena paginator jenis itu sengaja tidak menghitung total baris.
Versi Lengkap Controller yang Siap Pakai
Setelah memahami bagian-bagiannya, berikut controller yang menggabungkan validasi, pencarian, filter status, dan pagination. Saya memakai method when() dari query builder, yang hanya menjalankan closure jika nilai pertamanya terisi, sehingga tidak perlu banyak if:
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function index(Request $request)
{
$validated = $request->validate([
'q' => ['nullable', 'string', 'max:100'],
]);
$keyword = trim($validated['q'] ?? '');
$posts = Post::query()
->with('category')
->where('status', 'published')
->when($keyword !== '', function ($query) use ($keyword) {
$query->where(function ($q) use ($keyword) {
$q->where('title', 'like', "%{$keyword}%")
->orWhere('content', 'like', "%{$keyword}%");
});
})
->latest()
->paginate(10)
->withQueryString();
return view('posts.index', compact('posts', 'keyword'));
}
}
Validasi max:100 mencegah orang mengirim string ribuan karakter yang membebani database. with('category') memuat relasi sekaligus (eager loading) supaya tidak terjadi masalah N+1 query saat view menampilkan nama kategori.
Merapikan dengan Local Scope
Kalau logika pencarian dipakai di beberapa tempat, misalnya halaman publik dan panel admin, pindahkan ke local scope di model. Controller jadi lebih pendek dan aturannya cukup diubah di satu tempat:
// app/Models/Post.php
use Illuminate\Database\Eloquent\Builder;
public function scopeSearch(Builder $query, ?string $keyword): Builder
{
if (blank($keyword)) {
return $query;
}
return $query->where(function ($q) use ($keyword) {
$q->where('title', 'like', "%{$keyword}%")
->orWhere('content', 'like', "%{$keyword}%");
});
}
Pemakaiannya cukup satu baris:
$posts = Post::search($request->q)->latest()->paginate(10)->withQueryString();
Hati-hati jika nanti Anda memasang Laravel Scout: trait Searchable juga menyediakan method statis search(). Supaya tidak bentrok, beri nama scope yang berbeda, misalnya scopeKeyword.
Mencari di Tabel Relasi
Sering kali user mengetik nama kategori atau nama penulis, bukan judul. Untuk itu gunakan orWhereHas() di dalam grup yang sama:
Berlangganan gratis. Artikel & tutorial coding terbaru langsung ke email kamu. Tanpa spam.
$query->where(function ($q) use ($keyword) {
$q->where('title', 'like', "%{$keyword}%")
->orWhere('content', 'like', "%{$keyword}%")
->orWhereHas('category', function ($c) use ($keyword) {
$c->where('name', 'like', "%{$keyword}%");
});
});
whereHas menghasilkan subquery EXISTS, jadi pastikan relasi category() sudah didefinisikan di model. Jika belum paham cara mendefinisikan relasi, pelajari dulu Eloquent Relationship di Laravel. Pada data besar, subquery seperti ini cukup berat, jadi pakai seperlunya.
Mencari Beberapa Kata Sekaligus
Dengan satu LIKE, kata kunci "laravel pencarian" hanya cocok jika kedua kata muncul berurutan persis. Agar lebih fleksibel, pecah kata kunci lalu syaratkan setiap kata ada di judul atau konten:
$words = array_filter(explode(' ', $keyword));
foreach ($words as $word) {
$query->where(function ($q) use ($word) {
$q->where('title', 'like', "%{$word}%")
->orWhere('content', 'like', "%{$word}%");
});
}
Setiap kata menjadi satu grup yang digabung dengan AND, sehingga hasilnya hanya artikel yang mengandung semua kata, di urutan mana pun. Batasi jumlah kata (misalnya maksimal 5) agar query tidak membengkak.
Menangani Karakter % dan _ di Kata Kunci
Di LIKE, tanda % berarti "karakter apa saja berapa pun panjangnya" dan _ berarti "tepat satu karakter apa saja". Kalau user mengetik %, semua baris akan cocok. Ini bukan celah SQL injection karena Eloquent memakai parameter binding, tapi hasilnya jadi tidak sesuai harapan. Escape dulu sebelum dipakai:
$safe = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $keyword);
$query->where('title', 'like', "%{$safe}%");
Di MySQL dan MariaDB, backslash adalah karakter escape bawaan untuk LIKE, jadi kode di atas langsung bekerja.
Menyorot Kata Kunci di Hasil (Highlight)
Menebalkan kata yang dicari membantu user memindai hasil. Kuncinya: escape teks dulu, baru sisipkan tag <mark>, supaya tidak membuka celah XSS:
@php
$title = e($post->title);
if ($keyword !== '') {
$pattern = '/(' . preg_quote(e($keyword), '/') . ')/iu';
$title = preg_replace($pattern, '<mark>$1</mark>', $title);
}
@endphp
<h3>{!! $title !!}</h3>
Jangan pernah menaruh $post->title mentah di dalam {!! !!}. Selalu lewati e() lebih dulu.
Tips Performa
- Untuk tabel besar,
LIKE %kata%bisa lambat karena tidak memakai index. Pertimbangkan fulltext index MySQL untuk pencarian teks. - Untuk pencarian skala besar, gunakan Laravel Scout dengan driver seperti Meilisearch atau Algolia.
- Pola
LIKE 'kata%'(tanpa%di depan) masih bisa memakai index B-tree biasa. Cocok untuk pencarian kode produk, SKU, atau nomor invoice yang diketik dari awal. - Jangan memakai
select *jika kolomcontentsangat panjang dan tidak ditampilkan di daftar. Pilih kolom yang perlu saja denganselect().
Fulltext index di Laravel bisa dibuat langsung dari migration, lalu dipakai dengan whereFullText():
// migration
Schema::table('posts', function (Blueprint $table) {
$table->fullText(['title', 'content']);
});
// controller
$posts = Post::whereFullText(['title', 'content'], $keyword)->paginate(10);
Kolom di whereFullText() harus sama dengan kolom index fulltext-nya. Perlu diingat, fulltext MySQL bekerja per kata, bukan potongan kata: mencari "lara" tidak akan menemukan "laravel". Kata yang sangat pendek juga diabaikan, karena InnoDB punya batas minimal panjang token (pengaturan innodb_ft_min_token_size).
Untuk Laravel Scout, pasang paketnya dan tambahkan trait Searchable ke model:
composer require laravel/scout
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
use Laravel\Scout\Searchable;
class Post extends Model
{
use Searchable;
}
// pemakaian
$posts = Post::search($request->q)->paginate(10);
Scout juga punya driver database (atur SCOUT_DRIVER=database di .env) yang tidak butuh server tambahan, cocok sebagai langkah awal sebelum pindah ke Meilisearch.
Memilih Metode Pencarian yang Tepat
| Metode | Cocok untuk | Kelebihan | Keterbatasan |
|---|---|---|---|
LIKE %kata% | Data kecil sampai menengah, panel admin | Paling mudah, cocok potongan kata | Tidak memakai index, melambat di tabel besar |
| Fulltext index | Artikel atau konten teks panjang | Cepat, ada skor relevansi | Per kata utuh, khusus MySQL/MariaDB/PostgreSQL |
| Laravel Scout + Meilisearch/Algolia | Toko online, data besar, butuh typo tolerance | Sangat cepat, fitur lengkap | Perlu server/layanan tambahan dan sinkronisasi index |
Kesalahan Umum dan Solusinya
- Filter status ikut bocor. Penyebabnya
orWheretanpa grup. Solusi: bungkus dengan closure atau pakaiwhereAny(). - Halaman 2 menampilkan semua data. Lupa
withQueryString()pada paginator. - Pencarian tidak menemukan apa pun di PostgreSQL padahal datanya ada.
LIKEdi PostgreSQL membedakan huruf besar-kecil. Gunakan operatorilike. Di MySQL dengan collation_ci(bawaan Laravel), pencarian sudah tidak peka huruf besar-kecil. - Memakai
whereRawdengan variabel langsung. ContohwhereRaw("title like '%$keyword%'")rawan SQL injection. Jika butuh raw, pakai binding:whereRaw('title like ?', ["%{$keyword}%"]). - Halaman hasil lambat karena N+1 query. Tambahkan
with()untuk relasi yang ditampilkan di daftar hasil.
Kalau Anda juga butuh dropdown kategori, rentang tanggal, atau urutan harga di samping kotak pencarian, polanya mirip dan dibahas di fitur filter data di Laravel.
FAQ
Apakah pencarian dengan LIKE di Laravel aman dari SQL injection?
Ya, selama Anda memakai method query builder seperti where('title', 'like', ...). Laravel mengirim nilainya sebagai parameter binding PDO, bukan menempelkannya ke string SQL. Risiko muncul ketika memakai whereRaw atau DB::select dengan variabel yang ditempel langsung.
Kenapa hasil pencarian saya tidak peka huruf besar-kecil, atau justru sebaliknya?
Itu ditentukan oleh database, bukan Laravel. MySQL dan MariaDB dengan collation berakhiran _ci mengabaikan huruf besar-kecil, sedangkan LIKE di PostgreSQL peka huruf besar-kecil sehingga perlu ilike.
Kapan sebaiknya pindah dari LIKE ke fulltext atau Scout?
Saat query pencarian mulai terasa lambat di log atau monitoring, atau saat user butuh fitur seperti toleransi salah ketik dan urutan berdasarkan relevansi. Untuk blog kecil atau panel admin dengan beberapa ribu baris, LIKE biasanya masih memadai.
Bagaimana membuat pencarian live tanpa reload halaman?
Buat endpoint yang mengembalikan JSON (misalnya return response()->json($posts)), lalu panggil dengan fetch() dari JavaScript saat user mengetik. Beri jeda (debounce) sekitar beberapa ratus milidetik agar tidak mengirim request di setiap ketukan tombol. Alternatif lain adalah Livewire.
Apakah form pencarian perlu token CSRF?
Tidak, jika memakai method GET. Middleware CSRF Laravel hanya memeriksa request yang mengubah data seperti POST, PUT, PATCH, dan DELETE.
Kesimpulan
Membuat fitur pencarian di Laravel cukup dengan where('kolom', 'like', "%kata%"), dibungkus grup kondisi bila digabung filter lain, dan withQueryString() agar kata kunci bertahan saat pagination. Tambahkan validasi panjang kata kunci, escape karakter wildcard, dan eager loading supaya hasilnya aman dan ringan. Untuk data yang sangat besar, tingkatkan ke fulltext index atau Laravel Scout. Untuk memahami query relasi, lihat panduan Eloquent Relationship kami yang ditautkan di atas.