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:
| Model | Description |
|---|
Lunar\Models\Product | Products with attributes, SKUs, brand, and product type |
Lunar\Models\Collection | Collections with their attribute data |
Lunar\Models\Brand | Brands |
Lunar\Models\Customer | Customers (typically for admin use) |
Lunar\Models\Order | Orders (typically for admin use) |
Lunar\Models\ProductOption | Product 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:
| Property | Type | Description |
|---|
query | ?string | The search query that was executed |
count | int | Total number of matching results |
page | int | Current page number |
perPage | int | Results per page |
totalPages | int | Total number of pages |
hits | SearchHit[] | Array of search result hits |
facets | SearchFacet[] | Array of available facets with counts |
links | View | Pagination links |
Each SearchHit contains:
| Property | Type | Description |
|---|
document | array | The indexed document data (id, name, brand, SKUs, etc.) |
highlights | SearchHitHighlight[] | 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:
| Property | Type | Description |
|---|
label | string | Display label for the facet group |
field | string | The indexed field name |
values | SearchFacetValue[] | Available values with counts |
Each SearchFacetValue has:
| Property | Type | Description |
|---|
label | string | Display label for the value |
value | string | The value to filter by |
count | int | Number of matching results |
active | bool | Whether 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:
| Field | Source |
|---|
id | Product ID |
status | Product status (published, draft) |
product_type | Product type name |
brand | Brand name |
skus | Array of variant SKUs |
thumbnail | Thumbnail URL |
created_at | Unix timestamp |
| Attribute handles | Values 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.