<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sergey Polyakov</title>
    <description>The latest articles on DEV Community by Sergey Polyakov (@studiops).</description>
    <link>https://dev.to/studiops</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4242598%2F1b6dddda-5b7c-492c-8699-f94123bc29fc.png</url>
      <title>DEV Community: Sergey Polyakov</title>
      <link>https://dev.to/studiops</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/studiops"/>
    <language>en</language>
    <item>
      <title>How we built a product filter for OpenCart 3 and 4 without touching the core</title>
      <dc:creator>Sergey Polyakov</dc:creator>
      <pubDate>Fri, 13 Nov 2026 09:39:48 +0000</pubDate>
      <link>https://dev.to/studiops/how-we-built-a-product-filter-for-opencart-3-and-4-without-touching-the-core-1g8h</link>
      <guid>https://dev.to/studiops/how-we-built-a-product-filter-for-opencart-3-and-4-without-touching-the-core-1g8h</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This post covers the decisions that mattered most and the MySQL and OpenCart pitfalls we hit along the way.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhwa6l6ntlqaspml6tpeh.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhwa6l6ntlqaspml6tpeh.jpg" alt="PS Filter on a category page of a demo store" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. No core edits, only events
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;A filtered URL looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/telescopes/filter/brand=celestron,bresser/price=100-500/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OpenCart's SEO URL handler does not know the &lt;code&gt;filter/...&lt;/code&gt; segments, so it routes the request to &lt;code&gt;error/not_found&lt;/code&gt; while keeping the category path. A &lt;code&gt;controller/error/not_found/before&lt;/code&gt; handler takes over and reroutes the request to &lt;code&gt;product/category&lt;/code&gt; with the selection applied.&lt;/p&gt;

&lt;p&gt;Every URL has exactly one canonical form. A different facet order, &lt;code&gt;page=1&lt;/code&gt;, junk in &lt;code&gt;sort&lt;/code&gt; or &lt;code&gt;limit&lt;/code&gt;, or a facet that does not exist in the category gets a single 301 to the canonical address.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;price=1-N&lt;/code&gt; creates a new cache row for every N, and a huge number in a range turns into &lt;code&gt;INF&lt;/code&gt; in SQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Facet counts in MySQL
&lt;/h2&gt;

&lt;p&gt;Showing a product count next to every value is the expensive part of any filter. Here is the strategy that worked for us:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The category's product set is materialized into a temporary &lt;code&gt;MEMORY&lt;/code&gt; table.&lt;/li&gt;
&lt;li&gt;For each selected facet we build the set of matching products. Rare values are read through the value index; frequent values are checked with &lt;code&gt;EXISTS&lt;/code&gt; against the category set.&lt;/li&gt;
&lt;li&gt;The result is the intersection of those sets.&lt;/li&gt;
&lt;li&gt;Counts for unselected facets come from one &lt;code&gt;GROUP BY&lt;/code&gt; over the result. Counts for a selected facet are calculated against the intersection of all the &lt;em&gt;other&lt;/em&gt; selected facets, so the shopper sees what happens if they change that facet.&lt;/li&gt;
&lt;li&gt;Category stats without a filter are cached for an hour.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Numbers from our test bench (50,000 generated products, MySQL 8 in Docker, cold filter, warm category stats, SQL time only):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Median per click&lt;/th&gt;
&lt;th&gt;p90&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1,287 products&lt;/td&gt;
&lt;td&gt;~20 ms&lt;/td&gt;
&lt;td&gt;~40 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7,709 products with subcategories&lt;/td&gt;
&lt;td&gt;~44 ms&lt;/td&gt;
&lt;td&gt;~62 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same, derived-table fallback&lt;/td&gt;
&lt;td&gt;~190 ms&lt;/td&gt;
&lt;td&gt;~440 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;For very large catalogs, queries can be moved to the Manticore search server.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. AJAX that works with any theme
&lt;/h2&gt;

&lt;p&gt;Themes differ wildly in markup, so we did not want to render products with our own templates. Instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On click, JavaScript requests the same canonical URL with an &lt;code&gt;X-PSF: fragment&lt;/code&gt; header.&lt;/li&gt;
&lt;li&gt;The server renders the theme's own category page with the header, footer, and columns suppressed, and returns JSON: page content, panel, and title.&lt;/li&gt;
&lt;li&gt;JavaScript swaps the content block and the panel and calls &lt;code&gt;pushState&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F91qkk6cg31e4poz9jm82.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F91qkk6cg31e4poz9jm82.jpg" alt="The filter drawer on a phone" width="720" height="900"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. SEO: decide what may be indexed
&lt;/h2&gt;

&lt;p&gt;A filter can generate millions of URLs. We split them into two groups.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Indexable states:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Landing pages.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SEO templates.&lt;/strong&gt; Rules such as "category + one brand, at least 3 products", with a title pattern like &lt;code&gt;{category} {brand}&lt;/code&gt;. They open combinations for indexing without creating each page by hand. Price ranges are never indexed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Everything else&lt;/strong&gt; gets &lt;code&gt;noindex, follow&lt;/code&gt; and a canonical to the category. A few edge cases we handle explicitly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A selection that does not narrow the category (every product in "Celestron parts" is Celestron) is a full duplicate, so it is not indexed.&lt;/li&gt;
&lt;li&gt;A page number past the last page gets &lt;code&gt;noindex&lt;/code&gt; and a canonical to page one.&lt;/li&gt;
&lt;li&gt;A landing page with fewer products than a configurable minimum gets &lt;code&gt;noindex&lt;/code&gt; and drops out of the sitemap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only values that lead to an indexable state are rendered as real &lt;code&gt;&amp;lt;a href&amp;gt;&lt;/code&gt; links. The rest carry the URL in a &lt;code&gt;data-&lt;/code&gt; attribute for JavaScript, so crawlers do not waste their budget on combinations that will never be indexed. Landing pages go into a sitemap.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftc914y8xa0en104f1ef0.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftc914y8xa0en104f1ef0.jpg" alt="SEO settings in the admin" width="800" height="618"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Accessibility
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Styling stays in the theme: CSS variables (&lt;code&gt;--psf-*&lt;/code&gt;), classes, &lt;code&gt;psf:*&lt;/code&gt; events, and templates that can be overridden in the theme folder. A theme can match its design without &lt;code&gt;!important&lt;/code&gt; overrides or edits to the module.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Pitfalls worth knowing
&lt;/h2&gt;

&lt;p&gt;A few things that cost us time. If you write OpenCart extensions, some of them may save you an evening.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An event handler that returns &lt;code&gt;''&lt;/code&gt; does not suppress the controller.&lt;/strong&gt; OpenCart's loader compares the result loosely with &lt;code&gt;null&lt;/code&gt;, and an empty string passes as &lt;code&gt;null&lt;/code&gt;. To replace a controller's output, return a non-empty string such as &lt;code&gt;' '&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;START TRANSACTION&lt;/code&gt; inside an open transaction silently commits the outer one&lt;/strong&gt; in MySQL. A helper with its own transaction, called from inside a batch, made the storefront briefly see empty facets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collation vs. PHP arrays.&lt;/strong&gt; OpenCart tables use &lt;code&gt;*_general_ci&lt;/code&gt;, 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Join order.&lt;/strong&gt; Without &lt;code&gt;STRAIGHT_JOIN&lt;/code&gt;, 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Double escaping.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenCart 4 extension paths&lt;/strong&gt; (autoloading, templates, languages) are registered only for packages installed through the Installer. Copying a folder into &lt;code&gt;extension/&lt;/code&gt; is not enough. The extension code is taken from the archive file name, so &lt;code&gt;ps_filter.ocmod.zip&lt;/code&gt; must not be renamed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7. One codebase for OpenCart 3 and 4
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Demo store (category page with the filter): &lt;a href="https://topteleskop.ru/telescopes" rel="noopener noreferrer"&gt;https://topteleskop.ru/telescopes&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source and releases: &lt;a href="https://github.com/studiops-ru/ps_filter" rel="noopener noreferrer"&gt;https://github.com/studiops-ru/ps_filter&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Description and admin screenshots: &lt;a href="https://studiops.ru/en/modules/ps-filter?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=ps-filter" rel="noopener noreferrer"&gt;https://studiops.ru/en/modules/ps-filter?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=ps-filter&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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+.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance, based on our module docs and benchmarks.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>opencart</category>
      <category>php</category>
      <category>mysql</category>
      <category>ecommerce</category>
    </item>
  </channel>
</rss>
