Files
core/docs/product-search.md
T

3.6 KiB

Product Search

Modules\Core\Catalog\Services\ProductSearchService provides locale-aware full-text product search on top of Laravel Scout + Meilisearch.


Why locale-aware search isn't a filter

Lunar's Meilisearch indexer (Lunar\Search\ScoutIndexer::mapSearchableAttributes()) flattens every translated attribute into locale-suffixed fields on a single document — a product with a translated name produces name_en, name_el, etc. as separate top-level fields, not separate documents per locale and not a filterable locale field.

That means "search in Greek" isn't a ->filter('locale = el') — Meilisearch has no such field to filter on. It's a choice of which fields the query targets: name_el/description_el instead of name_en/description_en. This is what Meilisearch's attributesToSearchOn search parameter controls, exposed through Scout via Builder::options(), which passes straight through to the underlying Meilisearch client call (Laravel\Scout\Engines\MeilisearchEngine::performSearch() merges $builder->options directly into the search request).


Usage

use Modules\Core\Catalog\Services\ProductSearchService;

$results = app(ProductSearchService::class)->search('running shoes');
// or an explicit locale, bypassing App::getLocale():
$results = app(ProductSearchService::class)->search('running shoes', 'el');

Returns an Illuminate\Database\Eloquent\Collection of Lunar\Models\Product — Scout's ->get() hydrates real models from the database after the Meilisearch query, so relations (variants, brand, media, etc.) are available on the results as normal.

$locale defaults to App::getLocale() — already set correctly on every storefront request by Modules\Core\Localization\Middleware\LocaleMiddleware (see localization.md), so callers in controllers don't need to pass it explicitly.


Missing-translation fallback

If a product was only ever given an English name, name_el doesn't exist on that document at all (Lunar's indexer only writes a {handle}_{locale} field for locales actually present in the attribute's stored data — see ScoutIndexer::mapSearchableAttributes()). Searching strictly against name_el would make that product invisible to Greek-locale search, even though it's a real catalog item.

To avoid silently hiding incompletely-translated products, ProductSearchService targets both the resolved locale's fields and the default language's fields (Lunar\Models\Language::getDefault()->code) — e.g. searching in el targets name_el, name_en, description_el, description_en together (assuming en is the default language). A product missing an el translation still matches via its en fields.


Field list is dynamic, not hardcoded

The set of attribute handles searched (name, description, or whatever else) comes from Lunar\Facades\AttributeManifest::getSearchableAttributes(Product::morphName()) — the same source ScoutIndexer itself uses to decide what gets indexed. If an admin marks a new attribute searchable in the panel, ProductSearchService picks it up automatically; nothing in this class needs to change.


Re-syncing after indexer changes

Changing which attributes are searchable, or ProductIndexer's filterable/sortable fields, requires re-syncing Meilisearch's index settings and re-indexing existing documents:

php artisan lunar:meilisearch:setup
php artisan lunar:search:index "Lunar\Models\Product" --refresh

ProductSearchService itself needs no re-sync when locales change — attributesToSearchOn is computed per-query from the live language list, not baked into index settings.