# 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 ```php use Modules\Core\Catalog\DTOs\ProductFilters; use Modules\Core\Catalog\Enums\ProductSort; use Modules\Core\Catalog\Services\ProductSearchService; $results = app(ProductSearchService::class)->search('running shoes'); // Filters/sort apply the exact same semantics ProductService::list() uses for // collection browsing (same ProductFilterBuilder, same ProductSort) — a shopper // narrowing a text search by price/brand/stock gets identical filter behavior // to narrowing a category listing. $results = app(ProductSearchService::class)->search( 'running shoes', filters: new ProductFilters(brand: 'Acme', minPrice: 20.0, inStockOnly: true), sort: ProductSort::PriceAsc, ); ``` 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. There is no `$locale` parameter — see "Field list is dynamic, not hardcoded" below for why every configured store language is always searched, regardless of the current request locale. --- ## Missing-translation fallback, in both directions 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 the current request's locale field would make that product invisible whenever a shopper's locale doesn't match the language it happens to be translated into. `ProductSearchService` avoids this by targeting **every configured store language's fields** (`Lunar\Models\Language::all()`) on every search, not just the current request locale plus the store default — e.g. with `el`/`en` configured, every search targets `name_el`, `name_en`, `description_el`, `description_en` together, regardless of which locale the shopper is browsing in. This is deliberately not scoped to "current locale + default locale": if the current locale already equals the default (a single-language store, or a shopper browsing in the default language), that pairing collapses to one locale and stops catching anything else — always searching every configured language avoids that gap in both directions, at the cost of a larger `attributesToSearchOn` list as the store's language count grows. --- ## Variant option values are searched too Alongside the locale-suffixed attribute fields, every search also targets `variants.options.value` directly — e.g. a variant named "Κάπτεν Γαμέρικα" on a "Name" option matches a search for that text, even though it never appears in the product's own name or description. This isn't one of Lunar's own attributes (`AttributeManifest` has no entry for it), so it can't be discovered the way `name`/`description` are — it's a structural field of `Modules\Core\Catalog\Services\ProductIndexer`'s own document shape (see `ProductIndexer::mapVariant()`), added here directly. Not locale-suffixed — each option value is stored as one already-resolved string per variant. --- ## 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: ```bash 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.