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.