How to Show Popular Products in OpenCart 4.x Store Frontend
Learn how to create a custom Popular Products module in OpenCart 4.x using the oc_product_viewed table for faster performance and accurate trending items.
Displaying popular products on your OpenCart store's frontend is one of the simplest ways to boost conversions and customer engagement. When visitors see what other shoppers are buying the most, it builds trust and encourages them to make a purchase.
OpenCart 4.x brings several architectural changes to improve performance, event handling, and reporting structure. One major change that many developers and store owners notice is in the way product view statistics are stored and reported. This article will explain in simple terms how the product view tracking works in OpenCart 4.x, what tables are involved, and how you can create or use a Popular Products module to display the most-viewed items on your store frontend.
Why Show Popular Products on Your Store?
As a store owner, you can’t underestimate the power of social proof. Showing the most popular or trending products helps in multiple ways:
- Boost conversions – Customers are more likely to buy products that are already in demand.
- Reduce bounce rate – Highlighting bestsellers encourages users to explore your store further.
- Increase average order value – You can place popular products next to related or upsell items.
- Improve SEO – A dynamic section like “Popular Products” keeps your homepage content fresh and relevant.
Understanding How OpenCart 4.x Tracks Product Views
In previous OpenCart versions (like 2.x or 3.x), every product had a viewed column in the oc_product table that directly stored the number of views. However, starting from OpenCart 4.x, this logic was restructured to make the system more scalable and event-driven.
Now, whenever a customer views a product page, OpenCart creates a new entry in the oc_product_report table. This table stores raw product view events with details such as product ID, store ID, IP address, and date. It helps collect detailed analytics data.
Here’s how the tables work together:
- Table 1 – oc_product_report: Stores every single product view entry.
- Table 2 – oc_product_viewed: Stores the aggregated (summarized) view count per product.
The key difference is that oc_product_report contains every view as a separate row, while oc_product_viewed keeps only the total view count per product. This design helps keep your reports and modules lightweight, while still allowing detailed tracking if needed.
Why “Generate” Button is Required in the Admin Reports
When you go to Reports → Products Viewed Report in your OpenCart admin panel, you’ll notice that the report doesn’t automatically refresh with every view. Instead, there is a Generate button that updates the report data.
Here’s what happens behind the scenes:
- When someone views a product, a new entry is added to
oc_product_report. - The admin report shows data from
oc_product_viewed, not directly fromoc_product_report. - When you click the Generate button, OpenCart aggregates all entries from
oc_product_report, counts how many times each product was viewed, and updates theoc_product_viewedtable. - After that, the report is refreshed and you can see the latest numbers in your dashboard.
This means the report data is not real-time. It gets updated only when the admin manually clicks the Generate button (or when a developer creates an automation like a cron job). This behavior is intentional—it improves performance and avoids continuous database recalculations.
Using the Same Logic for Popular Products Module
Since the oc_product_viewed table already stores the aggregated data, it makes perfect sense to use this table for displaying Popular Products on your store frontend. Querying this table is much faster than grouping data from the oc_product_report table, especially when you have thousands of product view records.
In our custom module setup, we use the oc_product_viewed data directly. This way, the frontend loads quickly and efficiently, showing the most-viewed products based on the last generated report. The only thing to remember is that the data will update only after the admin clicks the Generate button in the backend.
Creating Our Custom Module Files (OpenCart 4.x Structure)
OpenCart 4.x introduced a more modular and cleaner folder structure than older versions like 2.x or 3.x. We’ll create a brand-new module from scratch without cloning any existing one.
Our new module will be named Popular Products and will follow this structure:
extension/
└── opencart/
├── admin/
│ ├── controller/module/popular.php
│ ├── language/en-gb/module/popular.php
│ └── view/template/module/popular.twig
└── catalog/
├── controller/module/popular.php
├── language/en-gb/module/popular.php
├── model/module/popular.php
└── view/template/module/popular.twig
Admin Files
These files handle the backend (admin) part of the module.
extension/opencart/admin/controller/module/popular.php — Manages the logic for module configuration in the admin area. It handles loading, saving, and validating settings.
<?php
namespace Opencart\Admin\Controller\Extension\Opencart\Module;
/**
* Class Popular
*
* @package Opencart\Admin\Controller\Extension\Opencart\Module
*/
class Popular extends \Opencart\System\Engine\Controller {
/**
* Index
*
* @return void
*/
public function index(): void {
$this->load->language('extension/opencart/module/popular');
$this->document->setTitle($this->language->get('heading_title'));
$data['breadcrumbs'] = [];
$data['breadcrumbs'][] = [
'text' => $this->language->get('text_home'),
'href' => $this->url->link('common/dashboard', 'user_token=' . $this->session->data['user_token'])
];
$data['breadcrumbs'][] = [
'text' => $this->language->get('text_extension'),
'href' => $this->url->link('marketplace/extension', 'user_token=' . $this->session->data['user_token'] . '&type=module')
];
if (!isset($this->request->get['module_id'])) {
$data['breadcrumbs'][] = [
'text' => $this->language->get('heading_title'),
'href' => $this->url->link('extension/opencart/module/popular', 'user_token=' . $this->session->data['user_token'])
];
} else {
$data['breadcrumbs'][] = [
'text' => $this->language->get('heading_title'),
'href' => $this->url->link('extension/opencart/module/popular', 'user_token=' . $this->session->data['user_token'] . '&module_id=' . $this->request->get['module_id'])
];
}
if (!isset($this->request->get['module_id'])) {
$data['save'] = $this->url->link('extension/opencart/module/popular.save', 'user_token=' . $this->session->data['user_token']);
} else {
$data['save'] = $this->url->link('extension/opencart/module/popular.save', 'user_token=' . $this->session->data['user_token'] . '&module_id=' . $this->request->get['module_id']);
}
$data['back'] = $this->url->link('marketplace/extension', 'user_token=' . $this->session->data['user_token'] . '&type=module');
// Extension
if (isset($this->request->get['module_id'])) {
$this->load->model('setting/module');
$module_info = $this->model_setting_module->getModule($this->request->get['module_id']);
}
if (isset($module_info['name'])) {
$data['name'] = $module_info['name'];
} else {
$data['name'] = '';
}
if (isset($module_info['axis'])) {
$data['axis'] = $module_info['axis'];
} else {
$data['axis'] = '';
}
if (isset($module_info['limit'])) {
$data['limit'] = $module_info['limit'];
} else {
$data['limit'] = 5;
}
if (isset($module_info['width'])) {
$data['width'] = $module_info['width'];
} else {
$data['width'] = 200;
}
if (isset($module_info['height'])) {
$data['height'] = $module_info['height'];
} else {
$data['height'] = 200;
}
if (isset($module_info['status'])) {
$data['status'] = $module_info['status'];
} else {
$data['status'] = '';
}
if (isset($this->request->get['module_id'])) {
$data['module_id'] = (int)$this->request->get['module_id'];
} else {
$data['module_id'] = 0;
}
$data['header'] = $this->load->controller('common/header');
$data['column_left'] = $this->load->controller('common/column_left');
$data['footer'] = $this->load->controller('common/footer');
$this->response->setOutput($this->load->view('extension/opencart/module/popular', $data));
}
/**
* Save
*
* @return void
*/
public function save(): void {
$this->load->language('extension/opencart/module/popular');
$json = [];
if (!$this->user->hasPermission('modify', 'extension/opencart/module/popular')) {
$json['error']['warning'] = $this->language->get('error_permission');
}
$required = [
'module_id' => 0,
'name' => '',
'width' => 0,
'height' => 0
];
$post_info = $this->request->post + $required;
if (!oc_validate_length($post_info['name'], 3, 64)) {
$json['error']['name'] = $this->language->get('error_name');
}
if (!$post_info['width']) {
$json['error']['width'] = $this->language->get('error_width');
}
if (!$post_info['height']) {
$json['error']['height'] = $this->language->get('error_height');
}
if (!$json) {
// Extension
$this->load->model('setting/module');
if (!$post_info['module_id']) {
$json['module_id'] = $this->model_setting_module->addModule('opencart.popular', $post_info);
} else {
$this->model_setting_module->editModule($post_info['module_id'], $post_info);
}
$this->cache->delete('product');
$json['success'] = $this->language->get('text_success');
}
$this->response->addHeader('Content-Type: application/json');
$this->response->setOutput(json_encode($json));
}
}
extension/opencart/admin/language/en-gb/module/popular.php — Stores all text labels and messages for easy translation and localization.
<?php
// Heading
$_['heading_title'] = 'Popular';
// Text
$_['text_extension'] = 'Extensions';
$_['text_success'] = 'Success: You have modified popular module!';
$_['text_edit'] = 'Edit Popular Module';
$_['text_horizontal'] = 'Horizontal';
$_['text_vertical'] = 'Vertical';
// Entry
$_['entry_name'] = 'Module Name';
$_['entry_axis'] = 'Axis';
$_['entry_limit'] = 'Limit';
$_['entry_width'] = 'Image Width';
$_['entry_height'] = 'Image Height';
$_['entry_status'] = 'Status';
// Error
$_['error_permission'] = 'Warning: You do not have permission to modify popular module!';
$_['error_name'] = 'Module Name must be between 3 and 64 characters!';
$_['error_width'] = 'Width required!';
$_['error_height'] = 'Height required!';
extension/opencart/admin/view/template/module/popular.twig — Defines how the module settings page looks inside the admin panel (status toggle, limit, image width/height, etc.).
{{ header }}{{ column_left }}
<div id="content">
<div class="page-header">
<div class="container-fluid">
<div class="float-end">
<button type="submit" form="form-module" data-bs-toggle="tooltip" title="{{ button_save }}" class="btn btn-primary"><i class="fa-solid fa-save"></i></button>
<a href="{{ back }}" data-bs-toggle="tooltip" title="{{ button_back }}" class="btn btn-light"><i class="fa-solid fa-reply"></i></a></div>
<h1>{{ heading_title }}</h1>
<ol class="breadcrumb">
{% for breadcrumb in breadcrumbs %}
<li class="breadcrumb-item"><a href="{{ breadcrumb.href }}">{{ breadcrumb.text }}</a></li>
{% endfor %}
</ol>
</div>
</div>
<div class="container-fluid">
<div class="card">
<div class="card-header"><i class="fa-solid fa-pencil"></i> {{ text_edit }}</div>
<div class="card-body">
<form id="form-module" action="{{ save }}" method="post" data-oc-toggle="ajax">
<div class="row mb-3">
<label for="input-name" class="col-sm-2 col-form-label">{{ entry_name }}</label>
<div class="col-sm-10">
<input type="text" name="name" value="{{ name }}" placeholder="{{ entry_name }}" id="input-name" class="form-control"/>
<div id="error-name" class="invalid-feedback"></div>
</div>
</div>
<div class="row mb-3">
<label for="input-axis" class="col-sm-2 col-form-label">{{ entry_axis }}</label>
<div class="col-sm-10">
<select name="axis" id="input-axis" class="form-select">
<option value="horizontal"{% if axis == 'horizontal' %} selected{% endif %}>{{ text_horizontal }}</option>
<option value="vertical"{% if axis == 'vertical' %} selected{% endif %}>{{ text_vertical }}</option>
</select>
</div>
</div>
<div class="row mb-3">
<label for="input-limit" class="col-sm-2 col-form-label">{{ entry_limit }}</label>
<div class="col-sm-10">
<input type="text" name="limit" value="{{ limit }}" placeholder="{{ entry_limit }}" id="input-limit" class="form-control"/>
</div>
</div>
<div class="row mb-3">
<label for="input-width" class="col-sm-2 col-form-label">{{ entry_width }}</label>
<div class="col-sm-10">
<input type="text" name="width" value="{{ width }}" placeholder="{{ entry_width }}" id="input-width" class="form-control"/>
<div id="error-width" class="invalid-feedback"></div>
</div>
</div>
<div class="row mb-3">
<label for="input-height" class="col-sm-2 col-form-label">{{ entry_height }}</label>
<div class="col-sm-10">
<input type="text" name="height" value="{{ height }}" placeholder="{{ entry_height }}" id="input-height" class="form-control"/>
<div id="error-height" class="invalid-feedback"></div>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-2 col-form-label">{{ entry_status }}</label>
<div class="col-sm-10">
<div class="form-check form-switch form-switch-lg">
<input type="hidden" name="status" value="0"/>
<input type="checkbox" name="status" value="1" id="input-status" class="form-check-input"{% if status %} checked{% endif %}/>
</div>
</div>
</div>
<input type="hidden" name="module_id" value="{{ module_id }}" id="input-module-id"/>
</form>
</div>
</div>
</div>
</div>
{{ footer }}
Catalog Files
The frontend part of the module displays popular products to users visiting the store.
extension/opencart/catalog/controller/module/popular.php — Loads product data using the model and passes it to the frontend view.
<?php
namespace Opencart\Catalog\Controller\Extension\Opencart\Module;
/**
* Class Popular
*
* @package Opencart\Catalog\Controller\Extension\Opencart\Module
*/
class Popular extends \Opencart\System\Engine\Controller {
/**
* Index
*
* @param array<string, mixed> $setting array of filters
*
* @return string
*/
public function index(array $setting): string {
$this->load->language('extension/opencart/module/popular');
$data['axis'] = $setting['axis'];
$data['products'] = [];
// Popular
$this->load->model('extension/opencart/module/popular');
// Image
$this->load->model('tool/image');
$results = $this->model_extension_opencart_module_popular->getPopular($setting['limit']);
if ($results) {
foreach ($results as $result) {
if ($result['image']) {
$image = $this->model_tool_image->resize(html_entity_decode($result['image'], ENT_QUOTES, 'UTF-8'), $setting['width'], $setting['height']);
} else {
$image = $this->model_tool_image->resize('placeholder.png', $setting['width'], $setting['height']);
}
if ($this->customer->isLogged() || !$this->config->get('config_customer_price')) {
$price = $this->currency->format($this->tax->calculate($result['price'], $result['tax_class_id'], $this->config->get('config_tax')), $this->session->data['currency']);
} else {
$price = false;
}
if ((float)$result['special']) {
$special = $this->currency->format($this->tax->calculate($result['special'], $result['tax_class_id'], $this->config->get('config_tax')), $this->session->data['currency']);
} else {
$special = false;
}
if ($this->config->get('config_tax')) {
$tax = $this->currency->format((float)$result['special'] ? $result['special'] : $result['price'], $this->session->data['currency']);
} else {
$tax = false;
}
$product_data = [
'product_id' => $result['product_id'],
'thumb' => $image,
'name' => $result['name'],
'description' => oc_substr(trim(strip_tags(html_entity_decode($result['description'], ENT_QUOTES, 'UTF-8'))), 0, $this->config->get('config_product_description_length')) . '..',
'price' => $price,
'special' => $special,
'tax' => $tax,
'minimum' => $result['minimum'] > 0 ? $result['minimum'] : 1,
'rating' => $result['rating'],
'href' => $this->url->link('product/product', 'language=' . $this->config->get('config_language') . '&product_id=' . $result['product_id'])
];
$data['products'][] = $this->load->controller('product/thumb', $product_data);
}
return $this->load->view('extension/opencart/module/popular', $data);
} else {
return '';
}
}
}
extension/opencart/catalog/language/en-gb/module/popular.php — Defines frontend language strings like "Popular Products" or "View Product".
<?php
// Heading
$_['heading_title'] = 'Popular';
extension/opencart/catalog/model/module/popular.php — Fetches data from the oc_product_viewed table for the most-viewed items.
<?php
namespace Opencart\Catalog\Model\Extension\Opencart\Module;
/**
* Class Popular
*
* Can be called from $this->load->model('extension/opencart/module/popular');
*
* @package Opencart\Catalog\Model\Extension\Opencart\Module
*/
class Popular extends \Opencart\Catalog\Model\Catalog\Product {
/**
* Get Popular
*
* @param int $limit
*
* @return array<int, array<string, mixed>>
*
* @example
*
* $results = $this->model_extension_opencart_module_popular->getPopular($limit);
*/
public function getPopular(int $limit): array {
$sql = "SELECT DISTINCT *, pd.name, pv.viewed, " . $this->statement['discount'] . ", " . $this->statement['special'] . ", " . $this->statement['reward'] . ", " . $this->statement['review'] . "
FROM `" . DB_PREFIX . "product_viewed` pv
INNER JOIN `" . DB_PREFIX . "product` p ON (p.product_id = pv.product_id)
INNER JOIN `" . DB_PREFIX . "product_to_store` p2s ON (p.product_id = p2s.product_id)
LEFT JOIN `" . DB_PREFIX . "product_description` pd ON (p.product_id = pd.product_id)
WHERE p.status = '1'
AND p.date_available <= NOW()
AND p2s.store_id = '" . (int)$this->config->get('config_store_id') . "'
AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "'
ORDER BY pv.viewed DESC
LIMIT 0," . (int)$limit;
$key = md5($sql);
$product_data = $this->cache->get('product.' . $key);
if (!$product_data) {
$query = $this->db->query($sql);
$product_data = $query->rows;
$this->cache->set('product.' . $key, $product_data);
}
return (array)$product_data;
}
}
extension/opencart/catalog/view/template/module/popular.twig — Displays the product cards (image, name, price, and links) in a clean layout.
<h3>{{ heading_title }}</h3>
<div class="row{% if axis == 'horizontal' %} row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-xl-4{% endif %}">
{% for product in products %}
<div class="col mb-3">{{ product }}</div>
{% endfor %}
</div>
Why Use the oc_product_viewed Table
While the oc_product_report table stores individual product views, querying it directly would be inefficient — especially for large stores. Instead, OpenCart maintains a summarized version of that data in oc_product_viewed.
This table contains only two important columns:
- product_id — The ID of the product.
- viewed — The total number of views recorded after report generation.
So, when we click “Generate” in the admin report, OpenCart recalculates these totals and updates the oc_product_viewed table. That means our module always reads from pre-processed data, ensuring maximum speed and minimal load on the database.
Steps to Configure and Display Popular Products
- Copy all files into their respective folders under
extension/opencart/. - Go to Admin → Extensions → Extensions → Modules.
- Locate Popular module and click Install.
- Click Edit to configure:
- Module Title
- Limit (how many products to show)
- Image Width/Height
- Status (Enable/Disable)
- Assign the module to a layout (e.g., Home, Category Page, etc.).
- Click Save.
Now, the module will display your most-viewed products on the store frontend, using real data from oc_product_viewed.
💡 Pro Tip: Keep Data Updated
Since OpenCart doesn’t auto-refresh the viewed report table in real time, remember to generate the report regularly. You can do it manually or automate it using a CRON job that triggers the “Generate Viewed Report” logic periodically.
Advantages of This Approach
- ✅ Ultra-fast queries (only two columns queried).
- ✅ Optimized for large catalogs.
- ✅ Uses existing OpenCart logic — no extra tracking code needed.
- ✅ Works seamlessly with default reports and statistics.
🎨 Frontend Display
Your popular.twig file can be customized to display:
- Product image and name
- Price and special price
- Short description (optional)
- Add to Cart or View Details buttons
You can easily match its design with your store theme and use the module in different positions such as home page, sidebar, or footer.
📈 SEO & Performance Benefits
Adding a popular products section improves both user experience and search engine visibility:
- ⭐ Helps users discover trending items quickly.
- ⭐ Increases time-on-page and reduces bounce rate.
- ⭐ Builds internal links to top-performing products.
- ⭐ Boosts chances of conversions for best-selling items.
🏁 Conclusion
By following this step-by-step approach, you’ve built a completely custom Popular Products module in OpenCart 4.x — fully optimized for performance and SEO.
Unlike older methods that used heavy queries or runtime calculations, this module leverages OpenCart’s existing oc_product_viewed table for speed and efficiency.
So, the next time you click “Generate” in the admin Products Viewed report, remember — it’s not just updating numbers; it’s also powering your store’s popular products display!
0 Comments