Feature: Adding Product Service to Lunar

This commit introduces a Product service to Lunar. This product service calls Meilisearch to fetch an indexed product. The indexer has been updated to also include the collection and the price of the product.

A product search service has also been created to be used by the frontend's search
This commit is contained in:
2026-08-24 12:41:28 +03:00
parent f7da26b487
commit 5cb6c529a0
7 changed files with 387 additions and 8 deletions
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Modules\Core\Catalog;
/**
* 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\Search\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,
) {}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace Modules\Core\Catalog;
use Illuminate\Support\Collection;
use Lunar\Models\Product;
/**
* Storefront product listing/filtering, reading directly from the Meilisearch index
* (Modules\Core\Search\ProductIndexer) rather than the database — no ->get() model
* hydration, so callers get plain arrays of the indexed document, not Eloquent models.
* Consumers needing the full record (e.g. a product detail page) should look the
* product up directly via Lunar's Product model instead.
*
* Full-text query search lives separately in Modules\Core\Search\ProductSearchService;
* this service is for browsing/filtering without a search term.
*/
class ProductService
{
/**
* @return array{data: array<int, array>, meta: array}
*/
public function list(?ProductFilters $filters = null, int $perPage = 24, int $page = 1): array
{
$paginator = Product::search('')
->options([
'filter' => $this->buildFilter($filters),
])
->paginateRaw(perPage: $perPage, page: $page);
// 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.
$rawResponse = $paginator->items();
return [
'data' => collect($rawResponse['hits'] ?? [])->values()->all(),
'meta' => [
'total' => $paginator->total(),
'per_page' => $paginator->perPage(),
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
],
];
}
private function buildFilter(?ProductFilters $filters): ?string
{
if ($filters === null) {
return null;
}
$clauses = Collection::make([
$filters->collectionId !== null ? "collections = \"{$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 ');
}
}
+44 -8
View File
@@ -2,26 +2,62 @@
namespace Modules\Core\Search;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Lunar\Models\Currency;
use Lunar\Models\Product;
use Lunar\Search\ProductIndexer as BaseProductIndexer;
/**
* Lunar's own indexer puts raw attribute HTML (e.g. name_en, description_en) into
* the search index, which pollutes relevance ranking and highlighting with markup.
* Strip tags from string fields before they reach Meilisearch.
* Extends Lunar's own indexer to add fields needed for storefront listing/filtering
* (Modules\Core\Catalog\ProductService) that aren't part of Lunar's default document:
* collection membership and a comparable price. Neither is filterable in Meilisearch
* until `php artisan lunar:meilisearch:setup` re-syncs index settings and the index is
* refreshed — see docs/product-listing.md.
*/
class ProductIndexer extends BaseProductIndexer
{
public function getFilterableFields(): array
{
return [
...parent::getFilterableFields(),
'brand',
'collections',
'price',
];
}
public function makeAllSearchableUsing(Builder $query): Builder
{
return parent::makeAllSearchableUsing($query)->with(['collections', 'variants.prices']);
}
public function toSearchableArray(Model $model): array
{
/** @var Product $model */
$data = parent::toSearchableArray($model);
foreach ($data as $key => $value) {
if (is_string($value)) {
$data[$key] = trim(strip_tags($value));
}
}
$data['collections'] = $model->collections->pluck('id')->map(fn ($id) => (string) $id)->all();
$data['price'] = $this->cheapestPrice($model);
return $data;
}
/**
* 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): ?float
{
$currency = Currency::getDefault();
$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;
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace Modules\Core\Search;
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();
}
}