Files
core/docs/product-listing.md
T
arvanitakis 5cb6c529a0 Feature: Adding Product Service to Lunar
This commit introduces a Product service to Lunar. This product service calls Meilisearch to fetch an indexed product. The indexer has been updated to also include the collection and the price of the product.

A product search service has also been created to be used by the frontend's search
2026-08-24 12:41:28 +03:00

5.5 KiB

Product Listing

Modules\Core\Catalog\ProductService provides browsing/filtering of the product catalog (list all, filter by collection/brand/price range) for a storefront, reading directly from the Meilisearch index rather than the database.

This is separate from Modules\Core\Search\ProductSearchService (see product-search.md), which handles free-text query search. ProductService is for browsing without a search term.


Why it reads from the index, not the database

ProductService::list() calls Product::search('')->paginateRaw(...) and returns the raw Meilisearch hits directly — it never calls Scout's ->get(), which would re-hydrate Eloquent models from the database per result. This avoids an extra database round-trip on every listing request, but it means callers only get whatever fields are in the indexed document, not the full Product model or its relations.

A single-product detail view needs the full record (all attributes, media, variants, etc.) and should look the product up directly via Lunar\Models\Product, not through ProductService.


Usage

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

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

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

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 doesn't index collection membership or a comparable price, and only marks __soft_deleted, skus, status as filterable — none of what ProductFilters needs. Modules\Core\Search\ProductIndexer extends it to add:

Field Source Notes
collections $product->collections->pluck('id') Array of collection IDs (as strings). Filtering matches by ID, not slug — the caller resolves whichever collection it means before calling ProductService.
price Cheapest variant's base price 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.

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.


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.


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.