Skip to content

Search

Storefront product search runs on Meilisearch. The code is in app-modules/catalog/src/Search/. Staff guidance is in search.

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.

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.

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’s saved hook.
  • 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.

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.

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.

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.

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