# Product Listing `Modules\Core\Catalog\Services\ProductService` provides catalog browsing/filtering AND single-product lookup for a storefront — `list()`, `getById()`, `getBySlug()` — all reading directly from the Meilisearch index rather than the database. One data source for everything this service does. This is separate from `Modules\Core\Catalog\Services\ProductSearchService` (see `product-search.md`), which handles free-text query search. `ProductService` is for browsing/lookup without a search term. --- ## Why it reads from the index, not the database Every method here reads Meilisearch documents directly and returns plain arrays — never Scout's `->get()`, which would re-hydrate Eloquent models from the database. This means the index has to carry everything a detail page needs (variants, prices, options, media, reviews — see below), not just the trimmed fields a listing page needs. `Modules\Core\Catalog\Services\ProductIndexer` is built to carry that full shape. --- ## Usage ```php use Modules\Core\Catalog\DTOs\ProductFilters; use Modules\Core\Catalog\Services\ProductService; use Modules\Core\Catalog\Enums\ProductSort; $service = app(ProductService::class); // List everything, paginated — returns a real Illuminate\Pagination\LengthAwarePaginator, // built from the localized Meilisearch hits (not Scout's own paginateRaw() result — see // "Meilisearch driver quirk" below), so it behaves like any other Laravel paginator. $products = $service->list(perPage: 24, page: 1); // Filter by collection, brand, and/or price range $products = $service->list( filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0), perPage: 24, page: 1, ); // Sort — cheapest/priciest first, or newest first. Omit for Meilisearch's default // relevance ordering (irrelevant here since the query is always empty). $products = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc); $products->items(); // array of Meilisearch documents (plain arrays, not models) $products->total(); $products->perPage(); $products->currentPage(); $products->lastPage(); $products->links(); // in a Blade view — renders pagination links as usual // Single product, by primary key $product = $service->getById(367); // array, or null if not found // Single product, by URL slug (any locale — slugs are indexed across all languages) $product = $service->getBySlug('erotika-mprelok'); // array, or null if not found ``` All `ProductFilters` fields are optional; only the ones set are added to the Meilisearch query. --- ## Fields this depends on: `Modules\Core\Catalog\Services\ProductIndexer` Lunar's own `Lunar\Search\ProductIndexer` only carries listing-grade fields (name, description, status, brand, a single thumbnail, skus) and marks just `__soft_deleted`, `skus`, `status` as filterable. `Modules\Core\Catalog\Services\ProductIndexer` extends it to add everything `ProductService` needs, listing and detail alike: | Field | Source | Notes | |---|---|---| | `id` | — | Newly marked **filterable** — needed for `getById()`'s `id = "..."` filter; Meilisearch doesn't filter on the primary key by default. | | `collections` | `$product->collections` | Array of `{id, name}` — directly assigned collections only, `name` is the translated collection name. Not filterable — see `collection_ids`. | | `collection_ids` | `$product->collections` + `->ancestors` | Filterable. Flat array of every directly-assigned collection's id, unioned with all of its ancestors' ids. `ProductFilters(collectionId: ...)` filters against this field, not `collections`, since products are typically attached only to leaf collections — a plain direct-match filter would never return anything for a parent/root category page. | | `slugs` | `$product->urls->pluck('slug')` | Filterable. Every locale's `Url::slug` for the product, so `getBySlug()` resolves purely from the index — no database read. | | `price` | Cheapest variant's base price | Filterable. Float in major units (e.g. `19.99`, not `1999`). Base price only — no customer group, default currency (`Currency::getDefault()`) only. `null` if the product has no priced variant yet, so it's excluded from range filters rather than treated as free. | | `brand` | Already indexed by Lunar's base indexer | Newly marked **filterable** — it existed in the document already, just wasn't usable in a `filter` clause. | | `tags` | `$product->tags->pluck('value')` | Display only. | | `media` | `$product->media` | Full gallery (id/url/thumb per image), not just the single thumbnail Lunar's base indexer sends. | | `variants` | `$product->variants` | Per variant: `id`, `sku`, `stock`, `purchasable`, `options` (option/value names, in the current locale), `prices` (per currency/customer group), `media` (variant-specific images). | | `reviews` | `Modules\Core\Review\Models\ProductReview` | `{items, count, average_rating}` — see "Reviews" below. | `name`/`description` (and any other `TranslatedText` attribute) are indexed per-locale — see "Locale resolution" below for how `ProductService` resolves them down to one value per request. **`ProductOption`/`ProductOptionValue` names need a different translation accessor.** Unlike `Product`/`Collection`/`Brand`, their `name` is a plain locale-keyed array cast, not `attribute_data` — Lunar's `translateAttribute('name')` silently returns `null` for them. The indexer's `translatedName()` reads the array directly instead. See `docs/lunar.md` "Gotchas". --- ## Locale resolution: `name`, `description`, and any other translated attribute Lunar's base `ScoutIndexer` explodes every `TranslatedText` attribute into one `{handle}_{locale}` field per store language at index time (`name_el`, `name_en`, `description_el`, ... — and the same for any custom translated attribute a store adds, e.g. `seo_title`/`seo_description`). Every raw document in Meilisearch carries all of them side by side, since a document is written once but read across many different-locale requests. `ProductService` resolves these back down to a single value per request. For every result it returns (`list()`'s items, `getById()`, `getBySlug()`), it: 1. Reads which `Product` attributes are `TranslatedText` from `Lunar\Base\AttributeManifest` — the same source Lunar's own indexer reads — rather than a hardcoded `['name', 'description']` list, so a store's own custom translated attributes are picked up automatically with no change here. 2. For each one, resolves `{handle}_{currentLocale}`, falling back to `{handle}_{storeDefaultLocale}` (`LanguageCache::defaultLocale()`) if the current locale has no translation — e.g. a product with no English copy yet still shows its Greek name on `/en/` rather than rendering blank. 3. Assigns the result to a plain `{handle}` key and **strips every raw `{handle}_{locale}` key** — callers only ever see `$product['name']`/`$product['seo_title']`/etc., never the per-locale fields the index actually stores. `description` and other translated attributes are otherwise indexed as-is, including any HTML markup (e.g. from a Shopify `Body (HTML)` import) — **not stripped**. Any view rendering a description sourced from `ProductService`'s results must treat it as trusted HTML. --- ## Reviews `Modules\Core\Review\Models\ProductReview` (`product_reviews` table) is indexed per-product under a single `reviews` key: `{items, count, average_rating}` — `items` is the array of reviews, `average_rating` is rounded to 1 decimal (`null` if the product has no reviews). Only public-safe fields are included on each item — **`reviewer_email` is deliberately excluded**, it's PII with no storefront use. `reply`/`replied_at` (the staff response) are included, since they're meant to be shown alongside the review. A review is created/edited independently of its product (a customer submission, a staff reply) — its own save doesn't touch the `Product` row, so the product's own model events never fire. `Modules\Core\Providers\ReviewServiceProvider` listens on `ProductReview`'s `created`/`updated`/ `deleted` events and calls `$review->product->searchable()`, so the parent product's document stays current without waiting for the next full reindex. This provider must be registered in `composer.json`'s `extra.laravel.providers` (already done in this repo) — see `docs/modules.md` "Provider Registration Pitfalls" for what happens if a provider like this is ever added but not registered. --- ## Multi-variant products and price A product's `price` is its *cheapest* variant's price ("from €19.99" style), not every variant's price. A price-range filter matches based on that single minimum — a product with one cheap variant and several expensive ones will match a low-price-range filter even though most of its variants don't. --- ## Sorting `ProductSort` (`Modules\Core\Catalog\Enums\ProductSort`) is a fixed enum of supported sort orders — `PriceAsc`, `PriceDesc`, `Newest` — each mapping to a Meilisearch `sort` clause against a field `Modules\Core\Catalog\Services\ProductIndexer::getSortableFields()` marks sortable (`price`, plus `created_at`/`updated_at`/`skus`/`status` inherited from Lunar's base indexer). Adding a new `ProductSort` case requires adding the matching field to `getSortableFields()` and re-syncing (see below) — sortable attributes are index settings, not computed per-query, same as filterable ones. Omitting `sort` leaves Meilisearch's default ordering, which is meaningless here since `list()` always searches with an empty query string (`Product::search('')`) — there's no relevance score to rank by, so results come back in whatever order the index returns them absent an explicit sort. --- ## Registering the indexer Not automatic — an app opts in via its own `config/lunar/search.php`: ```php 'indexers' => [ Lunar\Models\Product::class => Modules\Core\Catalog\Services\ProductIndexer::class, // ...other model indexers unchanged ], ``` ## Re-syncing after this change Filterable attributes are Meilisearch index settings, not computed per-query — changing them requires re-syncing settings and reindexing existing documents: ```bash php artisan lunar:meilisearch:setup php artisan lunar:search:index "Lunar\Models\Product" --refresh ``` **If `SCOUT_QUEUE=true`, restart the queue worker after deploying an indexer change.** A running `queue:work` process loads PHP classes once at boot and keeps that code in memory for its entire lifetime — it does not pick up an edited/newly-deployed indexer class. Symptoms: reindexing commands succeed with no errors, `Product::toSearchableArray()` returns the new fields correctly when called directly (e.g. via `artisan tinker`, which always boots fresh), but documents written via `$model->searchable()` through the live queue are still missing the new fields. Restarting the queue worker (`docker compose restart queue`, or equivalent) resolves it — no code change needed. --- ## Meilisearch driver quirk: `paginateRaw()`'s `items()` is not a list of hits For the Meilisearch engine specifically, Scout's `Builder::paginateRaw()` puts the **entire raw response** (`hits`, `query`, `processingTimeMs`, `hitsPerPage`, `page`, `totalPages`, `totalHits`) into the paginator's `items()`, not a plain array of documents. Calling `$paginator->items()` and treating it as a list (e.g. `collect($paginator->items())->values()`) silently produces a 7-element array whose first element happens to be the real hits and the rest are stray scalars from the other response keys — no error, just wrong data leaking into what looks like a normal list. `ProductService::list()` pulls `$paginator->items()['hits']` explicitly to avoid this; `$paginator->total()`/`perPage()`/`currentPage()`/`lastPage()` are unaffected and safe to use as-is.