Search
Storefront product search runs on Meilisearch. The code is in app-modules/catalog/src/Search/. Staff
guidance is in search.
Scout, and what it isn’t used for
Section titled “Scout, and what it isn’t used for”laravel/scout is installed and supplies the configuration (config/scout.php) and the driver
switch. Product does not use Scout’s Searchable trait. Our documents need per-site rows, so
Search\ProductSearchIndex talks to the Meilisearch client directly.
| Env var | Use |
|---|---|
SCOUT_DRIVER |
meilisearch turns on keeping the index in step. Anything else skips it. |
SCOUT_PREFIX |
Prefix on the index name |
MEILISEARCH_HOST, MEILISEARCH_KEY |
Connection |
DDEV runs Meilisearch as a service (.ddev/docker-compose.meilisearch.yaml), with a local-only key.
One index, one document per product per site
Section titled “One index, one document per product per site”There is one index, products. A product has one document per site that lists it, with the document
id being its site_documents row there. Search\ProductSearchDocuments builds them in batches:
| Field | Holds |
|---|---|
site_id, product_id |
Filterable |
title |
The site’s title for the product |
keywords |
products.search_keywords, staff’s words a product is found by, never rendered |
categories, brand |
The site’s names for its categories, and its brands |
options |
What its offered variants come in: labels, aliases, colour groups |
codes |
Variant SKUs and supplier SKUs |
description |
Plain text, cut to 1,000 characters |
in_stock, price_from, created_at |
Ranking and sorting |
“Lists it” means Product::listableOn($site), so search shows the same products as the category
pages. options is why “red shirt” finds T-shirts offered in burgundy, when burgundy is in the Red
group.
Settings live in code
Section titled “Settings live in code”ProductSearchIndex::settings() is the whole configuration: searchable attributes in weight order,
filterable and sortable attributes, ranking rules (ending with in_stock:desc) and typo tolerance.
Change them there, then push them. They are stated in full because a settings update merges.
Keeping it current
Section titled “Keeping it current”The index follows the projection. Listeners\SyncProductSearchWithProjection listens to
content-management’s SiteDocumentsIndexed (see
sites and the projection):
| Reindexed | Queues |
|---|---|
| A product’s rows | Jobs\SyncProductSearch |
| A category’s rows | Jobs\SyncTermProductsSearch, then a sync for each of its products |
| A whole type | Jobs\RebuildProductSearch |
The jobs are unique while queued, so a product reindexed on three sites syncs once.
The projection doesn’t read everything search does, so other changes queue a sync themselves:
Product::SEARCH_ONLY_COLUMNS(search_keywords,description,mrp,mrp_with_charges), from the model’ssavedhook.- Variant and variant value saves and deletes, as they change codes and offered options.
- A brand rename, through
SyncTermProductsSearch.
ProductSearchIndex::syncProduct() writes the documents for the sites that list the product and
deletes the rest, so a product that stops being listed drops out by itself.
Synonyms
Section titled “Synonyms”Models\SearchSynonym is a group of words treated as the same, edited at System > Configuration, under
Catalog, Search synonyms by anyone with catalog.manage. forMeilisearch() maps each word to the rest of its
group. Saving or deleting one queues Jobs\PushProductSearchSettings, so there is no rebuild step.
Commands
Section titled “Commands”| Command | Does | When |
|---|---|---|
ddev artisan catalog:search-rebuild |
Builds products_rebuild from scratch, then swaps it in |
After a legacy import or a full cms:reindex, and on deploy |
ddev artisan catalog:search-settings |
Pushes settings() to the live index, keeping its documents |
After changing the settings |
The swap means search never goes empty during a rebuild. A change to a document’s fields needs a rebuild, and a change to settings only needs a push.
The storefront side
Section titled “The storefront side”Http\Controllers\SearchController serves these routes, registered in routes/web.php ahead of the
catch-all:
| Route | Name | Returns |
|---|---|---|
/search?q= |
search |
The theme’s search.blade.php, noindex |
/search/page/{page}?q= |
search.page |
Further pages |
/search/suggest?q= |
search.suggest |
JSON for the header dropdown |
ProductSearchIndex::search() always filters to the site and requires every word (matchingStrategy: all), so counts are honest. A query that finds nothing is retried without the words that find nothing
alone, then with most words, and the result’s query says what was searched, for the page to show.
The page feeds the ids (up to 1,000, SearchController::MAX_RESULTS) into the same product-list
component as category pages, so sorting and layered navigation work
on the matches.
If Meilisearch can’t be reached, the error is logged and the page says search is unavailable rather than failing. An empty query redirects home.
Testing
Section titled “Testing”phpunit.xml sets SCOUT_DRIVER=null, SCOUT_PREFIX=phpunit_ and an unreachable
MEILISEARCH_HOST, so tests can never touch the development index. Tests that turn the driver on
fake the queue or bring their own client. ProductSearchIndexTest runs against DDEV’s Meilisearch
in a throwaway index, and skips without one. Tests are in
app-modules/catalog/tests/Feature/Search/.