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
This commit is contained in:
2026-09-01 12:06:29 +03:00
parent 53d8a5aefe
commit 0f07751559
11 changed files with 507 additions and 0 deletions
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Catalog\Contracts;
use Illuminate\Support\Collection;
use Lunar\Models\Product;
/**
* One strategy for producing recommended products for a given product —
* e.g. same category, same tag, best sellers, random. Modules\Core\Catalog\
* Services\RecommendationService runs rules registered in
* config('catalog.recommendation_rules') in order, topping up from each
* successive rule until $limit distinct products are collected or every
* rule is exhausted (e.g. 3 from SameCategoryRule + 1 from RandomRule) —
* nothing here decides that accumulation itself; a store composes its own
* chain by ordering rules in config (e.g. [SameCategoryRule::class,
* RandomRule::class]).
*
* Returns raw Product models, not ProductService::list()'s locale-resolved
* array output — this runs at index time (ProductIndexer::toSearchableArray()),
* where "the current locale" isn't a meaningful concept the way it is for a
* storefront request. ProductIndexer resolves translated fields itself via
* translateAttribute(), same as it already does for the embedded `collections`
* field — same known index-time-locale tradeoff, not a new one.
*/
interface RecommendationRule
{
/**
* $exclude carries $product's own id plus every id already picked by an
* earlier rule this call — RecommendationService never shows the same
* product twice even when two rules would both suggest it, and a rule
* shouldn't spend its $limit budget re-returning something already
* collected.
*
* @param array<int> $exclude
* @return Collection<int, Product> at most $limit products
*/
public function recommend(Product $product, int $limit, array $exclude): Collection;
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Modules\Core\Catalog\Events;
/**
* Dispatched whenever a Product is deleted (see CatalogServiceProvider,
* which wires this to the model's own deleted() hook — fires for both a
* soft delete and a force delete, same as Laravel Scout's own
* ModelObserver::deleted() that triggers unsearchable() for the product
* itself). Same purpose as Modules\Core\Catalog\Events\ProductSaved: lets
* Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct find
* and re-index every OTHER product that currently embeds this one in its
* `recommendations` field, so a deleted product doesn't linger as a dead
* reference elsewhere. Carries only the id, not the Product model — by the
* time this fires the model may already be gone (force delete), and the
* reverse lookup only ever needs the id to filter on.
*/
class ProductDeleted
{
public function __construct(
public readonly int $productId,
) {}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Modules\Core\Catalog\Events;
use Lunar\Models\Product;
/**
* Dispatched whenever a Product is saved (see CatalogServiceProvider,
* which wires this to the model's own saved() hook) — exists specifically
* so Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct can
* find and re-index every OTHER product that currently embeds this one in
* its own `recommendations` field (see ProductIndexer). Those products
* have no direct database relationship to this one — a recommendation is
* computed and stored only inside Meilisearch (Modules\Core\Catalog\
* Services\RecommendationService) — so nothing about their own save
* lifecycle would otherwise pick up this product's changed name/price/
* image.
*/
class ProductSaved
{
public function __construct(
public readonly Product $product,
) {}
}
@@ -0,0 +1,64 @@
<?php
namespace Modules\Core\Catalog\Listeners;
use Lunar\Models\Product;
use Modules\Core\Catalog\Events\ProductDeleted;
use Modules\Core\Catalog\Events\ProductSaved;
/**
* Keeps every product's embedded `recommendations` field (see
* ProductIndexer) in sync when a product they recommend changes or is
* removed. Unlike Modules\Core\Catalog\Observers\ProductOptionReindexObserver's
* equivalent ("who references this option value"), there is no Postgres
* table to query here — a recommendation only exists inside Meilisearch,
* computed by RecommendationService at index time — so the reverse lookup
* is a Meilisearch filter query against `recommendations.id`, not a
* database join.
*
* Handles both ProductSaved (name/price/image changed — referencing
* products' embedded copy is stale) and ProductDeleted (the recommended
* product no longer exists at all — referencing products need to drop it
* and, since RecommendationService tops up to its limit, naturally pick up
* a replacement on reindex). Same reverse lookup either way, just a
* different source for the id being searched for.
*
* Re-indexing via ->searchable() dispatches Scout's own (queued, if
* SCOUT_QUEUE is configured) reindex job per matched product — this
* listener itself does no synchronous Meilisearch writing.
*/
class ReindexProductsRecommendingProduct
{
public function handleSaved(ProductSaved $event): void
{
$this->reindexReferencingProducts($event->product->id);
}
public function handleDeleted(ProductDeleted $event): void
{
$this->reindexReferencingProducts($event->productId);
}
private function reindexReferencingProducts(int $productId): void
{
$hits = Product::search('')
->options([
'filter' => "recommendations.id = \"{$productId}\"",
'attributesToRetrieve' => ['id'],
// Meilisearch's own hitsPerPage default (20) would silently
// drop referencing products past that count — this is a
// reverse lookup, not a paginated storefront result, so it
// needs every match, up to Meilisearch's hard limit.
'hitsPerPage' => 1000,
])
->raw()['hits'] ?? [];
$ids = collect($hits)->pluck('id')->unique()->values();
if ($ids->isEmpty()) {
return;
}
Product::whereIn('id', $ids)->get()->each->searchable();
}
}
@@ -0,0 +1,27 @@
<?php
namespace Modules\Core\Catalog\Recommendations;
use Illuminate\Support\Collection;
use Lunar\Models\Product;
use Modules\Core\Catalog\Contracts\RecommendationRule;
/**
* The universal fallback — always returns something as long as the store
* has more than one product, since it has no eligibility condition of its
* own to come up empty on. Meant to be placed last in
* config('catalog.recommendation_rules'), not first: every store using
* the default chain gets a real fallback, but one that only kicks in once
* more specific rules (same category, same tag, ...) have had a chance.
*/
class RandomRule implements RecommendationRule
{
public function recommend(Product $product, int $limit, array $exclude): Collection
{
return Product::query()
->whereKeyNot($exclude)
->inRandomOrder()
->limit($limit)
->get();
}
}
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Catalog\Recommendations;
use Illuminate\Support\Collection;
use Lunar\Models\Product;
use Modules\Core\Catalog\Contracts\RecommendationRule;
/**
* Recommends other products sharing at least one of $product's directly-
* assigned collections — takes $product's first collection (a product
* usually has one primary category; if it has several, the first is as
* good a choice as any without a "primary collection" concept to prefer).
* Returns nothing if $product has no collection at all, letting the next
* rule in the chain (see RecommendationRule's docblock) take over.
*
* Queries Eloquent directly rather than going through Modules\Core\Catalog\
* Services\ProductService — this runs at index time (see
* RecommendationRule's docblock), where Meilisearch may be mid-reindex for
* this very product and ProductService::list()'s locale-resolution has no
* meaningful "current locale" to resolve against anyway.
*/
class SameCategoryRule implements RecommendationRule
{
public function recommend(Product $product, int $limit, array $exclude): Collection
{
$collection = $product->collections->first();
if ($collection === null) {
return collect();
}
return $collection->products()
->whereKeyNot($exclude)
->inRandomOrder()
->limit($limit)
->get();
}
}
+26
View File
@@ -44,6 +44,21 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
* Reflects stock as of the last reindex only — nothing currently reindexes a
* product when an order decrements its stock (see docs/product-listing.md).
*
* - recommendations (recommendations.id filterable): [{id, name, price, image}, ...]
* up to 4 other products to show alongside this one (a "related products"
* section), sourced from Modules\Core\Catalog\Services\RecommendationService's
* configured rule chain (config('catalog.recommendation_rules')). Embedded
* card data, not just ids, same reasoning as `collections`: renders
* directly with zero extra Meilisearch calls. `name` is resolved via
* translateAttribute() at index time (not through ProductService's
* per-request locale resolution, since indexing has no "current locale"
* the way a storefront request does) — same known index-time-locale
* tradeoff `collections` already has. `recommendations.id` is filterable
* specifically so Modules\Core\Catalog\Listeners\
* ReindexProductsRecommendingProduct can find every product currently
* recommending a given one, when that one changes — there's no Postgres
* relation for this, a recommendation only exists inside the index.
*
* A review is created/edited independently of its product (Modules\Core\Providers\
* ReviewServiceProvider re-indexes the product on review create/update/delete), so
* this data doesn't go stale between full reindexes.
@@ -67,6 +82,7 @@ class ProductIndexer extends BaseProductIndexer
'slugs',
'channel_ids',
'in_stock',
'recommendations.id',
];
}
@@ -126,6 +142,16 @@ class ProductIndexer extends BaseProductIndexer
$data['in_stock'] = $model->variants->contains(
fn (ProductVariant $variant) => $variant->canBeFulfilledAtQuantity(1)
);
$data['recommendations'] = app(RecommendationService::class)
->recommend($model)
->load(['media', 'variants.prices'])
->map(fn (Product $recommendation) => [
'id' => $recommendation->id,
'name' => $recommendation->translateAttribute('name'),
'price' => $this->cheapestPrice($recommendation, $currency),
'image' => $recommendation->media->first() ? $this->mapMedia($recommendation->media->first())['thumb'] : null,
])
->all();
return $data;
}
@@ -0,0 +1,47 @@
<?php
namespace Modules\Core\Catalog\Services;
use Illuminate\Support\Collection;
use Lunar\Models\Product;
use Modules\Core\Catalog\Contracts\RecommendationRule;
/**
* Runs each rule in config('catalog.recommendation_rules'), in order,
* topping up from each successive rule until $limit distinct products are
* collected or every rule is exhausted — e.g. 3 from SameCategoryRule
* (the product's category only has 3 other products) + 1 from RandomRule.
* No rule is special-cased as "the fallback" here; a store gets fallback
* behaviour purely by how it orders its own config (e.g. SameCategoryRule
* before RandomRule). Never returns the same product twice even if two
* rules would both suggest it (see RecommendationRule's $exclude), and
* never returns fewer than $limit unless the store genuinely doesn't have
* that many other products at all.
*/
class RecommendationService
{
/**
* @return Collection<int, Product>
*/
public function recommend(Product $product, int $limit = 4): Collection
{
$recommendations = collect();
foreach (config('catalog.recommendation_rules', []) as $ruleClass) {
if ($recommendations->count() >= $limit) {
break;
}
$exclude = [$product->id, ...$recommendations->pluck('id')];
$remaining = $limit - $recommendations->count();
/** @var RecommendationRule $rule */
$rule = app($ruleClass);
$recommendations = $recommendations->merge(
$rule->recommend($product, $remaining, $exclude)
);
}
return $recommendations->take($limit)->values();
}
}
+38
View File
@@ -2,17 +2,32 @@
namespace Modules\Core\Providers;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Lunar\Models\Product;
use Lunar\Models\ProductOption;
use Lunar\Models\ProductOptionValue;
use Modules\Core\Catalog\Events\ProductDeleted;
use Modules\Core\Catalog\Events\ProductSaved;
use Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct;
use Modules\Core\Catalog\Observers\ProductOptionReindexObserver;
use Modules\Core\Catalog\OptionTypes\ColorOptionType;
use Modules\Core\Catalog\Services\ProductOptionTypeManager;
class CatalogServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../../config/catalog.php', 'catalog');
}
public function boot(): void
{
$this->publishes([
__DIR__ . '/../../config/catalog.php' => config_path('catalog.php'),
], 'core-config');
ProductOptionTypeManager::get()->register([
ColorOptionType::class,
]);
@@ -24,5 +39,28 @@ class CatalogServiceProvider extends ServiceProvider
ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value));
ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value));
Product::saved(fn (Product $product) => Event::dispatch(new ProductSaved($product)));
Product::deleted(fn (Product $product) => Event::dispatch(new ProductDeleted($product->id)));
Event::listen(ProductSaved::class, [ReindexProductsRecommendingProduct::class, 'handleSaved']);
Event::listen(ProductDeleted::class, [ReindexProductsRecommendingProduct::class, 'handleDeleted']);
$this->app->booted(function () {
// A full nightly reindex, on top of the per-event reindexing
// above — catches everything event-driven reindexing
// deliberately doesn't cover: a newly-created product not yet
// appearing as a recommendation elsewhere, in_stock/price
// drifting from an order decrementing stock outside a product
// save, and any other staleness ProductIndexer's own docblock
// already documents as accepted between reindexes. --refresh
// re-syncs filterable/sortable field settings too, not just
// documents, so a deploy that changed ProductIndexer's field
// list self-heals here even if `lunar:meilisearch:setup`
// wasn't run manually after that deploy.
$this->app->make(Schedule::class)
->command('lunar:search:index', ['Lunar\\Models\\Product', '--refresh'])
->dailyAt('03:00');
});
}
}