OpenCart Guide · 14 min read · Updated

Typo-Tolerant Product Search for OpenCart 4 With Meilisearch or Typesense: Indexing, Instant Results and a Safe Fallback

OpenCart's built-in search uses SQL LIKE, so "iphnoe" finds nothing. Add typo-tolerant, relevance-ranked product search to OpenCart 4.1 with Meilisearch (or Typesense): running the server, indexing products per language, keeping the index in sync with events, replacing search results with a fallback, instant search, and tracking zero-result searches in GA4.

Opencart 4 Meilisearch typesense product search

Shoppers who use site search usually know what they want, which makes them some of your most valuable visitors. OpenCart’s search lets many of them down. Type “iphnoe” in a default OpenCart 4 store and you get “There is no product that matches the search criteria”, although the iPhone is right there.

This post replaces OpenCart’s product search with a search engine built for it. We use Meilisearch, an open-source search engine that’s easy to run next to OpenCart, and show the Typesense equivalent at the end. The approach keeps OpenCart’s own search page, URLs, filters and templates, and falls back to the built-in search if the engine is ever down.

What OpenCart’s built-in search does

The search page (product/search) calls getProducts() in catalog/model/catalog/product.php with filter_search. For each word, it adds:

$implode[] = "`pd`.`name` LIKE '" . $this->db->escape('%' . $word . '%') . "'";

It also matches the exact model and the start of product codes (such as SKU), and, if the customer ticks “Search in product descriptions”, the description. That has predictable limits:

  • No typo tolerance. %iphnoe% matches nothing.
  • Plurals and word forms only work one way. “shirt” finds “shirts”, but “shirts” doesn’t find “shirt”.
  • No relevance ranking. Results are sorted by your product sort order, not by how well they match. A product with the word in its name and one with it buried in a description rank the same.
  • AND or OR, nothing in between. Depending on System > Settings > Option > Product Search, a multi-word search either needs every word in the name (so “blue linen shirt” misses “Linen Shirt – Blue Stripe” if one word differs) or any word (so “blue linen shirt” returns every blue product).
  • It gets slow. LIKE '%word%' can’t use an index, so every search scans every product name. With tens of thousands of products, that’s noticeable.

Choosing a search engine

OptionTypo toleranceRuns whereGood for
MySQL FULLTEXTNoYour databaseA small improvement without new software
MeilisearchYes, on by defaultYour server (single binary or Docker) or Meilisearch CloudMost OpenCart stores; very little to configure
TypesenseYes, on by defaultYour server or Typesense CloudSimilar to Meilisearch; schema-first, all data in memory
Elasticsearch / OpenSearchYes, with configurationUsually a separate serverVery large catalogs and teams that already run it
Hosted (Algolia and others)YesTheir cloudNo server to run; priced per search/records

For a typical OpenCart store, Meilisearch or Typesense gives the biggest improvement for the least work. The rest of this post uses Meilisearch.

Step 1: run Meilisearch

On the same server as OpenCart (or a small one next to it), with Docker:

docker run -d --name meilisearch --restart unless-stopped \
  -p 127.0.0.1:7700:7700 \
  -e MEILI_ENV=production \
  -e MEILI_MASTER_KEY='a-long-random-string-of-at-least-16-bytes' \
  -v /var/lib/meilisearch:/meili_data \
  getmeili/meilisearch:latest
  • 127.0.0.1:7700 keeps it off the public internet. Only OpenCart, on the same machine, talks to it. If it runs on a separate server, use a private network and a firewall rule instead.
  • In production mode, Meilisearch requires a master key. Don’t use the master key in OpenCart; ask Meilisearch for its default API keys and use those:
curl -s http://127.0.0.1:7700/keys -H "Authorization: Bearer a-long-random-string-of-at-least-16-bytes"

The response includes a Default Admin API Key (for indexing) and a Default Search API Key (for searching). Store both as settings in your extension.

  • Pin a version tag instead of latest once it works, and read the release notes before upgrading.

Step 2: decide what goes in the index

Create one index per language, because product names and descriptions are stored per language in OpenCart: products_en_gb, products_fr_fr. Each product becomes one document:

{
  "id": 40,
  "name": "iPhone",
  "model": "product 11",
  "sku": "IPH-001",
  "manufacturer": "Apple",
  "tags": ["phone", "smartphone"],
  "description": "iPhone is a revolutionary new mobile phone that allows you to make a call by simply tapping a name or number...",
  "category_ids": [20, 24],
  "store_ids": [0],
  "price": 101.00,
  "in_stock": true,
  "sort_order": 0
}

Only enabled products whose availability date has passed go in. Keep the description short (the first 1,000 characters is plenty) and strip the HTML.

Build documents with plain queries, so the same function works from the admin and from a cron job:

private function getDocument(int $product_id, int $language_id): ?array {
	$product = $this->db->query("SELECT `p`.*, `pd`.`name`, `pd`.`description`, `pd`.`tag`, `m`.`name` AS `manufacturer` FROM `" . DB_PREFIX . "product` `p` LEFT JOIN `" . DB_PREFIX . "product_description` `pd` ON (`p`.`product_id` = `pd`.`product_id` AND `pd`.`language_id` = '" . $language_id . "') LEFT JOIN `" . DB_PREFIX . "manufacturer` `m` ON (`p`.`manufacturer_id` = `m`.`manufacturer_id`) WHERE `p`.`product_id` = '" . $product_id . "' AND `p`.`status` = '1' AND `p`.`date_available` <= NOW()")->row;

	if (!$product || !$product['name']) {
		return null;
	}

	$sku = $this->db->query("SELECT `value` FROM `" . DB_PREFIX . "product_code` WHERE `product_id` = '" . $product_id . "' AND `code` = 'SKU' LIMIT 1")->row['value'] ?? '';

	$description = trim(preg_replace('/\s+/', ' ', strip_tags(html_entity_decode($product['description'], ENT_QUOTES, 'UTF-8'))));

	return [
		'id'           => $product_id,
		'name'         => html_entity_decode($product['name'], ENT_QUOTES, 'UTF-8'),
		'model'        => $product['model'],
		'sku'          => $sku,
		'manufacturer' => (string)$product['manufacturer'],
		'tags'         => array_values(array_filter(array_map('trim', explode(',', html_entity_decode((string)$product['tag'], ENT_QUOTES, 'UTF-8'))))),
		'description'  => mb_substr($description, 0, 1000),
		'category_ids' => array_map('intval', array_column($this->db->query("SELECT `category_id` FROM `" . DB_PREFIX . "product_to_category` WHERE `product_id` = '" . $product_id . "'")->rows, 'category_id')),
		'store_ids'    => array_map('intval', array_column($this->db->query("SELECT `store_id` FROM `" . DB_PREFIX . "product_to_store` WHERE `product_id` = '" . $product_id . "'")->rows, 'store_id')),
		'price'        => (float)$product['price'],
		'in_stock'     => (int)$product['quantity'] > 0,
		'sort_order'   => (int)$product['sort_order']
	];
}

The price is the base price, used only for sorting. The search page still shows prices from OpenCart, with specials, discounts, tax and currency applied.

Step 3: configure the index

Tell Meilisearch which fields to search, in order of importance, and which can be filtered and sorted:

curl -X PATCH http://127.0.0.1:7700/indexes/products_en_gb/settings \
  -H "Authorization: Bearer ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  --data '{
    "searchableAttributes": ["name", "model", "sku", "manufacturer", "tags", "description"],
    "filterableAttributes": ["store_ids", "category_ids", "in_stock"],
    "sortableAttributes": ["price", "name", "sort_order"],
    "typoTolerance": {"disableOnAttributes": ["model", "sku"]},
    "synonyms": {
      "tee": ["t-shirt"],
      "t-shirt": ["tee"],
      "sneakers": ["trainers"],
      "trainers": ["sneakers"]
    }
  }'
  • The order of searchableAttributes matters. A match in the name ranks above a match in the description.
  • Typo tolerance is on by default. With the default settings, words of 5 or more characters can have one typo and words of 9 or more can have two, so “iphnoe” finds “iphone”. It’s switched off for model and SKU here, because a customer typing “IPH-002” wants exactly that product, not IPH-001.
  • Synonyms are where your search logs pay off (see “Track searches that find nothing” below). Add the words your customers use that your product names don’t.

Step 4: index everything, then keep it in sync

For the first full index, and nightly as a safety net, send documents in batches of 500 or 1,000:

private function request(string $method, string $path, ?array $body = null, float $timeout = 10): ?array {
	$curl = curl_init(rtrim($this->config->get('module_meili_search_host'), '/') . $path);

	curl_setopt_array($curl, [
		CURLOPT_CUSTOMREQUEST  => $method,
		CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $this->config->get('module_meili_search_admin_key'), 'Content-Type: application/json'],
		CURLOPT_POSTFIELDS     => $body !== null ? json_encode($body) : null,
		CURLOPT_RETURNTRANSFER => true,
		CURLOPT_TIMEOUT_MS     => (int)($timeout * 1000)
	]);

	$response = curl_exec($curl);
	$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);

	if ($response === false || $status >= 400) {
		$this->log->write('Meilisearch ' . $method . ' ' . $path . ': HTTP ' . $status . ' ' . substr((string)$response, 0, 300));

		return null;
	}

	return json_decode((string)$response, true);
}

public function reindex(): void {
	$this->load->model('localisation/language');

	foreach ($this->model_localisation_language->getLanguages() as $language) {
		$index = 'products_' . str_replace('-', '_', $language['code']);
		$product_ids = array_column($this->db->query("SELECT `product_id` FROM `" . DB_PREFIX . "product`")->rows, 'product_id');

		foreach (array_chunk($product_ids, 500) as $chunk) {
			$documents = array_values(array_filter(array_map(fn ($id) => $this->getDocument((int)$id, (int)$language['language_id']), $chunk)));

			$this->request('POST', '/indexes/' . $index . '/documents?primaryKey=id', $documents);
		}
	}
}

Meilisearch creates the index on the first upload and processes documents in the background: a 202 response means “queued”, and the products are searchable a moment later. A full reindex replaces existing documents with the same id; to clear out products that were deleted or disabled, reindex into a fresh index and swap it in (POST /swap-indexes), or delete those IDs as below.

Between full reindexes, update single products when they change, with admin events:

EventAction
admin/model/catalog/product.addProduct/afterIndex the new product (its ID is $output)
admin/model/catalog/product.editProduct/afterRe-index $args[0]; if it’s now disabled, delete it
admin/model/catalog/product.deleteProduct/afterDELETE /indexes/{index}/documents/{id}
public function editProduct(string &$route, array &$args, &$output): void {
	$product_id = (int)$args[0];

	foreach ($this->getLanguages() as $language_id => $index) {
		$document = $this->getDocument($product_id, $language_id);

		if ($document) {
			$this->request('POST', '/indexes/' . $index . '/documents', [$document], 3);
		} else {
			$this->request('DELETE', '/indexes/' . $index . '/documents/' . $product_id, null, 3);
		}
	}
}

Stock changes from orders don’t go through editProduct. If you filter or rank by in_stock, also re-index the ordered products on catalog/model/checkout/order.addHistory/after, or rely on the nightly reindex if that’s fresh enough. Price and stock updates from a bulk CSV import or an ERP need the same treatment.

Step 5: use Meilisearch for OpenCart’s search page

Now the key part. OpenCart 4.1’s before events on models can change the arguments but can’t skip the original method, so the extension works after it: when getProducts() was called with a search, it asks Meilisearch and replaces the result. getTotalProducts() gets the matching total, so pagination is correct.

Register two catalog events:

  • catalog/model/catalog/product.getProducts/after → products
  • catalog/model/catalog/product.getTotalProducts/after → total
private static array $cache = [];

public function products(string &$route, array &$args, &$output): void {
	$result = $this->search($args[0] ?? []);

	if ($result === null) {
		return; // Not a search, or Meilisearch failed: keep OpenCart's own results.
	}

	$this->load->model('catalog/product');

	$products = [];

	foreach ($result['ids'] as $product_id) {
		$product = $this->model_catalog_product->getProduct($product_id);

		if ($product) {
			$products[$product_id] = $product;
		}
	}

	$output = $products;
}

public function total(string &$route, array &$args, &$output): void {
	$result = $this->search($args[0] ?? []);

	if ($result !== null) {
		$output = $result['total'];
	}
}

private function search(array $filter): ?array {
	// The search page sends the term as both filter_search and filter_tag; tag links send only filter_tag.
	$q = trim((string)(($filter['filter_search'] ?? '') ?: ($filter['filter_tag'] ?? '')));

	if ($q === '' || !$this->config->get('module_meili_search_status')) {
		return null;
	}

	$limit = max(1, (int)($filter['limit'] ?? 20));
	$page = (int)floor((int)($filter['start'] ?? 0) / $limit) + 1;

	$key = md5(json_encode([$q, $filter['filter_category_id'] ?? 0, $filter['filter_sub_category'] ?? false, $filter['sort'] ?? '', $filter['order'] ?? '', $page, $limit]));

	if (array_key_exists($key, self::$cache)) {
		return self::$cache[$key];
	}

	$filters = ['store_ids = ' . (int)$this->config->get('config_store_id')];

	if (!empty($filter['filter_category_id'])) {
		$category_ids = [(int)$filter['filter_category_id']];

		if (!empty($filter['filter_sub_category'])) {
			$category_ids = array_merge($category_ids, array_map('intval', array_column($this->db->query("SELECT `category_id` FROM `" . DB_PREFIX . "category_path` WHERE `path_id` = '" . (int)$filter['filter_category_id'] . "'")->rows, 'category_id')));
		}

		$filters[] = 'category_ids IN [' . implode(', ', array_unique($category_ids)) . ']';
	}

	$body = [
		'q'                    => $q,
		'filter'               => implode(' AND ', $filters),
		'page'                 => $page,
		'hitsPerPage'          => $limit,
		'attributesToRetrieve' => ['id']
	];

	// OpenCart's default sort (p.sort_order) means "best match". Other choices map to sortable attributes.
	$sorts = ['p.price' => 'price', 'pd.name' => 'name'];
	$sort = (string)($filter['sort'] ?? '');

	if (isset($sorts[$sort])) {
		$body['sort'] = [$sorts[$sort] . ':' . (strtoupper((string)($filter['order'] ?? 'ASC')) == 'DESC' ? 'desc' : 'asc')];
	}

	$index = 'products_' . str_replace('-', '_', $this->config->get('config_language'));

	$response = $this->request('POST', '/indexes/' . $index . '/search', $body, 0.8);

	self::$cache[$key] = $response === null ? null : [
		'ids'   => array_map('intval', array_column($response['hits'] ?? [], 'id')),
		'total' => (int)($response['totalHits'] ?? 0)
	];

	return self::$cache[$key];
}

(Here request() sends the search key, not the admin key, with a short timeout.)

Details that matter:

  • The fallback is free. OpenCart’s own query runs first, so if Meilisearch times out (0.8 seconds here) or returns an error, the customer gets the normal results instead of an error page. Log the failures and watch for them.
  • Order is kept. The array of products follows Meilisearch’s ranking, and the search template shows them in that order.
  • page and hitsPerPage make Meilisearch return an exact totalHits, which OpenCart’s pagination needs. With offset/limit you only get an estimate.
  • Prices still come from OpenCart. getProduct() applies the customer group, specials and discounts, so there’s no risk of the index showing an out-of-date price.
  • The tag link (index.php?route=product/search&tag=... on product pages) also goes through Meilisearch here. If you prefer exact tag matches for those, return null when filter_search is empty.
  • The core LIKE query still runs on every search. If that’s a problem on a very large catalog, add a before event that sets $args[0]['limit'] to 1 for searches, so the wasted query returns one row.

Step 6: instant results as customers type

The search box in OpenCart’s header submits a form. Instant results, a dropdown of products under the box, need a small JSON endpoint and a few lines of JavaScript. Keep Meilisearch private and go through OpenCart:

// catalog/controller/module/meili_search.php, route extension/meili_search/module/meili_search.suggest
public function suggest(): void {
	$q = trim((string)($this->request->get['q'] ?? ''));
	$json = [];

	if (oc_strlen($q) >= 2) {
		$result = $this->search(['filter_search' => $q, 'start' => 0, 'limit' => 6]);

		$this->load->model('catalog/product');

		foreach ($result['ids'] ?? [] as $product_id) {
			$product = $this->model_catalog_product->getProduct($product_id);

			if ($product) {
				$json[] = [
					'name' => html_entity_decode($product['name'], ENT_QUOTES, 'UTF-8'),
					'href' => html_entity_decode($this->url->link('product/product', 'language=' . $this->config->get('config_language') . '&product_id=' . $product_id), ENT_QUOTES, 'UTF-8')
				];
			}
		}
	}

	$this->response->addHeader('Content-Type: application/json');
	$this->response->setOutput(json_encode($json));
}
var timer;

$('#search input[name=\'search\']').attr('autocomplete', 'off').on('input', function() {
  var input = this;

  clearTimeout(timer);

  timer = setTimeout(function() {
    $.getJSON('index.php?route=extension/meili_search/module/meili_search.suggest', {q: input.value}, function(items) {
      var $menu = $('#search-suggest').length ? $('#search-suggest') : $('<ul id="search-suggest" class="dropdown-menu show w-100"></ul>').insertAfter(input);

      $menu.empty().toggle(items.length > 0);

      items.forEach(function(item) {
        $('<li><a class="dropdown-item"></a></li>').find('a').attr('href', item.href).text(item.name).end().appendTo($menu);
      });
    });
  }, 200);
});

The 200 ms delay means one request per pause in typing, not one per key. .text() keeps product names from being treated as HTML. Add the script from a catalog/view/common/header/after event so no theme file changes; if you also want images and prices in the dropdown, return them from suggest().

Step 7: track searches that find nothing

Searches with no results are a to-do list: missing products, missing synonyms, or words your customers use that your catalog doesn’t. GA4’s enhanced measurement already records site search as view_search_results when the URL has a search parameter, which OpenCart’s search page does. To see the ones that failed, send one more event when the search page has no products:

gtag('event', 'search_no_results', {search_term: 'iphnoe'});

Print it from a catalog/view/product/search/after event when $data['products'] is empty, encoding the search term with json_encode() as in our GA4 ecommerce extension. Register search_term as a custom dimension in GA4 and review the list monthly. Each fix is usually a synonym in Step 3 or a missing product tag.

The Typesense version

Typesense works the same way with different calls:

MeilisearchTypesense
Auth headerAuthorization: Bearer KEYX-TYPESENSE-API-KEY: KEY
IndexIndex, created on first uploadCollection, with a schema you define first
Add productsPOST /indexes/{index}/documentsPOST /collections/{name}/documents/import?action=upsert (JSON lines)
SearchPOST /indexes/{index}/search with qGET /collections/{name}/documents/search?q=...&query_by=name,model,sku,manufacturer,tags,description
Filterstore_ids = 0 AND category_ids IN [20, 24]filter_by=store_ids:=0 && category_ids:=[20,24]
TyposOn by defaultOn by default (num_typos, default 2)
IDsNumbers or stringsid must be a string: "40"

Typesense keeps the whole index in memory, which makes it very fast and means you size the server by catalog size. Either engine handles a typical OpenCart catalog comfortably on a small server.

Before you go live

  1. Search for your 20 most common queries (from GA4 or your server logs) on a staging copy, and compare the results with the built-in search.
  2. Test with Meilisearch stopped: searches should still work, with OpenCart’s normal results.
  3. Check a category-filtered search and a sorted search (price low to high) for correct counts and pages.
  4. Add Meilisearch to your backups and monitoring, or simply make sure a full reindex from OpenCart is quick, since the index can always be rebuilt from the database.

Fast search depends on a fast store too: see OPcache, APCu and Redis for OpenCart 4 and OpenCart 4 on Nginx. And customers increasingly search through AI assistants rather than your search box; our AI shopping product feed and AI shop assistant posts cover that side.

Rupak Nepali
Written byRupak Nepali

Author of four Opencart book.
The recent are Opencart 4 developer book and Opencart 4 user manual

Leave a Reply

Your email address will not be published. Required fields are marked *