Files
core/docs/product-listing.md
T

9.2 KiB

Product Listing

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.

This is separate from Modules\Core\Search\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\Search\ProductIndexer is built to carry that full shape.


Usage

use Modules\Core\Catalog\ProductFilters;
use Modules\Core\Catalog\ProductService;
use Modules\Core\Catalog\ProductSort;

$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,
);

// 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);

$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'];

// 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

All ProductFilters fields are optional; only the ones set are added to the Meilisearch query.


Fields this depends on: Modules\Core\Search\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\Search\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->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.
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, review_count, average_rating Modules\Core\Review\Models\ProductReview See "Reviews" below.

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.

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.


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


Registering the indexer

Not automatic — an app opts in via its own config/lunar/search.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:

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.