2026-08-24 12:41:28 +03:00
# Product Listing
2026-08-27 11:42:13 +03:00
`Modules\Core\Catalog\Services\ProductService` provides catalog browsing/filtering AND single-product
2026-09-03 11:44:23 +03:00
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.
2026-08-24 12:41:28 +03:00
2026-08-27 11:42:13 +03:00
This is separate from `Modules\Core\Catalog\Services\ProductSearchService` (see `product-search.md` ), which
2026-08-24 14:41:50 +03:00
handles free-text query search. `ProductService` is for browsing/lookup without a search term.
2026-08-24 12:41:28 +03:00
---
## Why it reads from the index, not the database
2026-08-24 14:41:50 +03:00
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
2026-08-27 11:42:13 +03:00
just the trimmed fields a listing page needs. `Modules\Core\Catalog\Services\ProductIndexer` is built to
2026-08-24 14:41:50 +03:00
carry that full shape.
2026-08-24 12:41:28 +03:00
---
## Usage
```php
2026-08-27 11:42:13 +03:00
use Modules\Core\Catalog\DTOs\ProductFilters ;
use Modules\Core\Catalog\Services\ProductService ;
use Modules\Core\Catalog\Enums\ProductSort ;
2026-08-24 12:41:28 +03:00
$service = app ( ProductService :: class );
2026-09-03 11:44:23 +03:00
// 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 );
2026-08-24 12:41:28 +03:00
2026-08-27 23:09:33 +03:00
// Filter by collection, brand, price range, and/or stock
2026-09-03 11:44:23 +03:00
$listing = $service -> list (
2026-08-27 23:09:33 +03:00
filters : new ProductFilters ( collectionId : 17 , minPrice : 10.0 , maxPrice : 50.0 , inStockOnly : true ),
2026-08-24 12:41:28 +03:00
perPage : 24 ,
page : 1 ,
);
2026-08-26 18:05:15 +03:00
// Sort — cheapest/priciest first, or newest first. Omit for Meilisearch's default
// relevance ordering (irrelevant here since the query is always empty).
2026-09-03 11:44:23 +03:00
$listing = $service -> list ( perPage : 24 , page : 1 , sort : ProductSort :: PriceAsc );
2026-08-26 18:05:15 +03:00
2026-09-03 11:44:23 +03:00
$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.
2026-08-27 00:48:38 +03:00
$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
2026-08-24 14:41:50 +03:00
2026-09-03 11:44:23 +03:00
$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)
2026-08-24 14:41:50 +03:00
// 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
2026-08-27 23:09:33 +03:00
2026-09-03 11:44:23 +03:00
// $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'], ...]
2026-08-27 23:09:33 +03:00
// 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]
2026-09-03 11:44:23 +03:00
// 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.
2026-08-27 23:09:33 +03:00
$range = $service -> priceRange ( new ProductFilters ( collectionId : 17 ));
// ['min' => 0.0, 'max' => 120.0]
2026-08-24 12:41:28 +03:00
```
All `ProductFilters` fields are optional; only the ones set are added to the Meilisearch query.
2026-08-27 23:09:33 +03:00
`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
2026-09-03 11:44:23 +03:00
`priceRange()` (or `list()` 's own `priceBounds` ) for `price` instead, which reads Meilisearch's
`facetStats` (min/max), a different feature from `facetDistribution` .
2026-08-27 23:09:33 +03:00
---
## 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.
2026-08-24 12:41:28 +03:00
---
2026-08-27 11:42:13 +03:00
## Fields this depends on: `Modules\Core\Catalog\Services\ProductIndexer`
2026-08-24 12:41:28 +03:00
2026-08-24 14:41:50 +03:00
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
2026-08-27 11:42:13 +03:00
filterable. `Modules\Core\Catalog\Services\ProductIndexer` extends it to add everything `ProductService`
2026-08-24 14:41:50 +03:00
needs, listing and detail alike:
2026-08-24 12:41:28 +03:00
| Field | Source | Notes |
|---|---|---|
2026-08-24 14:41:50 +03:00
| `id` | — | Newly marked **filterable** — needed for `getById()` 's `id = "..."` filter; Meilisearch doesn't filter on the primary key by default. |
2026-08-27 12:11:01 +03:00
| `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. |
2026-08-24 14:41:50 +03:00
| `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. |
2026-08-24 12:41:28 +03:00
| `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. |
2026-08-24 14:41:50 +03:00
| `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). |
2026-08-27 11:32:20 +03:00
| `reviews` | `Modules\Core\Review\Models\ProductReview` | `{items, count, average_rating}` — see "Reviews" below. |
2026-08-27 23:09:33 +03:00
| `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. |
2026-08-24 12:41:28 +03:00
2026-08-27 00:48:38 +03:00
`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.
2026-08-24 12:41:28 +03:00
2026-08-24 14:41:50 +03:00
** `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".
---
2026-08-27 00:48:38 +03:00
## 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.
---
2026-08-24 14:41:50 +03:00
## Reviews
2026-08-27 11:32:20 +03:00
`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.
2026-08-24 14:41:50 +03:00
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.
2026-08-24 12:41:28 +03:00
---
## 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.
---
2026-08-26 18:05:15 +03:00
## Sorting
2026-08-27 11:42:13 +03:00
`ProductSort` (`Modules\Core\Catalog\Enums\ProductSort` ) is a fixed enum of supported sort orders —
2026-08-26 18:05:15 +03:00
`PriceAsc` , `PriceDesc` , `Newest` — each mapping to a Meilisearch `sort` clause against a field
2026-08-27 11:42:13 +03:00
`Modules\Core\Catalog\Services\ProductIndexer::getSortableFields()` marks sortable (`price` , plus
2026-08-26 18:05:15 +03:00
`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.
---
2026-08-24 12:41:28 +03:00
## Registering the indexer
Not automatic — an app opts in via its own `config/lunar/search.php` :
```php
'indexers' => [
2026-08-27 11:42:13 +03:00
Lunar\Models\Product :: class => Modules\Core\Catalog\Services\ProductIndexer :: class ,
2026-08-24 12:41:28 +03:00
// ...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.