# 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 ```php 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`: ```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.