Feat: Restructuring Products into the Concern Catalog, for future Collection services

This commit is contained in:
2026-08-27 11:42:13 +03:00
parent 63caaf55c7
commit ba5a9523c8
20 changed files with 797 additions and 29 deletions
+2 -2
View File
@@ -1206,6 +1206,6 @@ Real bugs/traps hit while building against Lunar in this package — not obvious
- **`ProductOption.handle` must be unique and non-null if a product has more than one option.** Lunar's Filament variant-switcher widget does `SelectFilter::make($option->handle)` per option — two options with a `null`/matching handle throws "Filter must have a unique name" as a 500 when opening that product's variant pricing page. Always derive a slug and check uniqueness. - **`ProductOption.handle` must be unique and non-null if a product has more than one option.** Lunar's Filament variant-switcher widget does `SelectFilter::make($option->handle)` per option — two options with a `null`/matching handle throws "Filter must have a unique name" as a 500 when opening that product's variant pricing page. Always derive a slug and check uniqueness.
- **`Attribute.position` is per-group, and the panel sorts by it.** Hardcoding `position => 1` for multiple new attributes in the same group makes their order undefined/collide with existing attributes at position 1. Compute `max('position') + 1` per group instead. - **`Attribute.position` is per-group, and the panel sorts by it.** Hardcoding `position => 1` for multiple new attributes in the same group makes their order undefined/collide with existing attributes at position 1. Compute `max('position') + 1` per group instead.
- **Currency `decimal_places` isn't always 2.** A seeded/demo currency can have the wrong value (seen: EUR seeded with `decimal_places = 1`), which silently corrupts every price display (`€16.50` renders as `165`). If prices look wrong by a factor of 10, check the currency row before assuming the price-writing code is broken. - **Currency `decimal_places` isn't always 2.** A seeded/demo currency can have the wrong value (seen: EUR seeded with `decimal_places = 1`), which silently corrupts every price display (`€16.50` renders as `165`). If prices look wrong by a factor of 10, check the currency row before assuming the price-writing code is broken.
- **`Builder::paginateRaw()`'s `items()` is not a hit list on the Meilisearch driver.** It contains the *entire* raw response (`hits`, `query`, `processingTimeMs`, `hitsPerPage`, `page`, `totalPages`, `totalHits`) as one associative array. Treating `$paginator->items()` as a plain list (e.g. `collect($paginator->items())->values()`) silently produces 7 elements — the real hits array happens to land first, the rest are stray scalars from the other response keys — no error, just corrupted data. Pull `$paginator->items()['hits']` explicitly. `total()`/`perPage()`/`currentPage()`/`lastPage()` on the paginator are unaffected. See `Modules\Core\Product\Services\ProductService` / `docs/product-listing.md`. - **`Builder::paginateRaw()`'s `items()` is not a hit list on the Meilisearch driver.** It contains the *entire* raw response (`hits`, `query`, `processingTimeMs`, `hitsPerPage`, `page`, `totalPages`, `totalHits`) as one associative array. Treating `$paginator->items()` as a plain list (e.g. `collect($paginator->items())->values()`) silently produces 7 elements — the real hits array happens to land first, the rest are stray scalars from the other response keys — no error, just corrupted data. Pull `$paginator->items()['hits']` explicitly. `total()`/`perPage()`/`currentPage()`/`lastPage()` on the paginator are unaffected. See `Modules\Core\Catalog\Services\ProductService` / `docs/product-listing.md`.
- **`ProductOption`/`ProductOptionValue::$name` is not `attribute_data` — `translateAttribute('name')` silently returns null for them.** Unlike `Product`/`Collection`/`Brand`, their translated `name` is a plain locale-keyed array cast (`AsArrayObject`) directly on the column, not stored in `attribute_data`. `HasTranslations::translateAttribute()` only reads `attribute_data`, so calling it on these two models compiles fine and returns `null` with no error — read the array directly instead (`$value->name[$locale] ?? ...`). See `Modules\Core\Product\Services\ProductIndexer::translatedName()`. - **`ProductOption`/`ProductOptionValue::$name` is not `attribute_data` — `translateAttribute('name')` silently returns null for them.** Unlike `Product`/`Collection`/`Brand`, their translated `name` is a plain locale-keyed array cast (`AsArrayObject`) directly on the column, not stored in `attribute_data`. `HasTranslations::translateAttribute()` only reads `attribute_data`, so calling it on these two models compiles fine and returns `null` with no error — read the array directly instead (`$value->name[$locale] ?? ...`). See `Modules\Core\Catalog\Services\ProductIndexer::translatedName()`.
- **A running `queue:work` process does not pick up an edited/newly-added Scout indexer class.** It loads PHP classes once at boot and keeps them for the process's lifetime. Symptoms: reindexing commands succeed with no errors, calling `toSearchableArray()` directly (e.g. via `artisan tinker`, which always boots fresh) returns the new fields correctly, but documents written via `$model->searchable()` through the live queue are still missing them. Restart the queue worker after deploying an indexer change — no code fix needed. - **A running `queue:work` process does not pick up an edited/newly-added Scout indexer class.** It loads PHP classes once at boot and keeps them for the process's lifetime. Symptoms: reindexing commands succeed with no errors, calling `toSearchableArray()` directly (e.g. via `artisan tinker`, which always boots fresh) returns the new fields correctly, but documents written via `$model->searchable()` through the live queue are still missing them. Restart the queue worker after deploying an indexer change — no code fix needed.
+11 -11
View File
@@ -1,10 +1,10 @@
# Product Listing # Product Listing
`Modules\Core\Product\Services\ProductService` provides catalog browsing/filtering AND single-product `Modules\Core\Catalog\Services\ProductService` provides catalog browsing/filtering AND single-product
lookup for a storefront — `list()`, `getById()`, `getBySlug()` — all reading directly from the lookup for a storefront — `list()`, `getById()`, `getBySlug()` — all reading directly from the
Meilisearch index rather than the database. One data source for everything this service does. Meilisearch index rather than the database. One data source for everything this service does.
This is separate from `Modules\Core\Product\Services\ProductSearchService` (see `product-search.md`), which This is separate from `Modules\Core\Catalog\Services\ProductSearchService` (see `product-search.md`), which
handles free-text query search. `ProductService` is for browsing/lookup without a search term. handles free-text query search. `ProductService` is for browsing/lookup without a search term.
--- ---
@@ -14,7 +14,7 @@ handles free-text query search. `ProductService` is for browsing/lookup without
Every method here reads Meilisearch documents directly and returns plain arrays — never Scout's Every method here reads Meilisearch documents directly and returns plain arrays — never Scout's
`->get()`, which would re-hydrate Eloquent models from the database. This means the index has to `->get()`, which would re-hydrate Eloquent models from the database. This means the index has to
carry everything a detail page needs (variants, prices, options, media, reviews — see below), not carry everything a detail page needs (variants, prices, options, media, reviews — see below), not
just the trimmed fields a listing page needs. `Modules\Core\Product\Services\ProductIndexer` is built to just the trimmed fields a listing page needs. `Modules\Core\Catalog\Services\ProductIndexer` is built to
carry that full shape. carry that full shape.
--- ---
@@ -22,9 +22,9 @@ carry that full shape.
## Usage ## Usage
```php ```php
use Modules\Core\Product\DTOs\ProductFilters; use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Product\Services\ProductService; use Modules\Core\Catalog\Services\ProductService;
use Modules\Core\Product\Enums\ProductSort; use Modules\Core\Catalog\Enums\ProductSort;
$service = app(ProductService::class); $service = app(ProductService::class);
@@ -62,11 +62,11 @@ All `ProductFilters` fields are optional; only the ones set are added to the Mei
--- ---
## Fields this depends on: `Modules\Core\Product\Services\ProductIndexer` ## Fields this depends on: `Modules\Core\Catalog\Services\ProductIndexer`
Lunar's own `Lunar\Search\ProductIndexer` only carries listing-grade fields (name, description, Lunar's own `Lunar\Search\ProductIndexer` only carries listing-grade fields (name, description,
status, brand, a single thumbnail, skus) and marks just `__soft_deleted`, `skus`, `status` as status, brand, a single thumbnail, skus) and marks just `__soft_deleted`, `skus`, `status` as
filterable. `Modules\Core\Product\Services\ProductIndexer` extends it to add everything `ProductService` filterable. `Modules\Core\Catalog\Services\ProductIndexer` extends it to add everything `ProductService`
needs, listing and detail alike: needs, listing and detail alike:
| Field | Source | Notes | | Field | Source | Notes |
@@ -149,9 +149,9 @@ variants don't.
## Sorting ## Sorting
`ProductSort` (`Modules\Core\Product\Enums\ProductSort`) is a fixed enum of supported sort orders — `ProductSort` (`Modules\Core\Catalog\Enums\ProductSort`) is a fixed enum of supported sort orders —
`PriceAsc`, `PriceDesc`, `Newest` — each mapping to a Meilisearch `sort` clause against a field `PriceAsc`, `PriceDesc`, `Newest` — each mapping to a Meilisearch `sort` clause against a field
`Modules\Core\Product\Services\ProductIndexer::getSortableFields()` marks sortable (`price`, plus `Modules\Core\Catalog\Services\ProductIndexer::getSortableFields()` marks sortable (`price`, plus
`created_at`/`updated_at`/`skus`/`status` inherited from Lunar's base indexer). Adding a new `created_at`/`updated_at`/`skus`/`status` inherited from Lunar's base indexer). Adding a new
`ProductSort` case requires adding the matching field to `getSortableFields()` and re-syncing (see `ProductSort` case requires adding the matching field to `getSortableFields()` and re-syncing (see
below) — sortable attributes are index settings, not computed per-query, same as filterable ones. below) — sortable attributes are index settings, not computed per-query, same as filterable ones.
@@ -168,7 +168,7 @@ Not automatic — an app opts in via its own `config/lunar/search.php`:
```php ```php
'indexers' => [ 'indexers' => [
Lunar\Models\Product::class => Modules\Core\Product\Services\ProductIndexer::class, Lunar\Models\Product::class => Modules\Core\Catalog\Services\ProductIndexer::class,
// ...other model indexers unchanged // ...other model indexers unchanged
], ],
``` ```
+8 -8
View File
@@ -6,7 +6,7 @@ Each `ProductOptionValue` carries a free-form `meta` jsonb column, but nothing i
Lunar's own admin UI exposes it — there's no way for an admin to, say, attach a hex Lunar's own admin UI exposes it — there's no way for an admin to, say, attach a hex
code to a "Red" value without editing the database directly. code to a "Red" value without editing the database directly.
`Modules\Core\Product\Contracts\ProductOptionTypeInterface` describes how a category `Modules\Core\Catalog\Contracts\ProductOptionTypeInterface` describes how a category
of option behaves — what structured data its values carry in `meta`, and how an of option behaves — what structured data its values carry in `meta`, and how an
admin edits that data — without introducing a new model. `ProductOption`/ admin edits that data — without introducing a new model. `ProductOption`/
`ProductOptionValue` stay exactly as Lunar defines them. `ProductOptionValue` stay exactly as Lunar defines them.
@@ -19,7 +19,7 @@ A shop registers a type class from its own service provider's `boot()`, the same
shape as `Modules\Core\Notification\NotificationRegistry`: shape as `Modules\Core\Notification\NotificationRegistry`:
```php ```php
use Modules\Core\Product\Services\ProductOptionTypeManager; use Modules\Core\Catalog\Services\ProductOptionTypeManager;
ProductOptionTypeManager::get()->register([ ProductOptionTypeManager::get()->register([
\App\ProductOptions\ColorOptionType::class, \App\ProductOptions\ColorOptionType::class,
@@ -44,7 +44,7 @@ name/position, no extra meta form.
namespace App\ProductOptions; namespace App\ProductOptions;
use Filament\Forms\Components\ColorPicker; use Filament\Forms\Components\ColorPicker;
use Modules\Core\Product\Contracts\ProductOptionTypeInterface; use Modules\Core\Catalog\Contracts\ProductOptionTypeInterface;
class ColorOptionType implements ProductOptionTypeInterface class ColorOptionType implements ProductOptionTypeInterface
{ {
@@ -70,8 +70,8 @@ plain jsonb column). `getKey()` is the identifier used in the admin's "Option Ty
dropdown and in `ProductOption::meta['option_type']` — it has no relationship to the dropdown and in `ProductOption::meta['option_type']` — it has no relationship to the
`ProductOption::handle`. `ProductOption::handle`.
A reference implementation ships at `Modules\Core\Product\OptionTypes\ColorOptionType`, A reference implementation ships at `Modules\Core\Catalog\OptionTypes\ColorOptionType`,
registered automatically by `Modules\Core\Providers\ProductServiceProvider` — no shop registered automatically by `Modules\Core\Providers\CatalogServiceProvider` — no shop
setup needed for it to appear in the "Option Type" dropdown, though an admin still setup needed for it to appear in the "Option Type" dropdown, though an admin still
has to pick it per-`ProductOption` for it to take effect. has to pick it per-`ProductOption` for it to take effect.
@@ -79,7 +79,7 @@ has to pick it per-`ProductOption` for it to take effect.
## How it's wired into the admin UI ## How it's wired into the admin UI
`Modules\Core\Product\Services\ProductOptionTypeManager` is a singleton registry: `Modules\Core\Catalog\Services\ProductOptionTypeManager` is a singleton registry:
- `get(): static` — the shared instance. - `get(): static` — the shared instance.
- `register(array $types): void` — registers one or more type classes, keyed - `register(array $types): void` — registers one or more type classes, keyed
internally by `getKey()`. internally by `getKey()`.
@@ -93,11 +93,11 @@ Two extensions hook into Lunar's admin via its extension system
(`LunarPanel::extensions([...])`, registered in `CorePlugin`) — no forking of Lunar's (`LunarPanel::extensions([...])`, registered in `CorePlugin`) — no forking of Lunar's
classes needed: classes needed:
- `Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension` extends - `Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension` extends
`Lunar\Admin\Filament\Resources\ProductOptionResource`'s own form with a `Select` `Lunar\Admin\Filament\Resources\ProductOptionResource`'s own form with a `Select`
(`meta.option_type`) listing every enabled type's key. Shown only when at least one (`meta.option_type`) listing every enabled type's key. Shown only when at least one
type is enabled. type is enabled.
- `Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension` extends - `Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension` extends
the "Values" tab's form. Its `extendForm()` reads the "Values" tab's form. Its `extendForm()` reads
`$option->meta['option_type']` off the owning `ProductOption`, resolves it via `$option->meta['option_type']` off the owning `ProductOption`, resolves it via
`ProductOptionTypeManager`, and appends `getMetaForm()`'s fields to the stock name `ProductOptionTypeManager`, and appends `getMetaForm()`'s fields to the stock name
+2 -2
View File
@@ -1,6 +1,6 @@
# Product Search # Product Search
`Modules\Core\Product\Services\ProductSearchService` provides locale-aware full-text product search on `Modules\Core\Catalog\Services\ProductSearchService` provides locale-aware full-text product search on
top of Laravel Scout + Meilisearch. top of Laravel Scout + Meilisearch.
--- ---
@@ -24,7 +24,7 @@ merges `$builder->options` directly into the search request).
## Usage ## Usage
```php ```php
use Modules\Core\Product\Services\ProductSearchService; use Modules\Core\Catalog\Services\ProductSearchService;
$results = app(ProductSearchService::class)->search('running shoes'); $results = app(ProductSearchService::class)->search('running shoes');
// or an explicit locale, bypassing App::getLocale(): // or an explicit locale, bypassing App::getLocale():
@@ -0,0 +1,34 @@
<?php
namespace Modules\Core\Catalog\Contracts;
use Filament\Forms\Components\Component;
/**
* A Product Option Type describes how a category of Lunar `ProductOption` (e.g.
* "Color", "Size", "Material") behaves — namely, what structured data its values
* carry in their free-form `meta` jsonb column, and how an admin edits that data.
*
* `ProductOption`/`ProductOptionValue` themselves stay exactly as Lunar defines
* them — this is not a new model. `ProductOptionTypeManager` maps a
* `ProductOption::handle` to the type describing it (via `config('core.product_option_types')`,
* typed explicitly by the admin), so adding a new kind of option is a single new
* class, not scattered per-option special-casing across the admin UI or storefront.
*/
interface ProductOptionTypeInterface
{
/**
* Matches the ProductOption::handle this type describes (e.g. 'color', 'size').
*/
public static function getKey(): string;
/**
* Filament form components for editing a ProductOptionValue's `meta` under this
* option type — e.g. Color returns a color picker for `meta.hex`, Size returns a
* numeric input for `meta.sort_value`. Field names should be dot-notation under
* `meta` (e.g. `meta.hex`), matching where ValuesRelationManagerExtension saves them.
*
* @return array<Component>
*/
public function getMetaForm(): array;
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Catalog\DTOs;
/**
* Filter input for ProductService::list(). All fields are optional — omitted
* filters are simply not added to the Meilisearch query. Values are matched
* against Modules\Core\Catalog\Services\ProductIndexer's document fields, so
* filtering only works on stores where that indexer is registered and the index
* has been re-synced (see docs/product-listing.md).
*/
class ProductFilters
{
public function __construct(
public readonly ?int $collectionId = null,
public readonly ?string $brand = null,
public readonly ?float $minPrice = null,
public readonly ?float $maxPrice = null,
) {}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Modules\Core\Catalog\Enums;
/**
* Sort options for ProductService::list(), each mapped to a Meilisearch `sort`
* clause against a field indexed as sortable by Modules\Core\Catalog\Services\
* ProductIndexer (see its getSortableFields()). Adding a case here requires the
* matching field to also be sortable in the index, re-synced via
* `php artisan lunar:meilisearch:setup`.
*/
enum ProductSort: string
{
case PriceAsc = 'price_asc';
case PriceDesc = 'price_desc';
case Newest = 'newest';
public function toMeilisearchSort(): string
{
return match ($this) {
self::PriceAsc => 'price:asc',
self::PriceDesc => 'price:desc',
self::Newest => 'created_at:desc',
};
}
}
@@ -0,0 +1,39 @@
<?php
namespace Modules\Core\Catalog\Filament\Extensions;
use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Illuminate\Support\Str;
use Lunar\Admin\Support\Extending\ResourceExtension;
use Modules\Core\Catalog\Services\ProductOptionTypeManager;
/**
* Adds an "Option Type" dropdown to Lunar's own ProductOptionResource form, letting
* an admin pick which registered `ProductOptionTypeInterface` (if any) describes this
* option's values — e.g. "Color" — independent of the option's own `handle`. The
* selection is saved to `ProductOption::meta['option_type']`.
*/
class ProductOptionResourceExtension extends ResourceExtension
{
public function extendForm(Form $form): Form
{
$options = collect(ProductOptionTypeManager::get()->all())
->keys()
->mapWithKeys(fn (string $key) => [$key => Str::headline($key)])
->all();
if ($options === []) {
return $form;
}
return $form->schema([
...$form->getComponents(),
Select::make('meta.option_type')
->label('Option Type')
->options($options)
->helperText('Controls which meta fields appear when editing this option\'s values.')
->native(false),
]);
}
}
@@ -0,0 +1,34 @@
<?php
namespace Modules\Core\Catalog\Filament\Extensions;
use Filament\Forms\Form;
use Lunar\Admin\Support\Extending\RelationManagerExtension;
use Lunar\Models\ProductOption;
use Modules\Core\Catalog\Services\ProductOptionTypeManager;
/**
* Appends the owning `ProductOption`'s registered `ProductOptionTypeInterface` meta
* form (if any) to Lunar's own ValuesRelationManager form, so e.g. a "color" option
* gets a hex-color picker for each value alongside the stock name field — without
* forking Lunar's relation manager.
*/
class ValuesRelationManagerExtension extends RelationManagerExtension
{
public function extendForm(Form $form): Form
{
/** @var ProductOption $option */
$option = $this->caller->getOwnerRecord();
$type = ProductOptionTypeManager::get()->resolve($option->meta['option_type'] ?? null);
if ($type === null) {
return $form;
}
return $form->schema([
...$form->getComponents(),
...$type->getMetaForm(),
]);
}
}
@@ -0,0 +1,68 @@
<?php
namespace Modules\Core\Catalog\Observers;
use Illuminate\Support\Facades\DB;
use Lunar\Models\Product;
use Lunar\Models\ProductOption;
use Lunar\Models\ProductOptionValue;
use Lunar\Models\ProductVariant;
/**
* Keeps every product using a ProductOption/ProductOptionValue in sync with
* Meilisearch. ProductIndexer::mapVariant() embeds each option value's `meta`
* (e.g. a color's hex) directly into the product's indexed document — but saving
* the option or one of its values never fires the *product's* own save/update
* events, so without this, a changed option_type or a changed hex would only
* reach the index on that product's next unrelated reindex.
*/
class ProductOptionReindexObserver
{
public function optionSaved(ProductOption $option): void
{
$this->reindexProductsForOption($option->id);
}
public function optionDeleted(ProductOption $option): void
{
$this->reindexProductsForOption($option->id);
}
public function valueSaved(ProductOptionValue $value): void
{
$this->reindexProductsForValues([$value->id]);
}
public function valueDeleted(ProductOptionValue $value): void
{
$this->reindexProductsForValues([$value->id]);
}
private function reindexProductsForOption(int $optionId): void
{
$valueIds = ProductOptionValue::where('product_option_id', $optionId)->pluck('id');
$this->reindexProductsForValues($valueIds->all());
}
private function reindexProductsForValues(array $valueIds): void
{
if ($valueIds === []) {
return;
}
$prefix = config('lunar.database.table_prefix');
$variantIds = DB::table("{$prefix}product_option_value_product_variant")
->whereIn('value_id', $valueIds)
->pluck('variant_id');
if ($variantIds->isEmpty()) {
return;
}
$productIds = ProductVariant::whereIn('id', $variantIds)->pluck('product_id')->unique();
Product::whereIn('id', $productIds)->get()->each->searchable();
}
}
@@ -0,0 +1,29 @@
<?php
namespace Modules\Core\Catalog\OptionTypes;
use Filament\Forms\Components\ColorPicker;
use Modules\Core\Catalog\Contracts\ProductOptionTypeInterface;
/**
* Describes a 'color' ProductOption's values as carrying a hex code in
* `meta.hex`, editable via a Filament color picker. Registered automatically by
* `Modules\Core\Providers\CatalogServiceProvider` — a shop's admin still has to
* pick "Color" from the Option Type dropdown per-ProductOption for it to apply.
*/
class ColorOptionType implements ProductOptionTypeInterface
{
public static function getKey(): string
{
return 'color';
}
public function getMetaForm(): array
{
return [
ColorPicker::make('meta.hex')
->label('Color')
->required(),
];
}
}
+198
View File
@@ -0,0 +1,198 @@
<?php
namespace Modules\Core\Catalog\Services;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Lunar\Models\Currency;
use Lunar\Models\Price;
use Lunar\Models\Product;
use Lunar\Models\ProductVariant;
use Lunar\Search\ProductIndexer as BaseProductIndexer;
use Modules\Core\Review\Models\ProductReview;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
/**
* Extends Lunar's own indexer so Modules\Core\Catalog\Services\ProductService can
* serve both listing/filtering AND single-product lookups from Meilisearch alone —
* one data source, no separate database read path for a product detail page. Adds:
* - collections: [{id, name}, ...] — filterable via `collections.id`
* - slugs (every locale's Url::slug for the product, filterable) — lets
* ProductService::getBySlug() resolve a product from the index directly, with
* no database read at all
* - price (cheapest variant, filterable) and full per-variant pricing
* - variants: sku, stock, purchasable, option values, prices, media
* - the full media gallery (not just the single thumbnail Lunar's base indexer sends)
* - tags
* - reviews: {items: [...], count, average_rating} — items are public-safe fields
* only (see mapReview() — reviewer_email is deliberately excluded, it's PII with
* no storefront use), including staff replies
* - channel_ids (filterable) — Lunar's base indexer only indexes "status" as
* filterable, not channel assignment, so search results can't otherwise be
* scoped to products actually assigned+enabled on the current sales channel
*
* 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.
*
* New fields aren't filterable in Meilisearch until `php artisan lunar:meilisearch:setup`
* re-syncs index settings, and existing documents need `lunar:search:index --refresh` to
* pick up the new shape — see docs/product-listing.md. If SCOUT_QUEUE is enabled, the
* queue worker also needs restarting after deploying changes to this class (see
* docs/lunar.md "Gotchas" — a running worker keeps stale indexer code in memory).
*/
class ProductIndexer extends BaseProductIndexer
{
public function getFilterableFields(): array
{
return [
...parent::getFilterableFields(),
'id',
'brand',
'collections.id',
'price',
'slugs',
'channel_ids',
];
}
public function getSortableFields(): array
{
return [
...parent::getSortableFields(),
'price',
];
}
public function makeAllSearchableUsing(Builder $query): Builder
{
return parent::makeAllSearchableUsing($query)->with([
'collections',
'media',
'tags',
'urls',
'variants.images',
'variants.prices',
'variants.values.option',
]);
}
public function toSearchableArray(Model $model): array
{
/** @var Product $model */
$data = parent::toSearchableArray($model);
$currency = Currency::getDefault();
$reviews = ProductReview::where('product_id', $model->id)->with('media')->get();
$data['collections'] = $model->collections->map(fn ($collection) => [
'id' => $collection->id,
'name' => $collection->translateAttribute('name'),
])->all();
$data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all();
$data['tags'] = $model->tags->pluck('value')->all();
$data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all();
$data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all();
$data['price'] = $this->cheapestPrice($model, $currency);
$data['reviews'] = [
'items' => $reviews->map(fn (ProductReview $review) => $this->mapReview($review))->all(),
'count' => $reviews->count(),
'average_rating' => $reviews->isEmpty() ? null : round($reviews->avg('rating'), 1),
];
$data['channel_ids'] = $model->channels()
->wherePivot('enabled', true)
->pluck('lunar_channels.id')
->toArray();
return $data;
}
private function mapVariant(ProductVariant $variant, Currency $currency): array
{
return [
'id' => $variant->id,
'sku' => $variant->sku,
'stock' => $variant->stock,
'purchasable' => $variant->purchasable,
'options' => $variant->values->map(fn ($value) => [
'option' => $this->translatedName($value->option->name),
'handle' => $value->option->handle,
'value' => $this->translatedName($value->name),
'meta' => $value->meta,
])->all(),
'prices' => $variant->prices->map(fn (Price $price) => [
'currency_id' => $price->currency_id,
'customer_group_id' => $price->customer_group_id,
'price' => $price->price->decimal(),
'compare_price' => $price->compare_price?->decimal(),
'min_quantity' => $price->min_quantity,
])->all(),
'media' => $variant->images->map(fn (Media $media) => $this->mapMedia($media))->all(),
];
}
/**
* Public-safe fields only — reviewer_email is PII with no storefront use and is
* deliberately excluded, unlike every other column on the review. reply/replied_at
* (the staff response) are included since they're meant to be shown alongside the
* review on the storefront.
*/
private function mapReview(ProductReview $review): array
{
return [
'id' => $review->id,
'title' => $review->title,
'body' => $review->body,
'rating' => $review->rating,
'reviewed_at' => $review->reviewed_at?->timestamp,
'reviewer_name' => $review->reviewer_name,
'reply' => $review->reply,
'replied_at' => $review->replied_at?->timestamp,
'location' => $review->location,
'media' => $review->media->map(fn (Media $media) => $this->mapMedia($media))->all(),
];
}
/**
* ProductOption/ProductOptionValue's `name` is a plain locale-keyed array cast
* (AsArrayObject) directly on the column — unlike Product/Collection/Brand, it is
* not stored in attribute_data. Lunar's translateAttribute() only reads
* attribute_data, so it silently returns null for these two models; this reads
* the array directly instead. Falls back to the first available locale if the
* current one is missing. Not a general replacement for translateAttribute() —
* every other translated field in this indexer (product/collection name and
* description) genuinely is attribute_data-backed and translateAttribute() is
* correct for those.
*/
private function translatedName(mixed $name): ?string
{
$names = is_array($name) ? $name : (array) $name;
return $names[app()->getLocale()] ?? reset($names) ?: null;
}
private function mapMedia(Media $media): array
{
return [
'id' => $media->id,
'url' => $media->getUrl(),
'thumb' => $media->getUrl('small'),
];
}
/**
* The cheapest variant's base price (no customer group) in the default currency,
* as a float in major units — e.g. 19.99, not 1999. Null if the product has no
* variant with a price in that currency yet, so it's excluded from price filters
* rather than sorting to the bottom as if it were free.
*/
private function cheapestPrice(Product $model, Currency $currency): ?float
{
$price = $model->variants
->flatMap(fn ($variant) => $variant->prices)
->filter(fn ($price) => $price->currency_id === $currency->id && $price->customer_group_id === null)
->min(fn ($price) => $price->price->value);
return $price !== null ? $price / (10 ** $currency->decimal_places) : null;
}
}
@@ -0,0 +1,68 @@
<?php
namespace Modules\Core\Catalog\Services;
use Modules\Core\Catalog\Contracts\ProductOptionTypeInterface;
/**
* Resolves an admin-selected option type key to the `ProductOptionTypeInterface`
* describing it. The selection (which key a given `Lunar\Models\ProductOption` uses)
* is stored per-option in `ProductOption::meta['option_type']` — deliberately not
* tied to the option's `handle`, since a shop's own handle naming (e.g. transliterated
* Greek, legacy imports) shouldn't have to match a type's key.
*
* A singleton registry, same shape as `Modules\Core\Notification\NotificationRegistry`
* — a consuming app calls `ProductOptionTypeManager::get()->register([...])` from its
* own service provider `boot()`, rather than listing classes in a published config
* file.
*/
class ProductOptionTypeManager
{
private static ?self $instance = null;
/** @var array<string, class-string<ProductOptionTypeInterface>> */
private array $types = [];
private function __construct() {}
public static function get(): static
{
if (static::$instance === null) {
static::$instance = new static();
}
return static::$instance;
}
/**
* @param array<class-string<ProductOptionTypeInterface>> $types
*/
public function register(array $types): void
{
foreach ($types as $class) {
$this->types[$class::getKey()] = $class;
}
}
public function unregister(string $key): void
{
unset($this->types[$key]);
}
public function resolve(?string $key): ?ProductOptionTypeInterface
{
if ($key === null || ! isset($this->types[$key])) {
return null;
}
return app($this->types[$key]);
}
/**
* @return array<string, class-string<ProductOptionTypeInterface>>
*/
public function all(): array
{
return $this->types;
}
}
@@ -0,0 +1,56 @@
<?php
namespace Modules\Core\Catalog\Services;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\App;
use Lunar\Facades\AttributeManifest;
use Lunar\Models\Language;
use Lunar\Models\Product;
/**
* Lunar's Meilisearch indexer flattens translated attributes into locale-suffixed
* fields on a single document (name_en, name_el, description_en, description_el —
* see Lunar\Search\ScoutIndexer::mapSearchableAttributes()), not separate indexes
* or a filterable locale field. Locale-aware search means choosing which fields
* to search on, not filtering results by locale.
*/
class ProductSearchService
{
/**
* @return Collection<int, Product>
*/
public function search(string $query, ?string $locale = null): Collection
{
$locale ??= App::getLocale();
$defaultLocale = Language::getDefault()->code;
return Product::search($query)
->options([
'attributesToSearchOn' => $this->searchableFields($locale, $defaultLocale),
])
->get();
}
/**
* Target the resolved locale's fields plus the default locale's fields, so a
* product that's only ever been translated into the default language still
* surfaces when searched in another locale, instead of becoming invisible
* until every product is fully translated.
*
* @return array<int, string>
*/
private function searchableFields(string $locale, string $defaultLocale): array
{
$handles = AttributeManifest::getSearchableAttributes(Product::morphName())
->pluck('handle');
$locales = array_unique([$locale, $defaultLocale]);
return $handles
->crossJoin($locales)
->map(fn (array $pair) => "{$pair[0]}_{$pair[1]}")
->values()
->all();
}
}
+168
View File
@@ -0,0 +1,168 @@
<?php
namespace Modules\Core\Catalog\Services;
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Lunar\Base\AttributeManifest;
use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Product;
use Modules\Core\Localization\Services\LanguageCache;
use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\Enums\ProductSort;
/**
* Storefront product listing/filtering AND single-product lookup, all reading directly
* from the Meilisearch index (Modules\Core\Catalog\Services\ProductIndexer) - one data
* source, no ->get() model hydration anywhere in this service. Callers get plain arrays
* of the indexed document, not Eloquent models.
*
* Full-text query search lives separately in Modules\Core\Catalog\Services\
* ProductSearchService; this service is for browsing/filtering without a search term.
*/
class ProductService
{
public function __construct(
private readonly LanguageCache $languages,
private readonly AttributeManifest $attributes,
) {}
/**
* Returns a real LengthAwarePaginator (not Scout's own paginateRaw() result -
* see "Meilisearch driver quirk" below) so a controller/view gets normal
* pagination behaviour ($products->links(), JSON serialization, etc.)
* without ever touching the raw Meilisearch response directly.
*/
public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1, ?ProductSort $sort = null): LengthAwarePaginator
{
$options = ['filter' => $this->buildFilter($filters)];
if ($sort !== null) {
$options['sort'] = [$sort->toMeilisearchSort()];
}
$paginator = Product::search('')
->options($options)
->paginateRaw(perPage: $perPage, page: $page);
$data = collect($this->hitsFrom($paginator))
->map(fn (array $product) => $this->withLocalizedFields($product))
->all();
return new LengthAwarePaginator(
items: $data,
total: $paginator->total(),
perPage: $paginator->perPage(),
currentPage: $paginator->currentPage(),
options: ['path' => LengthAwarePaginator::resolveCurrentPath()],
);
}
/**
* Look up a single product by its URL slug (any locale - slugs are indexed across
* all languages, see Modules\Core\Catalog\Services\ProductIndexer). Returns the full
* indexed product document, or null if no product has that slug.
*/
public function getBySlug(string $slug): ?array
{
return $this->findOneWhere('slugs = "'.addcslashes($slug, '"\\').'"');
}
/**
* Look up a single product by its primary key. Returns the full indexed product
* document, or null if no product has that id.
*/
public function getById(int $id): ?array
{
return $this->findOneWhere("id = \"{$id}\"");
}
private function findOneWhere(string $filter): ?array
{
$paginator = Product::search('')
->options(['filter' => $filter])
->paginateRaw(perPage: 1, page: 1);
$product = $this->hitsFrom($paginator)[0] ?? null;
return $product !== null ? $this->withLocalizedFields($product) : null;
}
/**
* Resolves every translated Product attribute's current-locale value from the
* indexer's per-locale `{handle}_{locale}` fields (e.g. `name_el`, `name_en`,
* `seo_title_el`, ...) into a plain `{handle}` key, falling back to the store's
* default language (LanguageCache::defaultLocale()) when the current locale
* has no translation - e.g. a product with no English copy yet still shows its
* Greek name on /en/ rather than rendering blank.
*
* Which handles are translated is read from AttributeManifest - the same
* source Lunar's own ScoutIndexer reads when exploding a TranslatedText
* attribute into `{handle}_{locale}` keys at index time - rather than a fixed
* list, so a store's own custom translated attributes (e.g. `seo_title`) are
* picked up automatically with no change here. The raw per-locale keys are
* then stripped, since once resolved, callers only ever need the one that
* matched the current locale.
*
* Deliberately not config('app.locale') - App::setLocale() overwrites that
* config value on every request, so by request time it's just whatever the
* current locale already is, not a stable fallback.
*/
private function withLocalizedFields(array $product): array
{
$locale = App::getLocale();
$fallbackLocale = $this->languages->defaultLocale();
$availableLocales = $this->languages->availableLocales();
foreach ($this->translatedAttributeHandles() as $handle) {
$product[$handle] = $product[$handle.'_'.$locale] ?? $product[$handle.'_'.$fallbackLocale] ?? null;
foreach ($availableLocales as $availableLocale) {
unset($product[$handle.'_'.$availableLocale]);
}
}
return $product;
}
/**
* @return array<int, string>
*/
private function translatedAttributeHandles(): array
{
return $this->attributes->getSearchableAttributes((new Product)->getMorphClass())
->filter(fn ($attribute) => $attribute->type === TranslatedText::class)
->pluck('handle')
->all();
}
/**
* For the Meilisearch driver, Scout's paginateRaw() puts the whole raw response
* (hits, query, processingTimeMs, ...) in items(), not a plain list of hits - the
* actual documents are under the 'hits' key.
*/
private function hitsFrom(LengthAwarePaginatorContract $paginator): array
{
$rawResponse = $paginator->items();
return collect($rawResponse['hits'] ?? [])->values()->all();
}
private function buildFilter(?ProductFilters $filters): ?string
{
if ($filters === null) {
return null;
}
$clauses = Collection::make([
$filters->collectionId !== null ? "collections.id = \"{$filters->collectionId}\"" : null,
$filters->brand !== null ? 'brand = "'.addcslashes($filters->brand, '"\\').'"' : null,
$filters->minPrice !== null ? "price >= {$filters->minPrice}" : null,
$filters->maxPrice !== null ? "price <= {$filters->maxPrice}" : null,
])->filter();
return $clauses->isEmpty() ? null : $clauses->join(' AND ');
}
}
+2 -2
View File
@@ -17,9 +17,9 @@ use Lunar\Shipping\ShippingPlugin;
use Modules\Core\Auth\Extensions\StaffResourceExtension; use Modules\Core\Auth\Extensions\StaffResourceExtension;
use Modules\Core\Auth\Filament\Pages\Login; use Modules\Core\Auth\Filament\Pages\Login;
use Modules\Core\Auth\Mail\InviteMail; use Modules\Core\Auth\Mail\InviteMail;
use Modules\Core\Catalog\Filament\Extensions\ProductOptionResourceExtension;
use Modules\Core\Catalog\Filament\Extensions\ValuesRelationManagerExtension;
use Modules\Core\Localization\Filament\Resources\LanguageLineResource; use Modules\Core\Localization\Filament\Resources\LanguageLineResource;
use Modules\Core\Product\Filament\Extensions\ProductOptionResourceExtension;
use Modules\Core\Product\Filament\Extensions\ValuesRelationManagerExtension;
use Modules\Core\Review\Extensions\ProductResourceExtension; use Modules\Core\Review\Extensions\ProductResourceExtension;
use Modules\Core\Review\Models\ProductReview; use Modules\Core\Review\Models\ProductReview;
+2 -2
View File
@@ -9,7 +9,7 @@ use Lunar\Models\Language;
/** /**
* Cached read layer over Lunar's `languages` table — the single source both * Cached read layer over Lunar's `languages` table — the single source both
* Modules\Core\Localization\Middleware\LocaleMiddleware (request-time locale resolution) and * Modules\Core\Localization\Middleware\LocaleMiddleware (request-time locale resolution) and
* any other locale-aware code (e.g. Modules\Core\Product\Services\ProductService) read * any other locale-aware code (e.g. Modules\Core\Catalog\Services\ProductService) read
* from, so the language list is fetched once per cache lifetime rather than once * from, so the language list is fetched once per cache lifetime rather than once
* per caller. Cached forever, invalidated via forget() by * per caller. Cached forever, invalidated via forget() by
* Modules\Core\Localization\Listeners\FlushLanguageCache on * Modules\Core\Localization\Listeners\FlushLanguageCache on
@@ -41,7 +41,7 @@ class LanguageCache
/** /**
* Every configured store locale code (e.g. ['el', 'en']) - for code that needs * Every configured store locale code (e.g. ['el', 'en']) - for code that needs
* to enumerate all locales a TranslatedText attribute was indexed under (see * to enumerate all locales a TranslatedText attribute was indexed under (see
* Modules\Core\Product\Services\ProductService::withLocalizedFields()), rather than * Modules\Core\Catalog\Services\ProductService::withLocalizedFields()), rather than
* hardcoding locale codes. * hardcoding locale codes.
* *
* @return array<int, string> * @return array<int, string>
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Modules\Core\Providers;
use Illuminate\Support\ServiceProvider;
use Lunar\Models\ProductOption;
use Lunar\Models\ProductOptionValue;
use Modules\Core\Catalog\Observers\ProductOptionReindexObserver;
use Modules\Core\Catalog\OptionTypes\ColorOptionType;
use Modules\Core\Catalog\Services\ProductOptionTypeManager;
class CatalogServiceProvider extends ServiceProvider
{
public function boot(): void
{
ProductOptionTypeManager::get()->register([
ColorOptionType::class,
]);
$observer = new ProductOptionReindexObserver;
ProductOption::saved(fn (ProductOption $option) => $observer->optionSaved($option));
ProductOption::deleted(fn (ProductOption $option) => $observer->optionDeleted($option));
ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value));
ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value));
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ use Modules\Core\Review\Models\ProductReview;
* Keeps a product's Meilisearch document in sync with its reviews. A review is * Keeps a product's Meilisearch document in sync with its reviews. A review is
* created/edited independently of its product (customer submission, staff reply), * created/edited independently of its product (customer submission, staff reply),
* so the product's own save/update events never fire for it — without this listener, * so the product's own save/update events never fire for it — without this listener,
* Modules\Core\Product\Services\ProductIndexer's review data would only refresh on * Modules\Core\Catalog\Services\ProductIndexer's review data would only refresh on
* the next full product reindex. * the next full product reindex.
*/ */
class ReviewServiceProvider extends ServiceProvider class ReviewServiceProvider extends ServiceProvider
+1 -1
View File
@@ -38,7 +38,7 @@ class ProductReview extends Model implements HasMedia
* Unlike Product/ProductVariant, this model sits outside Lunar's own * Unlike Product/ProductVariant, this model sits outside Lunar's own
* MediaDefinitionsInterface (Lunar\Base\StandardMediaDefinitions), which is * MediaDefinitionsInterface (Lunar\Base\StandardMediaDefinitions), which is
* what registers the 'small' conversion those models get automatically. Without * what registers the 'small' conversion those models get automatically. Without
* this, Modules\Core\Product\Services\ProductIndexer::mapMedia() — shared across * this, Modules\Core\Catalog\Services\ProductIndexer::mapMedia() — shared across
* product, variant, and review media — throws Spatie\MediaLibrary\MediaCollections\ * product, variant, and review media — throws Spatie\MediaLibrary\MediaCollections\
* Exceptions\InvalidConversion the first time a review has an image, since * Exceptions\InvalidConversion the first time a review has an image, since
* $media->getUrl('small') has no matching conversion to resolve. * $media->getUrl('small') has no matching conversion to resolve.