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:

  1. Data produk disimpan di database relasional, misalnya MySQL atau PostgreSQL.
  2. Model Product menggunakan trait Searchable dari Laravel Scout.
  3. Saat produk dibuat, diubah, atau dihapus, Scout akan menyinkronkan dokumen ke Meilisearch.
  4. Endpoint API akan mengirim keyword, filter, sorting, dan pagination ke Meilisearch melalui Scout.
  5. 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 -d

Cek apakah service aktif:

curl http://127.0.0.1:7700/health

Jika 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-guzzle

Publikasikan 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-aman

Pastikan 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=true

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

  • id
  • name
  • sku
  • category_id
  • price
  • is_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_name disalin 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-products

Kenapa konfigurasi ini penting?

  • Filterable: tanpa ini, query seperti category_id = 2 atau 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=1

Contoh 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 mengembalikan false, 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 price harus numerik, is_active boolean.
  • 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:

  1. Tambahkan field ke toSearchableArray()
  2. Reimport data dengan scout:flush lalu scout:import jika perlu
  3. 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.