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