# Product Listing `Modules\Core\Catalog\Services\ProductService` provides catalog browsing/filtering AND single-product lookup for a storefront — `list()`, `getById()`, `getBySlug()`, `random()`, `variantSummaries()` — 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); // One call for everything a listing page needs — products AND the price slider's // bounds together, as a Modules\Core\Catalog\DTOs\ProductListingResult. A caller // used to have to call list() and priceSliderBounds() (or the older priceRange()) // separately and glue the results together itself; that's now list()'s own job. $listing = $service->list(perPage: 24, page: 1); // Filter by collection, brand, price range, and/or stock $listing = $service->list( filters: new ProductFilters(collectionId: 17, minPrice: 10.0, maxPrice: 50.0, inStockOnly: true), 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). $listing = $service->list(perPage: 24, page: 1, sort: ProductSort::PriceAsc); $products = $listing->products; // 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->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 $bounds = $listing->priceBounds; // Modules\Core\Catalog\DTOs\PriceSliderBounds $bounds->floor; // ?int — floor() of the matching range's minimum, in whole currency units $bounds->ceil; // ?int — ceil() of the matching range's maximum $bounds->filtered; // bool — whether the applied filters' minPrice/maxPrice actually // narrow the slider below/above these bounds (drives whether a // "clear filter" control should show) // 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 // $limit random products — still scoped to the index's own default channel/status // visibility, unlike Eloquent's Product::inRandomOrder() (which has no notion of // that filtering at all). Meilisearch has no ORDER BY RANDOM() equivalent, so this // pulls every matching id only, shuffles in PHP, then fetches the full localized // documents for just the ids picked — see random()'s own docblock. $randomProducts = $service->random(13); // array of documents, same shape as list()'s items // The id/price/image of every variant on a product document — the base price and // thumbnail a variant picker/swatch list needs, without reaching into // $product['variants'][n]['prices'][0]/['media'][0] yourself. $variants = $service->variantSummaries($product); // [['id' => 1204, 'price' => 19.99, 'image' => 'https://.../thumb.jpg'], ...] // Facet counts for a sidebar — value => matching product count, scoped to whatever // $filters is passed. Does NOT exclude the faceted field itself from $filters — see // facets()'s docblock for why, and how to build a standard "every option's count, // unaffected by that option's own currently-selected value" sidebar. $brandCounts = $service->facets('brand', filters: new ProductFilters(collectionId: 17)); // ['3Dealer.gr - 3D printed creations' => 48, 'Kraniou Topos - 3D printed creations' => 135] // Min/max price across matching products — the raw, unrounded values list() itself // uses to build priceBounds above. minPrice/maxPrice are ALWAYS excluded from the // filter driving this (unlike facets(), which doesn't auto-exclude) — the slider's // own bounds shouldn't shrink to whatever range is currently selected on it. Other // filters (collectionId, brand, inStockOnly) still apply normally. Pass $query too // to scope the range to a text search's own matches (see product-search.md) rather // than the whole catalog. $range = $service->priceRange(new ProductFilters(collectionId: 17)); // ['min' => 0.0, 'max' => 120.0] ``` All `ProductFilters` fields are optional; only the ones set are added to the Meilisearch query. `facets()` only makes sense on discrete-value filterable fields (`brand`, `in_stock`) — a numeric field like `price` would return one "facet" per exact price, not a usable range bucket. Use `priceRange()` (or `list()`'s own `priceBounds`) for `price` instead, which reads Meilisearch's `facetStats` (min/max), a different feature from `facetDistribution`. --- ## Stock goes stale between orders `in_stock` reflects `ProductVariant::stock`/`purchasable` as of the **last reindex**, not live inventory. Nothing in this codebase currently reindexes a product when an order decrements its stock — that's a cart/checkout concern, not something `ProductIndexer` can solve on its own (see `Modules\Core\Catalog\Observers\ProductOptionReindexObserver` for the equivalent pattern once an order → stock → reindex pipeline exists to hook into). Until then, `in_stock`/`product_count` can drift from the database the same way every other indexed field already can between writes. --- ## 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. | | `skus` | `$product->variants->pluck('sku')` | Filterable. Every variant's `sku`, deduplicated, empty ones dropped. Same "resolve from the index alone" reasoning as `slugs`, for a future SKU-based lookup/filter. | | `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`, `gtin`, `mpn`, `ean`, `stock`, `backorder`, `unit_quantity`, `purchasable`, `shippable`, `tax_ref`, `dimensions` (`length`/`width`/`height`/`weight`/`volume`, each `{value, unit}`), `options` (option/value names, in the current locale), `prices` (per currency/customer group), `media` (the variant's own images — `ProductVariant::images()`, a separate pivot from the product's own gallery above, populated by `ShopifyExportImporter` from Shopify's `Variant Image` CSV column). | | `reviews` | `Modules\Core\Review\Models\ProductReview` | `{items, count, average_rating}` — see "Reviews" below. | | `in_stock` | `$model->variants` | Filterable boolean. `true` if ANY variant currently passes `ProductVariant::canBeFulfilledAtQuantity(1)` — Lunar's own purchasability rule (`purchasable === 'always'` ignores stock entirely; `in_stock` checks `stock` alone; anything else checks `stock + backorder`). Only as fresh as the last reindex — see "Stock goes stale" 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.