# Product Listing `Modules\Core\Catalog\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\Search\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\Search\ProductIndexer` is built to carry that full shape. --- ## Usage ```php use Modules\Core\Catalog\ProductFilters; use Modules\Core\Catalog\ProductService; use Modules\Core\Catalog\ProductSort; $service = app(ProductService::class); // List everything, paginated $result = $service->list(perPage: 24, page: 1); // Filter by collection, brand, and/or price range $result = $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). $result = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc); $result['data']; // array of Meilisearch documents (plain arrays, not models) $result['meta']['total']; $result['meta']['per_page']; $result['meta']['current_page']; $result['meta']['last_page']; // 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\Search\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\Search\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->pluck('id')` | Filterable. Array of collection IDs (as strings) — filtering matches by ID, not slug. | | `collection_names` | `$product->collections` | Display only, not filterable — translated collection names. | | `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`, `review_count`, `average_rating` | `Modules\Core\Review\Models\ProductReview` | See "Reviews" below. | `description` and other translated attributes are 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. **`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". --- ## Reviews `Modules\Core\Review\Models\ProductReview` (`product_reviews` table) is indexed per-product as `reviews` (array), plus `review_count` and `average_rating` (rounded to 1 decimal, `null` if the product has no reviews). Only public-safe fields are included — **`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\ProductSort`) is a fixed enum of supported sort orders — `PriceAsc`, `PriceDesc`, `Newest` — each mapping to a Meilisearch `sort` clause against a field `Modules\Core\Search\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\Search\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.