Skip to main content

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();

        // ...
    }
}

Displaying Collection Information

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
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 ValueDescription
customManual ordering via the position pivot column (default)
min_price:ascLowest base price first
min_price:descHighest base price first
sku:ascSKU ascending (alphabetical)
sku:descSKU 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>

Pagination

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