Overview
A Product Listing Page (PLP) displays a grid or list of products within a collection (category). Customers browse products, filter by attributes, sort results, and click through to individual product pages. This guide walks through building a PLP using Lunar’s models and facades.
The examples below use standard Laravel controllers and Blade templates. The same concepts apply whether the storefront is built with Livewire, Inertia, or a headless API.
Resolving a Collection from a URL
Like products, collections use Lunar’s Url model for slug-based lookups.
Define the Route
use App\Http\Controllers\CollectionController;
Route::get('/collections/{slug}', [CollectionController::class, 'show'])->name('collections.show');
Resolve the Collection
use Lunar\Models\Collection;
use Lunar\Models\Url;
class CollectionController extends Controller
{
public function show(string $slug)
{
$url = Url::where('slug', $slug)
->where('element_type', (new Collection)->getMorphClass())
->firstOrFail();
$collection = Collection::where('id', $url->element_id)
->with('media')
->firstOrFail();
// ...
}
}
Collection names and descriptions are stored as attribute data, accessed with the attr() method.
<h1>{{ $collection->attr('name') }}</h1>
@if($collection->attr('description'))
<div>{!! $collection->attr('description') !!}</div>
@endif
Collection Image
Collections support media through Spatie MediaLibrary, just like products.
@if($collection->getFirstMediaUrl('images', 'large'))
<img
src="{{ $collection->getFirstMediaUrl('images', 'large') }}"
alt="{{ $collection->attr('name') }}"
>
@endif
Breadcrumbs
Collections form a nested hierarchy. The breadcrumb attribute returns the translated names of all ancestor collections, which is useful for building breadcrumb navigation.
<nav aria-label="Breadcrumb">
<ol>
@foreach($collection->breadcrumb as $name)
<li>{{ $name }}</li>
@endforeach
<li aria-current="page">{{ $collection->attr('name') }}</li>
</ol>
</nav>
The breadcrumb attribute uses the ancestors relationship from the nested set. If displaying breadcrumbs on every page load, consider eager loading ancestors to avoid extra queries: $collection->load('ancestors').
Querying Products
The products() relationship on a Collection returns products ordered by the collection’s configured position pivot column.
Basic Query
$products = $collection->products()
->where('status', 'published')
->with([
'variants.prices.currency',
'media',
'brand',
])
->paginate(24);
Filtering by Channel
Products can be scheduled against channels. Use the channel scope to return only products that are active on the current channel.
use Lunar\Facades\StorefrontSession;
$channel = StorefrontSession::getChannel();
$products = $collection->products()
->where('status', 'published')
->channel($channel)
->with([
'variants.prices.currency',
'media',
'brand',
])
->paginate(24);
Filtering by Customer Group
Similarly, use the customerGroup scope to return only products visible to the current customer group.
$customerGroups = StorefrontSession::getCustomerGroups();
$products = $collection->products()
->where('status', 'published')
->channel($channel)
->customerGroup($customerGroups)
->with([
'variants.prices.currency',
'media',
'brand',
])
->paginate(24);
Sorting
Products in a collection have a default sort order determined by the collection’s sort field. The products() relationship already orders by the position pivot column, which reflects this configured sort.
The available sort options built into Lunar are:
| Sort Value | Description |
|---|
custom | Manual ordering via the position pivot column (default) |
min_price:asc | Lowest base price first |
min_price:desc | Highest base price first |
sku:asc | SKU ascending (alphabetical) |
sku:desc | SKU descending |
The position values on the pivot table are automatically recalculated when the collection’s sort field is changed. For example, setting sort to min_price:asc triggers a background job that reorders the position values by price.
Custom Sort on the Query
To let customers choose a sort order at browse time, override the default pivot ordering on the query.
$sortOptions = [
'price_asc' => 'Price: Low to High',
'price_desc' => 'Price: High to Low',
'newest' => 'Newest First',
];
$sort = $request->get('sort', 'default');
$query = $collection->products()
->where('status', 'published')
->channel($channel)
->customerGroup($customerGroups)
->with([
'variants.prices.currency',
'media',
'brand',
]);
$query = match ($sort) {
'newest' => $query->reorder()->latest(),
'price_asc' => $query->reorder()
->join(
'lunar_product_variants',
'lunar_product_variants.product_id',
'=',
'lunar_products.id'
)
->join(
'lunar_prices',
'lunar_prices.priceable_id',
'=',
'lunar_product_variants.id'
)
->where('lunar_prices.priceable_type', (new \Lunar\Models\ProductVariant)->getMorphClass())
->where('lunar_prices.min_quantity', 1)
->whereNull('lunar_prices.customer_group_id')
->orderBy('lunar_prices.price', 'asc'),
'price_desc' => $query->reorder()
->join(
'lunar_product_variants',
'lunar_product_variants.product_id',
'=',
'lunar_products.id'
)
->join(
'lunar_prices',
'lunar_prices.priceable_id',
'=',
'lunar_product_variants.id'
)
->where('lunar_prices.priceable_type', (new \Lunar\Models\ProductVariant)->getMorphClass())
->where('lunar_prices.min_quantity', 1)
->whereNull('lunar_prices.customer_group_id')
->orderBy('lunar_prices.price', 'desc'),
default => $query, // Uses the collection's configured position ordering
};
$products = $query->paginate(24);
For simpler sorting needs, the default position ordering is often sufficient. The collection’s sort field can be changed in the admin panel to reorder products by price or SKU without requiring custom query logic.
Filtering
Product filtering depends on the storefront’s requirements. Below are some common filtering approaches.
Filtering by Brand
if ($brandId = $request->get('brand')) {
$query->where('brand_id', $brandId);
}
To build a brand filter list from the products in the collection:
use Lunar\Models\Brand;
$brandIds = $collection->products()
->where('status', 'published')
->pluck('brand_id')
->unique()
->filter();
$brands = Brand::whereIn('id', $brandIds)->orderBy('name')->get();
Filtering by Price Range
if ($minPrice = $request->get('min_price')) {
$query->whereHas('variants.prices', function ($priceQuery) use ($minPrice) {
$priceQuery->where('price', '>=', (int) $minPrice * 100);
});
}
if ($maxPrice = $request->get('max_price')) {
$query->whereHas('variants.prices', function ($priceQuery) use ($maxPrice) {
$priceQuery->where('price', '<=', (int) $maxPrice * 100);
});
}
Prices are stored as integers in the lowest denomination (e.g., cents). Multiply the customer-facing price by 100 (or the currency’s factor) before comparing.
Filtering by Product Availability
if ($request->boolean('in_stock')) {
$query->whereHas('variants', function ($variantQuery) {
$variantQuery->where(function ($q) {
$q->where('purchasable', 'always')
->orWhere('stock', '>', 0);
});
});
}
Displaying Products
Product Grid
<div class="product-grid">
@foreach($products as $product)
@php
$variant = $product->variants->first();
$pricing = \Lunar\Facades\Pricing::for($variant)
->currency(\Lunar\Facades\StorefrontSession::getCurrency())
->customerGroups(\Lunar\Facades\StorefrontSession::getCustomerGroups())
->get();
$price = $pricing->matched;
@endphp
<a href="{{ route('products.show', $product->defaultUrl?->slug) }}" class="product-card">
<img
src="{{ $product->getFirstMediaUrl('images', 'medium') }}"
alt="{{ $product->attr('name') }}"
>
<h3>{{ $product->attr('name') }}</h3>
@if($product->brand)
<p>{{ $product->brand->name }}</p>
@endif
<p>{{ $price->price->formatted() }}</p>
@if($price->compare_price?->value)
<p><s>{{ $price->compare_price->formatted() }}</s></p>
@endif
</a>
@endforeach
</div>
Laravel’s built-in pagination works directly with the collection product query.
{{ $products->withQueryString()->links() }}
Using withQueryString() preserves any active filter and sort parameters in the pagination links.
Child Collections
Collections can contain child collections (subcategories). Display them to help customers navigate deeper into the catalog.
$children = $collection->children()
->with('media')
->get();
@if($children->isNotEmpty())
<div class="subcategories">
@foreach($children as $child)
<a href="{{ route('collections.show', $child->defaultUrl?->slug) }}">
@if($child->getFirstMediaUrl('images', 'medium'))
<img
src="{{ $child->getFirstMediaUrl('images', 'medium') }}"
alt="{{ $child->attr('name') }}"
>
@endif
<span>{{ $child->attr('name') }}</span>
</a>
@endforeach
</div>
@endif
Putting It All Together
Here is a complete controller that prepares all the data a PLP needs:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Lunar\Facades\Pricing;
use Lunar\Facades\StorefrontSession;
use Lunar\Models\Brand;
use Lunar\Models\Collection;
use Lunar\Models\Url;
class CollectionController extends Controller
{
public function show(Request $request, string $slug)
{
$url = Url::where('slug', $slug)
->where('element_type', (new Collection)->getMorphClass())
->firstOrFail();
$collection = Collection::where('id', $url->element_id)
->with('media')
->firstOrFail();
$channel = StorefrontSession::getChannel();
$customerGroups = StorefrontSession::getCustomerGroups();
$query = $collection->products()
->where('status', 'published')
->channel($channel)
->customerGroup($customerGroups)
->with([
'variants.prices.currency',
'media',
'brand',
'defaultUrl',
]);
// Apply filters
if ($brandId = $request->get('brand')) {
$query->where('brand_id', $brandId);
}
if ($request->boolean('in_stock')) {
$query->whereHas('variants', function ($q) {
$q->where(function ($q) {
$q->where('purchasable', 'always')
->orWhere('stock', '>', 0);
});
});
}
// Apply sort
$sort = $request->get('sort', 'default');
$query = match ($sort) {
'newest' => $query->reorder()->latest(),
default => $query,
};
$products = $query->paginate(24);
// Build filter options from the collection's products
$brandIds = $collection->products()
->where('status', 'published')
->pluck('brand_id')
->unique()
->filter();
$brands = Brand::whereIn('id', $brandIds)->orderBy('name')->get();
$children = $collection->children()
->with('media', 'defaultUrl')
->get();
return view('collections.show', [
'collection' => $collection,
'products' => $products,
'brands' => $brands,
'children' => $children,
'activeFilters' => $request->only(['brand', 'in_stock', 'sort']),
]);
}
}
Next Steps