We build online stores on OpenCart. Every store needs a product filter, and over the years we kept running into the same three problems with them. Category pages got slower as the catalog grew. Search engines found thousands of filter URLs that were near-duplicates of the category. And sooner or later, an update of OpenCart or the theme broke something, because the filter patched core files.
So we wrote our own and run it on our clients' stores. It is called PS Filter, it works on OpenCart 3.0.x and 4.x, and we have released it under the MIT license.
This post covers the decisions that mattered most and the MySQL and OpenCart pitfalls we hit along the way.
1. No core edits, only events
The module does not change OpenCart files and does not use OCMOD. Everything is wired through OpenCart events, so updating OpenCart or switching themes does not break the filter.
A filtered URL looks like this:
/telescopes/filter/brand=celestron,bresser/price=100-500/
OpenCart's SEO URL handler does not know the filter/... segments, so it routes the request to error/not_found while keeping the category path. A controller/error/not_found/before handler takes over and reroutes the request to product/category with the selection applied.
Every URL has exactly one canonical form. A different facet order, page=1, junk in sort or limit, or a facet that does not exist in the category gets a single 301 to the canonical address.
We also put hard limits on the URL: up to 20 facets, up to 50 values per facet, and bounded numbers in price ranges. Without them, a crawler walking through price=1-N creates a new cache row for every N, and a huge number in a range turns into INF in SQL.
2. Facet counts in MySQL
Showing a product count next to every value is the expensive part of any filter. Here is the strategy that worked for us:
- The category's product set is materialized into a temporary
MEMORYtable. - For each selected facet we build the set of matching products. Rare values are read through the value index; frequent values are checked with
EXISTSagainst the category set. - The result is the intersection of those sets.
- Counts for unselected facets come from one
GROUP BYover the result. Counts for a selected facet are calculated against the intersection of all the other selected facets, so the shopper sees what happens if they change that facet. - Category stats without a filter are cached for an hour.
Some shared hosting plans do not allow temporary tables, so there is a fallback strategy with derived tables. It is slower, but it works everywhere.
Numbers from our test bench (50,000 generated products, MySQL 8 in Docker, cold filter, warm category stats, SQL time only):
| Category | Median per click | p90 |
|---|---|---|
| 1,287 products | ~20 ms | ~40 ms |
| 7,709 products with subcategories | ~44 ms | ~62 ms |
| Same, derived-table fallback | ~190 ms | ~440 ms |
The filter reads its own index, not OpenCart's tables. The index updates itself when a product is saved, deleted, or disabled in the admin, when categories change, and when an order is placed. A full reindex rewrites data in transactional batches, so the storefront never sees an empty facet halfway through.
For very large catalogs, queries can be moved to the Manticore search server.
3. AJAX that works with any theme
Themes differ wildly in markup, so we did not want to render products with our own templates. Instead:
- On click, JavaScript requests the same canonical URL with an
X-PSF: fragmentheader. - The server renders the theme's own category page with the header, footer, and columns suppressed, and returns JSON: page content, panel, and title.
- JavaScript swaps the content block and the panel and calls
pushState.
One request per click, the product cards are exactly what the theme renders, and Back and Forward work. Without JavaScript, every value is a plain link and the filter still works.
4. SEO: decide what may be indexed
A filter can generate millions of URLs. We split them into two groups.
Indexable states:
- Landing pages. A category plus a selection, with its own URL, title, H1, meta description, and text. If someone opens the same selection through the filter, they get a 301 to the landing page.
-
SEO templates. Rules such as "category + one brand, at least 3 products", with a title pattern like
{category} {brand}. They open combinations for indexing without creating each page by hand. Price ranges are never indexed.
Everything else gets noindex, follow and a canonical to the category. A few edge cases we handle explicitly:
- A selection that does not narrow the category (every product in "Celestron parts" is Celestron) is a full duplicate, so it is not indexed.
- A page number past the last page gets
noindexand a canonical to page one. - A landing page with fewer products than a configurable minimum gets
noindexand drops out of the sitemap.
Only values that lead to an indexable state are rendered as real <a href> links. The rest carry the URL in a data- attribute for JavaScript, so crawlers do not waste their budget on combinations that will never be indexed. Landing pages go into a sitemap.
5. Accessibility
The storefront part follows WCAG 2.2 AA: everything works from the keyboard, focus is visible, the number of found products is announced to screen readers, and touch targets on phones are large enough to hit. On mobile the panel opens as a drawer with a "Show N products" button at the bottom.
Styling stays in the theme: CSS variables (--psf-*), classes, psf:* events, and templates that can be overridden in the theme folder. A theme can match its design without !important overrides or edits to the module.
6. Pitfalls worth knowing
A few things that cost us time. If you write OpenCart extensions, some of them may save you an evening.
-
An event handler that returns
''does not suppress the controller. OpenCart's loader compares the result loosely withnull, and an empty string passes asnull. To replace a controller's output, return a non-empty string such as' '. -
START TRANSACTIONinside an open transaction silently commits the outer one in MySQL. A helper with its own transaction, called from inside a batch, made the storefront briefly see empty facets. -
Collation vs. PHP arrays. OpenCart tables use
*_general_ci, where "Nestlé" equals "Nestle". A PHP array treats them as different keys, so an insert that "does not exist" in PHP hits a duplicate key error (1062) in MySQL. Catch it and read the existing row. -
Join order. Without
STRAIGHT_JOIN, MySQL chose to scan the whole product-to-value table instead of the category key range. Forcing the order made those queries 2 to 5 times faster. - Double escaping. OpenCart stores manufacturer, option, and filter names HTML-escaped. If you copy them into your own tables, decode them first, or the template escapes them a second time.
-
OpenCart 4 extension paths (autoloading, templates, languages) are registered only for packages installed through the Installer. Copying a folder into
extension/is not enough. The extension code is taken from the archive file name, sops_filter.ocmod.zipmust not be renamed.
7. One codebase for OpenCart 3 and 4
The filtering engine, index, and SEO logic do not depend on the OpenCart version. Each version gets a thin layer of controllers, events, and install code. Both archives are built from one repository and released under one version number, with the same features in both.
Try it
- Demo store (category page with the filter): https://topteleskop.ru/telescopes
- Source and releases: https://github.com/studiops-ru/ps_filter
- Description and admin screenshots: https://studiops.ru/en/modules/ps-filter?utm_source=devto&utm_medium=article&utm_campaign=ps-filter
Requirements: OpenCart 3.0.x (PHP 7.4–8.4) or OpenCart 4.x (tested on 4.1.0.4, PHP 8.1–8.5), MySQL 5.7+ or MariaDB 10.3+.
Bug reports and ideas are welcome in GitHub Issues. If you have built faceted search on MySQL differently, I would like to hear how in the comments.
Written with AI assistance, based on our module docs and benchmarks.



Top comments (0)