Files
core/docs/product-listing.md
T

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:

  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:

'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.