2026-08-24 12:41:28 +03:00
# Product Listing
2026-08-24 14:41:50 +03:00
`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.
2026-08-24 12:41:28 +03:00
This is separate from `Modules\Core\Search\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
just the trimmed fields a listing page needs. `Modules\Core\Search\ProductIndexer` is built to
carry that full shape.
2026-08-24 12:41:28 +03:00
---
## Usage
```php
use Modules\Core\Catalog\ProductFilters ;
use Modules\Core\Catalog\ProductService ;
2026-08-26 18:05:15 +03:00
use Modules\Core\Catalog\ProductSort ;
2026-08-24 12:41:28 +03:00
$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 ,
);
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).
$result = $service -> list ( perPage : 24 , page : 1 , sort : ProductSort :: PriceAsc );
2026-08-24 12:41:28 +03:00
$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' ];
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-24 12:41:28 +03:00
```
All `ProductFilters` fields are optional; only the ones set are added to the Meilisearch query.
---
## Fields this depends on: `Modules\Core\Search\ProductIndexer`
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
filterable. `Modules\Core\Search\ProductIndexer` extends it to add everything `ProductService`
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. |
| `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. |
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). |
| `reviews` , `review_count` , `average_rating` | `Modules\Core\Review\Models\ProductReview` | See "Reviews" below. |
2026-08-24 12:41:28 +03:00
`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.
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".
---
## 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.
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
`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.
---
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' => [
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.