Pada aplikasi e-commerce, fitur pencarian produk bukan sekadar kotak input yang mencocokkan kata kunci. Pengguna biasanya ingin mencari berdasarkan nama produk, SKU, kategori, rentang harga, dan hanya menampilkan produk yang aktif. Jika pencarian lambat atau hasilnya tidak relevan, pengalaman pengguna akan langsung turun.
Di Laravel, kombinasi Laravel Scout dan Meilisearch adalah pilihan yang rapi untuk kebutuhan ini. Scout memberi lapisan integrasi yang nyaman dari Eloquent ke search engine, sementara Meilisearch menyediakan pencarian full-text yang cepat, dukungan typo tolerance, filtering, sorting, dan relevansi yang cukup baik untuk banyak use case e-commerce.
Pada artikel ini kita akan membangun studi kasus pencarian produk dengan kebutuhan berikut:
- Pencarian berdasarkan nama, SKU, dan kategori
- Filter berdasarkan status aktif dan kategori
- Filter/range harga minimum dan maksimum
- Sorting berdasarkan harga atau nama
- Pagination hasil pencarian
- Debugging saat hasil kosong atau field tidak ikut terindeks
Arsitektur Singkat dan Alasan Memilih Pendekatan Ini
Secara umum alurnya seperti ini:
- Data produk disimpan di database relasional, misalnya MySQL atau PostgreSQL.
- Model
Productmenggunakan traitSearchabledari Laravel Scout. - Saat produk dibuat, diubah, atau dihapus, Scout akan menyinkronkan dokumen ke Meilisearch.
- Endpoint API akan mengirim keyword, filter, sorting, dan pagination ke Meilisearch melalui Scout.
- Meilisearch mengembalikan hasil yang sudah diperingkat berdasarkan relevansi.
Pendekatan ini bekerja baik karena database tetap menjadi source of truth, sedangkan Meilisearch bertugas khusus sebagai mesin pencarian. Dengan begitu, query relasional kompleks tetap di database, sementara pencarian teks dan ranking dipindahkan ke engine yang memang didesain untuk itu.
Catatan penting: Meilisearch bukan pengganti database utama. Ia adalah indeks pencarian yang harus dijaga sinkron dengan data aplikasi.
Menjalankan Meilisearch dengan Docker Compose
Untuk memulai dengan cepat, jalankan Meilisearch menggunakan Docker Compose berikut:
version: '3.8'
services:
meilisearch:
image: getmeili/meilisearch:v1.7
container_name: meilisearch
ports:
- "7700:7700"
environment:
MEILI_MASTER_KEY: masterKey-super-aman
MEILI_ENV: development
volumes:
- meili_data:/meili_data
restart: unless-stopped
volumes:
meili_data:Setelah itu jalankan:
docker compose up -dCek apakah service aktif:
curl http://127.0.0.1:7700/healthJika berjalan, biasanya responsnya akan berisi status available.
Instalasi Laravel Scout dan Konfigurasi Dasar
Pasang Scout dan driver Meilisearch pada proyek Laravel:
composer require laravel/scout meilisearch/meilisearch-php http-interop/http-factory-guzzlePublikasikan konfigurasi Scout:
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"Atur file .env:
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://127.0.0.1:7700
MEILISEARCH_KEY=masterKey-super-amanPastikan file config/scout.php menggunakan driver dan host dari environment. Secara umum konfigurasi default Scout sudah cukup, selama variabel di atas benar.
Jika aplikasi Anda memerlukan queue untuk indexing asynchronous, aktifkan queue Scout agar proses update indeks tidak memperlambat request tulis:
SCOUT_QUEUE=trueTrade-off-nya, hasil pencarian tidak selalu langsung konsisten saat data baru saja diubah, karena indexing berjalan di background.
Mendesain Model Product agar Siap Diindeks
Misalkan tabel produk memiliki field berikut:
idnameskucategory_idpriceis_active
Dan relasi ke tabel categories untuk mengambil nama kategori.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Laravel\Scout\Searchable;
class Product extends Model
{
use Searchable;
protected $fillable = [
'name',
'sku',
'category_id',
'price',
'is_active',
];
protected $casts = [
'price' => 'float',
'is_active' => 'boolean',
];
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function searchableAs(): string
{
return 'products';
}
public function shouldBeSearchable(): bool
{
return $this->is_active === true;
}
public function toSearchableArray(): array
{
$this->loadMissing('category');
return [
'id' => (int) $this->id,
'name' => $this->name,
'sku' => $this->sku,
'category_id' => (int) $this->category_id,
'category_name' => $this->category?->name,
'price' => (float) $this->price,
'is_active' => (bool) $this->is_active,
];
}
}Ada beberapa hal penting di sini:
shouldBeSearchable()memastikan hanya produk aktif yang masuk indeks. Ini berguna jika Anda tidak ingin produk nonaktif muncul di hasil pencarian.toSearchableArray()harus mengembalikan field yang memang ingin dipakai untuk pencarian, filter, atau sorting.category_namedisalin ke dokumen indeks agar keyword seperti nama kategori bisa dicari tanpa join ke database saat search.
Kesalahan umum pada tahap ini adalah menganggap semua field model otomatis bisa dipakai untuk filter dan sorting. Tidak. Field harus ada di dokumen indeks, lalu dikonfigurasi sebagai filterable atau sortable di Meilisearch.
Import Data ke Indeks dan Sinkronisasi Awal
Setelah model siap, lakukan import awal:
php artisan scout:import "App\Models\Product"Jika Anda baru menambahkan relasi kategori ke toSearchableArray(), sebaiknya load relasi saat import dalam jumlah besar melalui query yang efisien, misalnya dengan custom import flow atau memastikan relasi tersedia untuk menghindari N+1 query. Untuk data tidak terlalu besar, pendekatan sederhana masih cukup.
Jika ingin menghapus seluruh indeks dan membangun ulang:
php artisan scout:flush "App\Models\Product"
php artisan scout:import "App\Models\Product"Mengatur Filterable dan Sortable Attributes
Agar Meilisearch bisa melakukan filter dan sorting, Anda perlu mengatur atribut indeks. Ini biasanya dilakukan sekali saat bootstrap atau melalui command/skrip internal. Contoh command Artisan sederhana:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Meilisearch\Client;
class ConfigureProductSearch extends Command
{
protected $signature = 'search:configure-products';
protected $description = 'Configure Meilisearch settings for products index';
public function handle(): int
{
$client = new Client(
config('scout.meilisearch.host'),
config('scout.meilisearch.key')
);
$index = $client->index('products');
$index->updateFilterableAttributes([
'category_id',
'is_active',
'price',
]);
$index->updateSortableAttributes([
'price',
'name',
]);
$index->updateSearchableAttributes([
'name',
'sku',
'category_name',
]);
$this->info('Products index configured successfully.');
return self::SUCCESS;
}
}Jalankan:
php artisan search:configure-productsKenapa konfigurasi ini penting?
- Filterable: tanpa ini, query seperti
category_id = 2atau rentang harga tidak akan bekerja. - Sortable: tanpa ini, sorting seperti harga termurah tidak akan diterapkan.
- Searchable: membantu menentukan field mana yang ikut mempengaruhi pencarian full-text.
Untuk e-commerce, susunan field searchable juga mempengaruhi relevansi. Biasanya name dan sku lebih penting daripada category_name.
Membuat Request Validation untuk API Search
Supaya endpoint tetap bersih dan aman, validasi parameter sebaiknya dipisah ke Form Request.
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProductSearchRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'q' => ['nullable', 'string', 'max:100'],
'category_id' => ['nullable', 'integer'],
'min_price' => ['nullable', 'numeric', 'min:0'],
'max_price' => ['nullable', 'numeric', 'min:0'],
'sort_by' => ['nullable', Rule::in(['price', 'name'])],
'sort_order' => ['nullable', Rule::in(['asc', 'desc'])],
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
'page' => ['nullable', 'integer', 'min:1'],
];
}
}Validasi ini mencegah input liar, membatasi pagination, dan memastikan sorting hanya memakai field yang memang kita izinkan.
Controller Search: Filtering, Sorting, dan Pagination
Berikut controller API yang memakai Scout dan callback ke Meilisearch untuk mengirim filter, sort, serta pagination:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\ProductSearchRequest;
use App\Models\Product;
use Illuminate\Http\JsonResponse;
class ProductSearchController extends Controller
{
public function __invoke(ProductSearchRequest $request): JsonResponse
{
$validated = $request->validated();
$q = $validated['q'] ?? '';
$perPage = $validated['per_page'] ?? 12;
$page = $validated['page'] ?? 1;
$filters = ['is_active = true'];
if (!empty($validated['category_id'])) {
$filters[] = 'category_id = ' . (int) $validated['category_id'];
}
if (isset($validated['min_price'])) {
$filters[] = 'price >= ' . (float) $validated['min_price'];
}
if (isset($validated['max_price'])) {
$filters[] = 'price <= ' . (float) $validated['max_price'];
}
$sort = [];
if (!empty($validated['sort_by'])) {
$direction = $validated['sort_order'] ?? 'asc';
$sort[] = $validated['sort_by'] . ':' . $direction;
}
$builder = Product::search($q, function ($meilisearch, string $query, array $options) use ($filters, $sort, $page, $perPage) {
$options['filter'] = implode(' AND ', $filters);
if (!empty($sort)) {
$options['sort'] = $sort;
}
$options['hitsPerPage'] = $perPage;
$options['page'] = $page;
return $meilisearch->search($query, $options);
});
$results = $builder->paginate($perPage, 'page', $page);
return response()->json([
'message' => 'Search results retrieved successfully',
'meta' => [
'query' => $q,
'page' => $results->currentPage(),
'per_page' => $results->perPage(),
'total' => $results->total(),
'last_page' => $results->lastPage(),
],
'data' => $results->items(),
]);
}
}Route API-nya:
use App\Http\Controllers\Api\ProductSearchController;
Route::get('/products/search', ProductSearchController::class);Contoh request:
GET /api/products/search?q=iphone&category_id=2&min_price=5000000&max_price=20000000&sort_by=price&sort_order=asc&per_page=10&page=1Contoh response JSON:
{
"message": "Search results retrieved successfully",
"meta": {
"query": "iphone",
"page": 1,
"per_page": 10,
"total": 2,
"last_page": 1
},
"data": [
{
"id": 15,
"name": "iPhone 13 128GB",
"sku": "APL-IP13-128",
"category_id": 2,
"price": 10999000,
"is_active": true
},
{
"id": 18,
"name": "iPhone 13 256GB",
"sku": "APL-IP13-256",
"category_id": 2,
"price": 12499000,
"is_active": true
}
]
}Membuat Hasil Pencarian Lebih Relevan
1. Simpan Field yang Benar di Indeks
Jika pengguna sering mencari berdasarkan SKU, maka SKU harus ada di toSearchableArray() dan termasuk searchable attribute. Jika kategori juga sering dipakai sebagai keyword, simpan category_name di indeks.
2. Gunakan Filter untuk Data Struktural
Field seperti is_active, category_id, dan price lebih tepat dipakai sebagai filter, bukan full-text search. Ini membuat hasil lebih presisi dan query lebih mudah dipahami.
3. Manfaatkan Typo Tolerance
Salah satu keunggulan Meilisearch adalah toleransi typo. Misalnya pengguna mengetik iphne, sistem masih bisa menemukan iphone. Fitur ini biasanya aktif secara default dan sangat membantu untuk e-commerce karena nama produk sering diketik tidak sempurna.
Namun ada trade-off. Untuk SKU, typo tolerance kadang justru tidak diinginkan karena SKU bersifat presisi. Jika use case Anda menuntut SKU harus benar-benar exact, pertimbangkan strategi tambahan seperti pencocokan SKU spesifik atau pengaturan search rules yang lebih ketat.
4. Sorting Jangan Mengorbankan Relevansi Tanpa Alasan
Jika pengguna memasukkan keyword, hasil default sebaiknya mengutamakan relevansi. Sorting eksplisit seperti price asc baru diterapkan saat pengguna memang memintanya. Jika sorting selalu dipaksa, hasil yang paling cocok bisa turun jauh ke bawah.
Debugging: Saat Hasil Kosong atau Field Tidak Terindeks
Kasus 1: Hasil pencarian kosong padahal data ada
- Pastikan Meilisearch aktif dan host/key benar.
- Pastikan produk sudah di-import dengan
scout:import. - Cek
shouldBeSearchable(). Jika mengembalikanfalse, produk tidak akan masuk indeks. - Jika memakai queue, pastikan worker berjalan. Tanpa worker, job indexing bisa menumpuk dan indeks tidak pernah terbarui.
Kasus 2: Filter tidak bekerja
- Pastikan field sudah didaftarkan di
filterableAttributes. - Pastikan tipe data konsisten. Misalnya
priceharus numerik,is_activeboolean. - Pastikan field memang ada di hasil
toSearchableArray().
Kasus 3: Sorting gagal atau diabaikan
- Pastikan field ada di
sortableAttributes. - Pastikan nama field sorting sama persis dengan nama di dokumen indeks.
Kasus 4: Field tertentu tidak ikut terindeks
Ini sangat umum saat menambah field baru seperti category_name. Solusinya:
- Tambahkan field ke
toSearchableArray() - Reimport data dengan
scout:flushlaluscout:importjika perlu - Update searchable/filterable/sortable attributes
Kasus 5: Data berubah di database tapi hasil search belum ikut berubah
Jika Anda menggunakan queue, keterlambatan sinkronisasi biasanya berasal dari worker yang belum jalan atau job gagal diproses. Periksa queue worker, failed jobs, dan log aplikasi.
Tips Produksi dan Trade-off
- Queue indexing cocok untuk performa write yang lebih baik, tetapi ada eventual consistency.
- Duplikasi data ke indeks memang menambah kompleksitas, tetapi ini normal pada arsitektur search engine.
- Filter harga sebaiknya memakai field numerik murni, bukan string yang sudah diformat mata uang.
- Jangan indeks semua field jika tidak dibutuhkan. Dokumen yang terlalu gemuk membuat manajemen indeks lebih sulit.
Untuk aplikasi e-commerce skala menengah, kombinasi Laravel Scout dan Meilisearch memberikan keseimbangan yang baik antara kemudahan integrasi dan fitur pencarian yang cukup lengkap. Anda bisa mulai dari implementasi sederhana seperti di artikel ini, lalu menambah fitur lanjutan seperti synonym, faceted search, boosting tertentu, atau indeks terpisah per tenant jika arsitektur aplikasi membutuhkannya.
Penutup
Implementasi pencarian produk yang rapi tidak berhenti pada Product::search(). Yang menentukan kualitas hasil adalah desain dokumen indeks, pemisahan antara keyword search dan structured filter, konfigurasi filterable/sortable attributes, serta proses debugging saat sinkronisasi tidak berjalan sesuai harapan.
Dengan Laravel Scout dan Meilisearch, Anda bisa membangun API pencarian produk yang cepat, relevan, dan mudah dirawat. Mulailah dari field inti seperti name, sku, category_name, price, dan is_active, lalu evaluasi perilaku pengguna untuk menyempurnakan relevansi pencarian secara bertahap.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!