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
156 lines
7.9 KiB
Markdown
156 lines
7.9 KiB
Markdown
# 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.
|