5.6 KiB
Collections
Modules\Core\Catalog\Services\CollectionService provides category browsing/nav AND
single-collection lookup for a storefront — list(), getById(), getBySlug() —
all reading directly from the Meilisearch index, mirroring
Modules\Core\Catalog\Services\ProductService (see product-listing.md) exactly.
Why it reads from the index, not the database
Lunar's own Lunar\Search\CollectionIndexer only carries id/name/created_at —
nowhere near enough for a storefront category page or a nav tree.
Modules\Core\Catalog\Services\CollectionIndexer extends it to add everything
CollectionService needs:
| Field | Source | Notes |
|---|---|---|
parent_id |
$model->parent_id |
Filterable. The nested-set tree's parent pointer — null for a top-level collection. |
_lft |
$model->_lft |
Filterable and sortable. The nested-set tree position — lets CollectionService resolve tree order without a database read. |
collection_group_id |
$model->collection_group_id |
Filterable. Mirrors Collection::scopeInGroup(). |
slugs |
$model->urls->pluck('slug') |
Filterable. Every locale's Url::slug, so getBySlug() resolves purely from the index. |
thumbnail |
$model->getThumbnailImage() |
Display only. null if the collection has no thumbnail image. |
ancestors |
$model->ancestors |
Display only. Array of {id, name}, ordered root-first — a breadcrumb (Home > Apparel > Keychains) can render directly from a single getById()/getBySlug() call, no extra queries. Empty array for a top-level collection. |
product_count |
Queried from the product Meilisearch index at collection-index time | Display only. How many products are in this collection or any of its descendants — matches what ProductService::list(ProductFilters(collectionId: ...)) would return, not just direct assignment. Computed via Product::search('')->options(['filter' => "collection_ids = \"{id}\""]), so it depends on the product index already being current — reindex products before collections (see "Gotchas" below). |
name/description (and any other TranslatedText attribute) are indexed per-locale
by Lunar's base indexer and resolved by CollectionService exactly like
ProductService does — see product-listing.md's "Locale resolution" section, same
logic, same LanguageCache::defaultLocale() fallback.
Usage
use Modules\Core\Catalog\DTOs\CollectionFilters;
use Modules\Core\Catalog\Enums\CollectionSort;
use Modules\Core\Catalog\Services\CollectionService;
$service = app(CollectionService::class);
// Top-level collections only (parent_id IS NULL) — for building a nav tree
$roots = $service->list(
filters: new CollectionFilters(rootOnly: true),
sort: CollectionSort::Position,
);
// Children of a specific collection
$children = $service->list(
filters: new CollectionFilters(parentId: 222),
sort: CollectionSort::Position,
);
// Filter by collection group
$collections = $service->list(filters: new CollectionFilters(groupId: 4));
// Single collection, by primary key or slug
$collection = $service->getById(223);
$collection = $service->getBySlug('keychains');
CollectionFilters(parentId: ..., rootOnly: ...) are mutually exclusive — if both are
set, parentId wins. There's no parentId: null shorthand for "root only", since
that would be ambiguous with "don't filter by parent at all" (the DTO's actual
default); rootOnly names the root-collections case explicitly instead.
CollectionSort::Position (_lft:asc) is the recommended default for any nav/tree
UI — it matches the order an admin arranges collections in Lunar's own Filament UI.
Name and Newest are also available, mirroring ProductSort's shape.
Registration
Like ProductIndexer, CollectionIndexer must be registered in the consuming app's
own config/lunar/search.php:
'indexers' => [
Lunar\Models\Collection::class => Modules\Core\Catalog\Services\CollectionIndexer::class,
// ...
],
New/changed fields aren't filterable/sortable 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. If SCOUT_QUEUE is enabled,
the queue worker also needs restarting after deploying changes to the indexer class —
see docs/lunar.md "Gotchas".
product_count needs the product index reindexed first. config/lunar/search.php's
indexers array is typically ordered Collection before Product, so a plain
lunar:search:index --refresh computes product_count against whatever the product
index held before this run — stale if products changed too. lunar:search:index
takes an explicit model list as its argument (--ignore restricts it to only those),
so reindex products first, then collections, when both need a fresh --refresh in the
same deploy:
php artisan lunar:search:index "Lunar\Models\Product" --ignore --refresh
php artisan lunar:search:index "Lunar\Models\Collection" --ignore --refresh
When to still use Eloquent directly
A single collection's full detail page (breadcrumb via $collection->breadcrumb,
tree ancestors/descendants, route-model-bound Collection $collection in a
controller signature) should keep reading Eloquent directly rather than going through
CollectionService — the indexed document doesn't carry ancestor chains or the full
nested-set relations, and route-model binding already gives a controller the full
model for free. CollectionService is for browsing/listing and lightweight
by-id/by-slug lookups where a full Eloquent hydration would be wasteful, the same
tradeoff ProductService makes for products.