3.6 KiB
Product Search
Modules\Core\Product\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\Product\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.