Skip to main content

Overview

Product search lets customers find products by typing keywords, browsing faceted filters, and navigating directly to products via URLs. This guide walks through building search and discovery features using Lunar’s Search facade and Url model. 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.

Search Setup

Lunar’s search system is provided by the lunarphp/search add-on package. It is built on top of Laravel Scout with a driver-based architecture that supports Meilisearch, Typesense, and a database fallback. The Search facade provides a consistent API regardless of which driver is active.

Installing the Search Package

composer require lunarphp/search
This installs the Search facade, search indexers, and data classes used throughout this guide.

Installing a Search Driver

For production storefronts, install either Meilisearch or Typesense. The database driver works out of the box but lacks faceting and advanced relevance features.
# Meilisearch
composer require meilisearch/meilisearch-php

# Typesense
composer require typesense/typesense-php
Set the driver in the application’s .env file:
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://127.0.0.1:7700
MEILISEARCH_KEY=your-master-key

Indexing Products

After configuring the driver, import existing products into the search index:
php artisan scout:import "Lunar\Models\Product"
Lunar automatically keeps the index in sync as products are created, updated, or deleted.

Searchable Models

The following models are searchable by default:
ModelDescription
Lunar\Models\ProductProducts with attributes, SKUs, brand, and product type
Lunar\Models\CollectionCollections with their attribute data
Lunar\Models\BrandBrands
Lunar\Models\CustomerCustomers (typically for admin use)
Lunar\Models\OrderOrders (typically for admin use)
Lunar\Models\ProductOptionProduct options

Searching Products

Use the Search facade to query products. The query() method accepts a search string, and get() returns a Lunar\Search\Data\SearchResults object. Products are the default model, so no additional configuration is needed.
use Lunar\Search\Facades\Search;

$results = Search::query('running shoes')->get();
To search a different model, use model() to specify it:
use Lunar\Models\Collection;
use Lunar\Search\Facades\Search;

$results = Search::model(Collection::class)->query('hoodies')->get();

Search Results

The SearchResults object contains everything needed to render a results page:
PropertyTypeDescription
query?stringThe search query that was executed
countintTotal number of matching results
pageintCurrent page number
perPageintResults per page
totalPagesintTotal number of pages
hitsSearchHit[]Array of search result hits
facetsSearchFacet[]Array of available facets with counts
linksViewPagination links
Each SearchHit contains:
PropertyTypeDescription
documentarrayThe indexed document data (id, name, brand, SKUs, etc.)
highlightsSearchHitHighlight[]Highlighted matches (Typesense only)

Building a Search Controller

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Lunar\Search\Facades\Search;

class SearchController extends Controller
{
    public function index(Request $request)
    {
        $query = $request->get('q', '');

        if (empty($query)) {
            return view('search.index', ['results' => null, 'query' => '']);
        }

        $results = Search::query($query)
            ->perPage(24)
            ->get();

        return view('search.index', [
            'results' => $results,
            'query' => $query,
        ]);
    }
}

Displaying Search Results

<form action="{{ route('search') }}" method="GET">
    <input
        type="search"
        name="q"
        value="{{ $query }}"
        placeholder="Search products..."
    >
    <button type="submit">Search</button>
</form>

@if($results)
    <p>{{ $results->count }} results for "{{ $results->query }}"</p>

    <div class="product-grid">
        @foreach($results->hits as $hit)
            <a href="{{ route('products.show', $hit->document['id']) }}">
                @if(! empty($hit->document['thumbnail']))
                    <img src="{{ $hit->document['thumbnail'] }}" alt="{{ $hit->document['name'] ?? '' }}">
                @endif

                <h3>{{ $hit->document['name'] ?? 'Untitled' }}</h3>

                @if(! empty($hit->document['brand']))
                    <p>{{ $hit->document['brand'] }}</p>
                @endif
            </a>
        @endforeach
    </div>

    {{ $results->links }}
@endif
Search hits contain the indexed document data, not Eloquent models. The indexed fields for products include id, status, product_type, brand, thumbnail, skus, and any attributes marked as searchable. To load full Eloquent models from search results, collect the IDs and query the database.

Loading Eloquent Models from Results

When full model data is needed (for example, to use the Pricing facade or access relationships), load the models from the hit IDs:
$productIds = collect($results->hits)->pluck('document.id');

$products = Product::whereIn('id', $productIds)
    ->with([
        'variants.prices.currency',
        'media',
        'brand',
        'defaultUrl',
    ])
    ->get()
    ->keyBy('id');
@foreach($results->hits as $hit)
    @php
        $product = $products[$hit->document['id']] ?? null;
    @endphp

    @if($product)
        <a href="{{ route('products.show', $product->defaultUrl?->slug) }}">
            <img
                src="{{ $product->getFirstMediaUrl('images', 'medium') }}"
                alt="{{ $product->attr('name') }}"
            >
            <h3>{{ $product->attr('name') }}</h3>
        </a>
    @endif
@endforeach

Sorting Results

Use the sort() method with a field:direction string to control result ordering.
$results = Search::query('shoes')
    ->sort('created_at:desc')
    ->get();
The sortable fields for products are created_at, updated_at, skus, and status. Additional sortable fields can be configured in a custom indexer.

Letting Customers Choose a Sort Order

$sort = $request->get('sort', '');

$engine = Search::query($request->get('q', ''));

if (in_array($sort, ['created_at:asc', 'created_at:desc'])) {
    $engine->sort($sort);
}

$results = $engine->get();

Faceted Filtering

Facets allow customers to narrow results by attributes like brand, size, or color. Facets are configured per model in the search configuration and are only available when using Meilisearch or Typesense.

Configuring Facets

Publish the search configuration and define facets for the Product model:
// config/lunar/search.php
return [
    'facets' => [
        \Lunar\Models\Product::class => [
            'brand' => [],
        ],
    ],
];
Each key in the facets array corresponds to a field in the product’s search index. Additional facets can be added for any indexed field.

Displaying Facets

After executing a search, the facets property on the results contains the available facet groups with value counts.
@foreach($results->facets as $facet)
    <div>
        <h4>{{ $facet->label }}</h4>

        @foreach($facet->values as $value)
            <label>
                <input
                    type="checkbox"
                    name="filters[{{ $facet->field }}][]"
                    value="{{ $value->value }}"
                    @checked($value->active)
                >
                {{ $value->label }} ({{ $value->count }})
            </label>
        @endforeach
    </div>
@endforeach
Each SearchFacet has:
PropertyTypeDescription
labelstringDisplay label for the facet group
fieldstringThe indexed field name
valuesSearchFacetValue[]Available values with counts
Each SearchFacetValue has:
PropertyTypeDescription
labelstringDisplay label for the value
valuestringThe value to filter by
countintNumber of matching results
activeboolWhether this filter is currently applied

Applying Facet Filters

Pass filters as an associative array to the filter() method. Multiple values for the same facet use OR logic.
$engine = Search::query($request->get('q', ''));

if ($filters = $request->get('filters')) {
    $engine->filter($filters);
}

$results = $engine->get();
Filters can also be added individually:
$engine->addFilter('brand', ['Nike', 'Adidas']);
$engine->addFilter('status', 'published');

URL-Based Product Resolution

Lunar stores SEO-friendly slugs in the Lunar\Models\Url model. Products, collections, and brands all support URL resolution through the HasUrls trait.

Defining a Catch-All Route

A common pattern is to define a catch-all route that resolves the URL to the correct model type:
use App\Http\Controllers\ResolverController;

// Place this after all other routes
Route::get('/{slug}', [ResolverController::class, 'show'])->where('slug', '.*');

Resolving the URL

<?php

namespace App\Http\Controllers;

use Lunar\Models\Brand;
use Lunar\Models\Collection;
use Lunar\Models\Product;
use Lunar\Models\Url;

class ResolverController extends Controller
{
    public function show(string $slug)
    {
        $url = Url::where('slug', $slug)->firstOrFail();

        $element = $url->element;

        return match ($element::class) {
            Product::class, Product::modelClass() => $this->showProduct($element),
            Collection::class, Collection::modelClass() => $this->showCollection($element),
            Brand::class, Brand::modelClass() => $this->showBrand($element),
            default => abort(404),
        };
    }

    protected function showProduct(Product $product)
    {
        $product->load([
            'variants.prices.currency',
            'media',
            'brand',
        ]);

        return view('products.show', compact('product'));
    }

    protected function showCollection(Collection $collection)
    {
        return view('collections.show', compact('collection'));
    }

    protected function showBrand(Brand $brand)
    {
        return view('brands.show', compact('brand'));
    }
}

Working with URLs on Models

use Lunar\Models\Product;

$product = Product::find(1);

// Get the default URL
$defaultUrl = $product->defaultUrl;
echo $defaultUrl->slug; // e.g., "blue-running-shoes"

// Get all URLs
$urls = $product->urls;

// Build a link
$href = '/' . $product->defaultUrl?->slug;

Searchable Attributes

Product attributes marked as searchable in the admin panel are automatically included in the search index. This means customers can search by any attribute value, such as material, color, or specifications. The search index for a product includes:
FieldSource
idProduct ID
statusProduct status (published, draft)
product_typeProduct type name
brandBrand name
skusArray of variant SKUs
thumbnailThumbnail URL
created_atUnix timestamp
Attribute handlesValues from searchable attributes
To make an attribute searchable, edit the attribute in the Lunar admin panel and enable the “Searchable” option. After changing searchable attributes, reimport the search index with php artisan scout:import "Lunar\Models\Product".

Routes

use App\Http\Controllers\ResolverController;
use App\Http\Controllers\SearchController;

Route::get('/search', [SearchController::class, 'index'])->name('search');
Route::get('/{slug}', [ResolverController::class, 'show'])->where('slug', '.*');

Putting It All Together

Here is a complete search controller with faceted filtering and sorting:
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Lunar\Models\Product;
use Lunar\Search\Facades\Search;

class SearchController extends Controller
{
    public function index(Request $request)
    {
        $query = $request->get('q', '');

        if (empty($query)) {
            return view('search.index', [
                'results' => null,
                'query' => '',
            ]);
        }

        $engine = Search::query($query)
            ->perPage(24);

        if ($filters = $request->get('filters')) {
            $engine->filter($filters);
        }

        if ($sort = $request->get('sort')) {
            if (in_array($sort, ['created_at:asc', 'created_at:desc'])) {
                $engine->sort($sort);
            }
        }

        $results = $engine->get();

        // Load full Eloquent models for display
        $productIds = collect($results->hits)->pluck('document.id');

        $products = Product::whereIn('id', $productIds)
            ->with([
                'variants.prices.currency',
                'media',
                'brand',
                'defaultUrl',
            ])
            ->get()
            ->keyBy('id');

        return view('search.index', [
            'results' => $results,
            'products' => $products,
            'query' => $query,
            'activeFilters' => $request->get('filters', []),
            'activeSort' => $request->get('sort', ''),
        ]);
    }
}

Next Steps

  • Review the Search reference for the full list of searchable models and configuration options.
  • Review the Search add-on for advanced search engine configuration.
  • Review the Extending Search guide for customizing indexers and adding custom fields to the search index.
  • Review the Product Listing Page guide for collection-based product browsing as an alternative to search.