123 lines
5.5 KiB
Markdown
123 lines
5.5 KiB
Markdown
# 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
|
||
|
|
|
||
|
|
```php
|
||
|
|
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`:
|
||
|
|
|
||
|
|
```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:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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.
|