Files

156 lines
7.9 KiB
Markdown
Raw Permalink Normal View History

2026-09-01 12:06:29 +03:00
# Product Recommendations
`Modules\Core\Catalog\Services\RecommendationService` computes "related products" for a given
product — a same-category pick today, with a random fallback, but built as a configurable chain of
strategies rather than one hardcoded rule. `Modules\Core\Catalog\Services\ProductIndexer` embeds
the result directly into each product's own Meilisearch document, so a product detail page renders
its recommendations with zero extra queries — same reasoning as `collections` (see
`docs/product-listing.md`).
---
## The rule chain
```php
use Modules\Core\Catalog\Services\RecommendationService;
$recommendations = app(RecommendationService::class)->recommend($product, limit: 4);
// Illuminate\Support\Collection<int, Lunar\Models\Product>
```
`recommend()` walks `config('catalog.recommendation_rules')` in order, **topping up** from each
successive rule until `$limit` distinct products are collected or every rule is exhausted — it does
not stop at the first rule that returns *something*. If a product's category only has 3 other
products, `SameCategoryRule` contributes those 3 and `RandomRule` fills the last slot. A rule is
handed the ids already collected (`$exclude`, always including the source product's own id) so it
never wastes its own `$limit` budget re-suggesting something already picked, and the same product
is never returned twice even if two rules would both suggest it.
Default chain (`config/catalog.php`):
```php
'recommendation_rules' => [
SameCategoryRule::class, // other products sharing $product's first collection
RandomRule::class, // universal fallback — always returns something as
// long as the store has more than one product
],
```
A consuming app publishes and edits this config to reorder, add, or remove rules — nothing about
the chain shape is hardcoded in `RecommendationService` itself. A new rule (same tag, best sellers,
"frequently bought together", ...) is a class implementing `Modules\Core\Catalog\Contracts\
RecommendationRule`, added to the array:
```php
interface RecommendationRule
{
/**
* @param array<int> $exclude ids to never return — the source product's own
* id, plus every id an earlier rule in the chain already picked
* @return Collection<int, Product> at most $limit products
*/
public function recommend(Product $product, int $limit, array $exclude): Collection;
}
```
Rules query Eloquent directly (`$product->collections->first()->products()`, `Product::query()`),
not `Modules\Core\Catalog\Services\ProductService` — see "Why not `ProductService`" below.
---
## Why not `ProductService`
Every other read path in `Modules\Core\Catalog` goes through `ProductService`, which reads
Meilisearch and resolves translated fields to whatever locale the *current request* is in (see
`docs/product-listing.md`, "Locale resolution"). Recommendation rules deliberately don't use it:
they run inside `ProductIndexer::toSearchableArray()`, at **index time** — there is no request, no
meaningful "current locale" to resolve against, and Meilisearch itself may be mid-write for the very
product being indexed. Rules return raw `Lunar\Models\Product` models instead; `ProductIndexer`
resolves what it embeds (`name` via `translateAttribute()`, `price` via the indexer's own
`cheapestPrice()`, `image` via its own `mapMedia()`) the same way it already does for the embedded
`collections` field — including that field's same accepted index-time-locale tradeoff (a
recommendation's embedded `name` reflects whatever locale was active when *that* product was last
indexed, not the viewer's current locale).
---
## What's embedded, and why not just an id
`ProductIndexer` embeds full card data per recommendation, not just an id:
```php
$data['recommendations'] = [
['id' => 42, 'name' => 'Espresso Cup', 'price' => 12.5, 'image' => 'https://.../thumb.jpg'],
// ...
];
```
This shape is deliberately exactly what `x-ui.product-card`/`x-product-grid` (3dealer's storefront
components) need — `name`, `price`, `image`, and an `id` the view resolves to a URL itself via
`route('product.show', ['id' => $rec['id']])`. A resolved `href` is **not** embedded: `product.show`
is locale-prefixed (`{locale}/products/{id}`), so a URL baked in at index time would be correct only
for whichever locale happened to be active during that index run — wrong for every other locale.
Building the URL is left to the view, which knows the current request's locale.
`recommendations.id` is marked **filterable** — not for the storefront, but for the reverse-lookup
reindexing below.
---
## Keeping it fresh: `ProductSaved` / `ProductDeleted`
A recommendation is computed once, at index time, and embedded — it does not update itself when the
recommended product later changes name, price, or image, or is deleted. Unlike `Modules\Core\Catalog\
Observers\ProductOptionReindexObserver`'s equivalent problem (which product option value is used by),
there is no Postgres relation for "which products currently recommend product X" — a recommendation
only exists inside Meilisearch. The fix is a reverse Meilisearch filter query, not a database join,
wired through a real event → listener pair (`Modules\Core\Providers\CatalogServiceProvider`):
- `Product::saved()` dispatches `Modules\Core\Catalog\Events\ProductSaved`.
- `Product::deleted()` dispatches `Modules\Core\Catalog\Events\ProductDeleted` — fires for both a
soft delete and a force delete (`Lunar\Models\Product` uses `SoftDeletes`), the same model event
Laravel Scout's own `ModelObserver` hooks to make a deleted product `unsearchable()`.
- `Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct` handles both: it searches the
product index for `recommendations.id = "{id}"`, finds every referencing product, and calls
`->searchable()` on each — which recomputes their `recommendations` field fresh, picking up the
changed name/price/image, or (for a delete) dropping the now-gone product and topping back up to
the configured limit via the rule chain, same as any other reindex.
`->searchable()` dispatches Scout's own reindex job, queued if `SCOUT_QUEUE` is configured — this
listener does no synchronous Meilisearch writing itself.
**Product creation is deliberately not hooked into this.** A brand-new product has no
`recommendations` of its own until Scout's existing create-triggered indexing runs (already correct
— nothing to add). What's *not* immediate is other products picking the new one up as a fresh
recommendation candidate — that happens on their own next natural reindex (a save, or the nightly
full reindex below), the same accepted staleness window `docs/product-listing.md` already documents
for `in_stock`/`price`. A full proactive "who could now recommend this new product" pass was
considered and rejected as unnecessary cost for a cosmetic delay.
---
## Nightly full reindex
`Modules\Core\Providers\CatalogServiceProvider` schedules `lunar:search:index "Lunar\Models\Product"
--refresh` daily at 03:00 — a safety net on top of the event-driven reindexing above, not a
replacement for it. Catches what event-driven reindexing deliberately doesn't cover: a newly-created
product not yet appearing as a recommendation elsewhere, and any other drift already accepted
between reindexes (see `docs/product-listing.md`, "Stock goes stale between orders"). `--refresh`
also re-syncs filterable/sortable index *settings*, not just documents, so a deploy that changed
`ProductIndexer`'s field list self-heals overnight even if `lunar:meilisearch:setup` wasn't run
manually right after that deploy.
---
## Re-syncing after this change
Same as any other `ProductIndexer` field change (see `docs/product-listing.md`):
```bash
php artisan lunar:meilisearch:setup
php artisan lunar:search:index "Lunar\Models\Product" --refresh
```
Restart the queue worker if `SCOUT_QUEUE=true` — see `docs/product-listing.md`'s "Re-syncing after
this change" for why a running worker won't otherwise pick up the new indexer code.