Files
core/docs/collections.md
T

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.