4.2 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. |
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".
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.