# 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 ``` `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 $exclude ids to never return — the source product's own * id, plus every id an earlier rule in the chain already picked * @return Collection 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.