> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lunarphp.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Product Listing Page

> Build a product listing page (PLP) with Lunar, covering collection resolution, product querying, filtering, sorting, pagination, and display.

## 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

```php theme={null}
use App\Http\Controllers\CollectionController;

Route::get('/collections/{slug}', [CollectionController::class, 'show'])->name('collections.show');
```

### Resolve the Collection

```php theme={null}
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.

```blade theme={null}
<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.

```blade theme={null}
@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.

```blade theme={null}
<nav aria-label="Breadcrumb">
    <ol>
        @foreach($collection->breadcrumb as $name)
            <li>{{ $name }}</li>
        @endforeach

        <li aria-current="page">{{ $collection->attr('name') }}</li>
    </ol>
</nav>
```

<Tip>
  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')`.
</Tip>

## Querying Products

The `products()` relationship on a Collection returns products ordered by the collection's configured `position` pivot column.

### Basic Query

```php theme={null}
$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.

```php theme={null}
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.

```php theme={null}
$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                                            |

<Info>
  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.
</Info>

### Custom Sort on the Query

To let customers choose a sort order at browse time, override the default pivot ordering on the query.

```php theme={null}
$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);
```

<Tip>
  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.
</Tip>

## Filtering

Product filtering depends on the storefront's requirements. Below are some common filtering approaches.

### Filtering by Brand

```php theme={null}
if ($brandId = $request->get('brand')) {
    $query->where('brand_id', $brandId);
}
```

To build a brand filter list from the products in the collection:

```php theme={null}
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

```php theme={null}
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);
    });
}
```

<Info>
  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.
</Info>

### Filtering by Product Availability

```php theme={null}
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

```blade theme={null}
<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.

```blade theme={null}
{{ $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.

```php theme={null}
$children = $collection->children()
    ->with('media')
    ->get();
```

```blade theme={null}
@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 theme={null}
<?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

* Review the [Collections reference](/1.x/reference/collections) for the full list of model fields, relationships, and sort options.
* Review the [Products reference](/1.x/reference/products) for product scopes and variant details.
* Review the [Storefront Session reference](/1.x/storefront-utils/storefront-session) to manage channel, currency, and customer group context.
* Review the [Product Display Page guide](/1.x/guides/product-display-page) for displaying individual products after a customer clicks through from the listing.
