Feature: Products Restructuring to follow a strict rule

This commit is contained in:
2026-08-27 01:05:04 +03:00
parent b3b5ca740d
commit ef356a6397
11 changed files with 45 additions and 42 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\Catalog\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\Product\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\Search\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\Product\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\Catalog\ProductService` provides catalog browsing/filtering AND single-product `Modules\Core\Product\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\Search\ProductSearchService` (see `product-search.md`), which This is separate from `Modules\Core\Product\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\Search\ProductIndexer` is built to just the trimmed fields a listing page needs. `Modules\Core\Product\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\Catalog\ProductFilters; use Modules\Core\Product\DTOs\ProductFilters;
use Modules\Core\Catalog\ProductService; use Modules\Core\Product\Services\ProductService;
use Modules\Core\Catalog\ProductSort; use Modules\Core\Product\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\Search\ProductIndexer` ## Fields this depends on: `Modules\Core\Product\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\Search\ProductIndexer` extends it to add everything `ProductService` filterable. `Modules\Core\Product\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\Catalog\ProductSort`) is a fixed enum of supported sort orders — `ProductSort` (`Modules\Core\Product\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\Search\ProductIndexer::getSortableFields()` marks sortable (`price`, plus `Modules\Core\Product\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\Search\ProductIndexer::class, Lunar\Models\Product::class => Modules\Core\Product\Services\ProductIndexer::class,
// ...other model indexers unchanged // ...other model indexers unchanged
], ],
``` ```
+2 -2
View File
@@ -1,6 +1,6 @@
# Product Search # Product Search
`Modules\Core\Search\ProductSearchService` provides locale-aware full-text product search on `Modules\Core\Product\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\Search\ProductSearchService; use Modules\Core\Product\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():
+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\LocaleMiddleware (request-time locale resolution) and * Modules\Core\Localization\LocaleMiddleware (request-time locale resolution) and
* any other locale-aware code (e.g. Modules\Core\Catalog\ProductService) read * any other locale-aware code (e.g. Modules\Core\Product\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\Catalog\ProductService::withLocalizedFields()), rather than * Modules\Core\Product\Services\ProductService::withLocalizedFields()), rather than
* hardcoding locale codes. * hardcoding locale codes.
* *
* @return array<int, string> * @return array<int, string>
@@ -1,13 +1,13 @@
<?php <?php
namespace Modules\Core\Catalog; namespace Modules\Core\Product\DTOs;
/** /**
* Filter input for ProductService::list(). All fields are optional — omitted * Filter input for ProductService::list(). All fields are optional — omitted
* filters are simply not added to the Meilisearch query. Values are matched * filters are simply not added to the Meilisearch query. Values are matched
* against Modules\Core\Search\ProductIndexer's document fields, so filtering * against Modules\Core\Product\Services\ProductIndexer's document fields, so
* only works on stores where that indexer is registered and the index has * filtering only works on stores where that indexer is registered and the index
* been re-synced (see docs/product-listing.md). * has been re-synced (see docs/product-listing.md).
*/ */
class ProductFilters class ProductFilters
{ {
@@ -1,12 +1,13 @@
<?php <?php
namespace Modules\Core\Catalog; namespace Modules\Core\Product\Enums;
/** /**
* Sort options for ProductService::list(), each mapped to a Meilisearch `sort` * Sort options for ProductService::list(), each mapped to a Meilisearch `sort`
* clause against a field indexed as sortable by Modules\Core\Search\ProductIndexer * clause against a field indexed as sortable by Modules\Core\Product\Services\
* (see its getSortableFields()). Adding a case here requires the matching field * ProductIndexer (see its getSortableFields()). Adding a case here requires the
* to also be sortable in the index, re-synced via `php artisan lunar:meilisearch:setup`. * matching field to also be sortable in the index, re-synced via
* `php artisan lunar:meilisearch:setup`.
*/ */
enum ProductSort: string enum ProductSort: string
{ {
@@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Core\Search; namespace Modules\Core\Product\Services;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -13,9 +13,9 @@ use Modules\Core\Review\Models\ProductReview;
use Spatie\MediaLibrary\MediaCollections\Models\Media; use Spatie\MediaLibrary\MediaCollections\Models\Media;
/** /**
* Extends Lunar's own indexer so Modules\Core\Catalog\ProductService can serve both * Extends Lunar's own indexer so Modules\Core\Product\Services\ProductService can
* listing/filtering AND single-product lookups from Meilisearch alone — one data * serve both listing/filtering AND single-product lookups from Meilisearch alone —
* source, no separate database read path for a product detail page. Adds: * one data source, no separate database read path for a product detail page. Adds:
* - collections (ids, filterable) and collection_names (display) * - collections (ids, filterable) and collection_names (display)
* - slugs (every locale's Url::slug for the product, filterable) — lets * - slugs (every locale's Url::slug for the product, filterable) — lets
* ProductService::getBySlug() resolve a product from the index directly, with * ProductService::getBySlug() resolve a product from the index directly, with
@@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Core\Search; namespace Modules\Core\Product\Services;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
@@ -1,6 +1,6 @@
<?php <?php
namespace Modules\Core\Catalog; namespace Modules\Core\Product\Services;
use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract; use Illuminate\Contracts\Pagination\LengthAwarePaginator as LengthAwarePaginatorContract;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
@@ -10,15 +10,17 @@ use Lunar\Base\AttributeManifest;
use Lunar\FieldTypes\TranslatedText; use Lunar\FieldTypes\TranslatedText;
use Lunar\Models\Product; use Lunar\Models\Product;
use Modules\Core\Localization\Services\LanguageCache; use Modules\Core\Localization\Services\LanguageCache;
use Modules\Core\Product\DTOs\ProductFilters;
use Modules\Core\Product\Enums\ProductSort;
/** /**
* Storefront product listing/filtering AND single-product lookup, all reading directly * Storefront product listing/filtering AND single-product lookup, all reading directly
* from the Meilisearch index (Modules\Core\Search\ProductIndexer) - one data source, no * from the Meilisearch index (Modules\Core\Product\Services\ProductIndexer) - one data
* ->get() model hydration anywhere in this service. Callers get plain arrays of the * source, no ->get() model hydration anywhere in this service. Callers get plain arrays
* indexed document, not Eloquent models. * of the indexed document, not Eloquent models.
* *
* Full-text query search lives separately in Modules\Core\Search\ProductSearchService; * Full-text query search lives separately in Modules\Core\Product\Services\
* this service is for browsing/filtering without a search term. * ProductSearchService; this service is for browsing/filtering without a search term.
*/ */
class ProductService class ProductService
{ {
@@ -60,8 +62,8 @@ class ProductService
/** /**
* Look up a single product by its URL slug (any locale - slugs are indexed across * Look up a single product by its URL slug (any locale - slugs are indexed across
* all languages, see Modules\Core\Search\ProductIndexer). Returns the full indexed * all languages, see Modules\Core\Product\Services\ProductIndexer). Returns the full
* product document, or null if no product has that slug. * indexed product document, or null if no product has that slug.
*/ */
public function getBySlug(string $slug): ?array public function getBySlug(string $slug): ?array
{ {
+2 -2
View File
@@ -9,8 +9,8 @@ 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\Search\ProductIndexer's review data would only refresh on the next * Modules\Core\Product\Services\ProductIndexer's review data would only refresh on
* full product reindex. * the next full product reindex.
*/ */
class ReviewServiceProvider extends ServiceProvider class ReviewServiceProvider extends ServiceProvider
{ {
+2 -2
View File
@@ -38,8 +38,8 @@ 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\Search\ProductIndexer::mapMedia() — shared across product, * this, Modules\Core\Product\Services\ProductIndexer::mapMedia() — shared across
* 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.
*/ */