14 KiB
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
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, price range, and/or stock
$products = $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).
$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
// 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, for sizing a price-range slider.
// 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.
$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() 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. |
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. |
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:
- Reads which
Productattributes areTranslatedTextfromLunar\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. - 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. - 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:
'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:
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.