Files
core/docs/product-search.md
T

5.2 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\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:

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.