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
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()dispatchesModules\Core\Catalog\Events\ProductSaved.Product::deleted()dispatchesModules\Core\Catalog\Events\ProductDeleted— fires for both a soft delete and a force delete (Lunar\Models\ProductusesSoftDeletes), the same model event Laravel Scout's ownModelObserverhooks to make a deleted productunsearchable().Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProducthandles both: it searches the product index forrecommendations.id = "{id}", finds every referencing product, and calls->searchable()on each — which recomputes theirrecommendationsfield 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.