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
This commit is contained in:
@@ -1206,3 +1206,5 @@ Real bugs/traps hit while building against Lunar in this package — not obvious
|
||||
- **`ProductOption.handle` must be unique and non-null if a product has more than one option.** Lunar's Filament variant-switcher widget does `SelectFilter::make($option->handle)` per option — two options with a `null`/matching handle throws "Filter must have a unique name" as a 500 when opening that product's variant pricing page. Always derive a slug and check uniqueness.
|
||||
- **`Attribute.position` is per-group, and the panel sorts by it.** Hardcoding `position => 1` for multiple new attributes in the same group makes their order undefined/collide with existing attributes at position 1. Compute `max('position') + 1` per group instead.
|
||||
- **Currency `decimal_places` isn't always 2.** A seeded/demo currency can have the wrong value (seen: EUR seeded with `decimal_places = 1`), which silently corrupts every price display (`€16.50` renders as `165`). If prices look wrong by a factor of 10, check the currency row before assuming the price-writing code is broken.
|
||||
- **`Builder::paginateRaw()`'s `items()` is not a hit list on the Meilisearch driver.** It contains the *entire* raw response (`hits`, `query`, `processingTimeMs`, `hitsPerPage`, `page`, `totalPages`, `totalHits`) as one associative array. Treating `$paginator->items()` as a plain list (e.g. `collect($paginator->items())->values()`) silently produces 7 elements — the real hits array happens to land first, the rest are stray scalars from the other response keys — no error, just corrupted data. Pull `$paginator->items()['hits']` explicitly. `total()`/`perPage()`/`currentPage()`/`lastPage()` on the paginator are unaffected. See `Modules\Core\Catalog\ProductService` / `docs/product-listing.md`.
|
||||
- **A running `queue:work` process does not pick up an edited/newly-added Scout indexer class.** It loads PHP classes once at boot and keeps them for the process's lifetime. Symptoms: reindexing commands succeed with no errors, calling `toSearchableArray()` directly (e.g. via `artisan tinker`, which always boots fresh) returns the new fields correctly, but documents written via `$model->searchable()` through the live queue are still missing them. Restart the queue worker after deploying an indexer change — no code fix needed.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Product Search
|
||||
|
||||
`Modules\Core\Search\ProductSearchService` provides locale-aware full-text product search on
|
||||
top of Laravel Scout + Meilisearch.
|
||||
|
||||
---
|
||||
|
||||
## Why locale-aware search isn't a filter
|
||||
|
||||
Lunar's Meilisearch indexer (`Lunar\Search\ScoutIndexer::mapSearchableAttributes()`) flattens
|
||||
every translated attribute into **locale-suffixed fields on a single document** — a product with
|
||||
a translated `name` produces `name_en`, `name_el`, etc. as separate top-level fields, not
|
||||
separate documents per locale and not a filterable `locale` field.
|
||||
|
||||
That means "search in Greek" isn't a `->filter('locale = el')` — Meilisearch has no such field to
|
||||
filter on. It's a choice of **which fields the query targets**: `name_el`/`description_el`
|
||||
instead of `name_en`/`description_en`. This is what Meilisearch's `attributesToSearchOn` search
|
||||
parameter controls, exposed through Scout via `Builder::options()`, which passes straight through
|
||||
to the underlying Meilisearch client call (`Laravel\Scout\Engines\MeilisearchEngine::performSearch()`
|
||||
merges `$builder->options` directly into the search request).
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```php
|
||||
use Modules\Core\Search\ProductSearchService;
|
||||
|
||||
$results = app(ProductSearchService::class)->search('running shoes');
|
||||
// or an explicit locale, bypassing App::getLocale():
|
||||
$results = app(ProductSearchService::class)->search('running shoes', 'el');
|
||||
```
|
||||
|
||||
Returns an `Illuminate\Database\Eloquent\Collection` of `Lunar\Models\Product` — Scout's
|
||||
`->get()` hydrates real models from the database after the Meilisearch query, so relations
|
||||
(`variants`, `brand`, `media`, etc.) are available on the results as normal.
|
||||
|
||||
`$locale` defaults to `App::getLocale()` — already set correctly on every storefront request by
|
||||
`Modules\Core\Localization\LocaleMiddleware` (see `localization.md`), so callers in controllers
|
||||
don't need to pass it explicitly.
|
||||
|
||||
---
|
||||
|
||||
## Missing-translation fallback
|
||||
|
||||
If a product was only ever given an English name, `name_el` doesn't exist on that document at
|
||||
all (Lunar's indexer only writes a `{handle}_{locale}` field for locales actually present in the
|
||||
attribute's stored data — see `ScoutIndexer::mapSearchableAttributes()`). Searching strictly
|
||||
against `name_el` would make that product invisible to Greek-locale search, even though it's a
|
||||
real catalog item.
|
||||
|
||||
To avoid silently hiding incompletely-translated products, `ProductSearchService` targets **both**
|
||||
the resolved locale's fields **and** the default language's fields
|
||||
(`Lunar\Models\Language::getDefault()->code`) — e.g. searching in `el` targets `name_el`,
|
||||
`name_en`, `description_el`, `description_en` together (assuming `en` is the default language).
|
||||
A product missing an `el` translation still matches via its `en` fields.
|
||||
|
||||
---
|
||||
|
||||
## Field list is dynamic, not hardcoded
|
||||
|
||||
The set of attribute handles searched (`name`, `description`, or whatever else) comes from
|
||||
`Lunar\Facades\AttributeManifest::getSearchableAttributes(Product::morphName())` — the same
|
||||
source `ScoutIndexer` itself uses to decide what gets indexed. If an admin marks a new attribute
|
||||
searchable in the panel, `ProductSearchService` picks it up automatically; nothing in this class
|
||||
needs to change.
|
||||
|
||||
---
|
||||
|
||||
## Re-syncing after indexer changes
|
||||
|
||||
Changing which attributes are searchable, or `ProductIndexer`'s filterable/sortable fields,
|
||||
requires re-syncing Meilisearch's index settings and re-indexing existing documents:
|
||||
|
||||
```bash
|
||||
php artisan lunar:meilisearch:setup
|
||||
php artisan lunar:search:index "Lunar\Models\Product" --refresh
|
||||
```
|
||||
|
||||
`ProductSearchService` itself needs no re-sync when locales change — `attributesToSearchOn` is
|
||||
computed per-query from the live language list, not baked into index settings.
|
||||
Reference in New Issue
Block a user