Files
core/docs/product-recommendations.md
arvanitakis 0f07751559 Feature: Recommendation Service
This commit introduces a RecommendationService that is used when indexing products.

The service is utilizing a recommendation rules interface, so many rules can be created and applied during indexing the products
2026-09-01 12:06:29 +03:00

7.9 KiB

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

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):

'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:

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:

$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):

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.