Files
core/docs/product-search.md

108 lines
5.2 KiB
Markdown

# 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.