The default product sorting mechanism in VirtueMart has several major flaws:
It doesn't accurately display which sorting method is currently applied – neither the default set in the VirtueMart configuration nor the one selected by the user.
When users change categories, their chosen sorting is preserved in the session, but the sorting dropdown misleadingly displays an incorrect value.
It's affected by page caching, which may lead to inconsistent UI and UX.
I created a workaround that solves all of these issues. Key advantages of my solution:
Sorting is handled via AJAX – fully independent of page cache, always reflecting the actual state.
Accurately shows the active sorting method – whether default or user-selected.
Stores the value in a cookie – which allows sorting preference to persist across categories without relying on session hacks.
SEO-friendly – since the AJAX-generated output isn't indexed by Google, it avoids duplicate content issues and preserves crawl budget.
Server-friendly – bots and crawlers won't overload the server with unnecessary requests since the mechanism works in the background.
If you're struggling with unreliable or misleading sorting behavior in VirtueMart, I highly recommend this approach. It's a lightweight plugin + template override that works reliably on Joomla 5 and VM 4.x.
Happy to share the code and logic if anyone's interested :)
You should create a plugin in the folder
/plugins/ajax/vmordderby/vmorderby.php
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Router\Route;
class PlgAjaxVmorderby extends CMSPlugin
{
public function onAjaxVmorderby()
{
// Download data from Request
$input = \Joomla\CMS\Factory::getApplication()->input;
$virtuemart_category_id = $input->getInt('virtuemart_category_id');
$virtuemart_manufacturer_id = $input->getInt('virtuemart_manufacturer_id', 0);
$itemid = $input->getInt('Itemid');
// Load VM configuration
require_once JPATH_ADMINISTRATOR . '/components/com_virtuemart/helpers/config.php';
VmConfig::loadConfig();
$lang = Factory::getLanguage();
$lang->load('com_virtuemart', JPATH_SITE, null, true);
$fields = VmConfig::get('browse_orderby_fields');
$orderbyRaw = $input->getString('orderby');
$orderby = $orderbyRaw?: ($_COOKIE['vm_orderby'] ?? VmConfig::get('browse_orderby_field'));
$orderDir = VmConfig::get ('prd_brws_orderby_dir');
$orderDirTxt = vmText::_ ('COM_VIRTUEMART_SEARCH_ORDER_'.$orderDir);
// If you have a new sorting by Get, you will overwrite the cookie
if ($orderbyRaw) {
$normalizedOrderby = preg_replace('/^`?p`?\./', '', strtolower($orderbyRaw));
$normalizedOrderby = str_replace('`', '', $normalizedOrderby);
setcookie('vm_orderby', $normalizedOrderby, time() + 300, '/');
}
$clean = str_replace(['`p`.', 'p.', '`', '.'], '', strtolower($orderby));
$displayText = vmText::_('COM_VIRTUEMART_SEARCH_ORDER_' . strtoupper($clean));
echo htmlspecialchars($displayText)
. '<span style="display:none" id="orderby">' . htmlspecialchars($clean) . '</span>'
. '<span class="sort-icon text-muted ms-2">'.$orderDirTxt.' <i class="fa fa-sort-amount-up" aria-hidden="true"></i></span>';
echo '<ul class="orderlist" style="display:none;">';
foreach ($fields as $field) {
$fieldWithoutPrefix = strpos($field, '.') !== false ? substr($field, strpos($field, '.') + 1) : $field;
$text = vmText::_('COM_VIRTUEMART_SEARCH_ORDER_' . strtoupper(str_replace([',',' '], ['_',''], $fieldWithoutPrefix)));
$link = 'index.php?option=com_virtuemart&view=category'
. '&orderby=' . $fieldWithoutPrefix
. '&virtuemart_category_id=' . $virtuemart_category_id
. '&virtuemart_manufacturer_id=' . $virtuemart_manufacturer_id
. '&Itemid=' . $itemid;
$link = Route::_($link, false);
echo '<li><a href="' . htmlspecialchars($link) . '">' . htmlspecialchars($text) . '</a></li>';
}
echo '</ul>';
}
}
And this xml file
/plugins/ajax/vmordderby/vmorderby.xml
<?xml version="1.0" encoding="utf-8"?>
<extension type="plugin" group="ajax" method="upgrade" version="5.0">
<name>plg_ajax_vmorderby</name>
<author>Piekielko.com</author>
<version>1.0.0</version>
<description>AJAX loader VMorderby by Hazael</description>
<files>
<filename plugin="vmorderby">vmorderby.php</filename>
</files>
</extension>
You can install the created plug from the ZIP package or after creating it in the indicated folder, detect it in the administrator panel
/administrator/index.php?option=com_installer&view=discover
- install it and publish it.
Then, in the Virtuemart template file
/templates/template-name/html/com_virtuemart/sublayouts/orderby.php
Replace the entire code with this
<?php
$categoryId = vRequest::getInt('virtuemart_category_id', 0);
$manufacturerId = vRequest::getInt('virtuemart_manufacturer_id', 0);
$itemId = JFactory::getApplication()->input->getInt('Itemid', 0);
$orderby = $_GET['orderby'];
?>
<div class="lista"><span id="ajax-orderby" class="activeOrder livesearch d-flex align-items-center justify-content-between"></span></div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const categoryId = <?php echo (int)$categoryId; ?>;
const manufacturerId = <?php echo (int)$manufacturerId; ?>;
const itemId = <?php echo (int)$itemId; ?>;
const urlParams = new URLSearchParams(window.location.search);
const orderbyParam = '<?php echo $orderby; ?>';
let ajaxUrl = '/index.php?option=com_ajax&plugin=vmorderby&format=raw'
+ '&virtuemart_category_id=' + categoryId
+ '&virtuemart_manufacturer_id=' + manufacturerId
+ '&Itemid=' + itemId;
if (orderbyParam) {
ajaxUrl += '&orderby=' + orderbyParam;
}
fetch(ajaxUrl)
.then(res => res.text())
.then(html => {
document.getElementById('ajax-orderby').innerHTML = html;
const selectWrapper = document.querySelector(".activeOrder");
const selectOptions = document.querySelector(".orderlist");
document.body.addEventListener("click", function(event) {
if (!event.target.closest(".lista")) {
selectOptions.style.display = "none";
}
});
if (selectWrapper) {
selectWrapper.addEventListener("click", function() {
selectOptions.style.display = (selectOptions.style.display === "block") ? "none" : "block";
});
}
});
});
</script>
My solution is available on this page:
Example 1: https://szeregowe.pl/projekty-domow
After changing sorting, you can go to another category and the check box will display your currently selected sorting regardless of the web cache
Example 2: https://szeregowe.pl/blizniaki
order.png
Wow, good stuff! Thanks for sharing your work :)
THX! 8)
A small fix to the code ensures that the product list is always generated according to the customer's selection, regardless of server-side page caching.
in orderby.php
<?php
defined('_JEXEC') or die;
$categoryId = vRequest::getInt('virtuemart_category_id', 0);
$manufacturerId = vRequest::getInt('virtuemart_manufacturer_id', 0);
$itemId = JFactory::getApplication()->input->getInt('Itemid', 0);
$orderby = $_GET['orderby'] ?? '';
?>
<div class="lista"><span id="ajax-orderby" class="activeOrder livesearch d-flex align-items-center justify-content-between"></span></div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const categoryId = <?php echo (int)$categoryId; ?>;
const manufacturerId = <?php echo (int)$manufacturerId; ?>;
const itemId = <?php echo (int)$itemId; ?>;
const orderbyParam = '<?php echo $orderby; ?>';
let ajaxUrl = '/index.php?option=com_ajax&plugin=vmorderby&format=raw'
+ '&virtuemart_category_id=' + categoryId
+ '&virtuemart_manufacturer_id=' + manufacturerId
+ '&Itemid=' + itemId;
if (orderbyParam) {
ajaxUrl += '&orderby=' + orderbyParam;
}
fetch(ajaxUrl)
.then(res => res.text())
.then(html => {
document.getElementById('ajax-orderby').innerHTML = html;
// Dropdown
const selectWrapper = document.querySelector(".activeOrder");
const selectOptions = document.querySelector(".orderlist");
document.body.addEventListener("click", function(event) {
if (!event.target.closest(".lista")) {
selectOptions.style.display = "none";
}
});
if (selectWrapper) {
selectWrapper.addEventListener("click", function() {
selectOptions.style.display = (selectOptions.style.display === "block") ? "none" : "block";
});
}
});
});
</script>
in vmorderby.php
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Router\Route;
class PlgAjaxVmorderby extends CMSPlugin
{
public function onAjaxVmorderby()
{
$input = Factory::getApplication()->input;
$user = Factory::getUser();
$virtuemart_category_id = $input->getInt('virtuemart_category_id');
$virtuemart_manufacturer_id = $input->getInt('virtuemart_manufacturer_id', 0);
$itemid = $input->getInt('Itemid');
// Load VirtueMart config
require_once JPATH_ADMINISTRATOR . '/components/com_virtuemart/helpers/config.php';
VmConfig::loadConfig();
$lang = Factory::getLanguage();
$lang->load('com_virtuemart', JPATH_SITE, null, true);
$fields = VmConfig::get('browse_orderby_fields');
$defaultField = VmConfig::get('browse_orderby_field');
$orderbyRaw = $input->getString('orderby');
// Detect potential cache
$isLikelyCached = empty($orderbyRaw) && $user->guest;
if ($isLikelyCached) {
$orderby = $defaultField;
// Remove the cookie, as the view is already from the cache anyway
if (isset($_COOKIE['vm_orderby'])) {
setcookie('vm_orderby', '', time() - 3600, '/');
unset($_COOKIE['vm_orderby']);
}
} else {
$orderby = $orderbyRaw ?: ($_COOKIE['vm_orderby'] ?? $defaultField);
}
// Set the sorting method
$orderby = $isLikelyCached
? $defaultField
: ($orderbyRaw ?: ($_COOKIE['vm_orderby'] ?? $defaultField));
// Save cookie when sorting changes
if ($orderbyRaw) {
$normalizedOrderby = preg_replace('/^`?p`?\./', '', strtolower($orderbyRaw));
$normalizedOrderby = str_replace('`', '', $normalizedOrderby);
setcookie('vm_orderby', $normalizedOrderby, time() + 300, '/');
}
// Process to translation key
$clean = str_replace(['`p`.', 'p.', '`', '.'], '', strtolower($orderby));
$translationKey = $clean ?: str_replace(['`p`.', 'p.', '`', '.'], '', strtolower($defaultField));
$displayText = vmText::_('COM_VIRTUEMART_SEARCH_ORDER_' . strtoupper($translationKey));
// Sorting direction
$orderDir = VmConfig::get('prd_brws_orderby_dir');
$orderDirTxt = vmText::_('COM_VIRTUEMART_SEARCH_ORDER_' . $orderDir);
// Link to active sorting
$activeLink = 'index.php?option=com_virtuemart&view=category'
. '&orderby=' . $clean
. '&virtuemart_category_id=' . $virtuemart_category_id
. '&virtuemart_manufacturer_id=' . $virtuemart_manufacturer_id
. '&Itemid=' . $itemid;
$activeLink = Route::_($activeLink, false);
// Main sorting field - You can transfer data from this area to JavaScript for correct pagination on the product list
echo htmlspecialchars($displayText)
. '<span style="display:none" id="orderby" data-url="' . htmlspecialchars($activeLink) . '">' . htmlspecialchars($clean) . '</span>'
. '<span class="sort-icon text-muted ms-2">' . $orderDirTxt . ' <i class="fa fa-sort-amount-up" aria-hidden="true"></i></span>';
// List of sorting options
echo '<ul class="orderlist" style="display:none;">';
foreach ($fields as $field) {
$fieldWithoutPrefix = strpos($field, '.') !== false
? substr($field, strpos($field, '.') + 1)
: $field;
$text = vmText::_('COM_VIRTUEMART_SEARCH_ORDER_' . strtoupper(str_replace([',', ' '], ['_', ''], $fieldWithoutPrefix)));
$link = 'index.php?option=com_virtuemart&view=category'
. '&orderby=' . $fieldWithoutPrefix
. '&virtuemart_category_id=' . $virtuemart_category_id
. '&virtuemart_manufacturer_id=' . $virtuemart_manufacturer_id
. '&Itemid=' . $itemid;
$link = Route::_($link, true);
echo '<li><a href="' . htmlspecialchars($link) . '">' . htmlspecialchars($text) . '</a></li>';
}
echo '</ul>';
}
}
If the user is logged in, the sorting option displayed is based on the user's selected method. The system reads the orderby value from the URL or from the cookie (vm_orderby), so the displayed product list and the selected sorting option are consistent.
If the user is not logged in, the page is often served from server cache. In that case, if no sorting has been selected, the system always displays the default sorting option defined in VirtueMart settings. If the guest user changes the sorting option, a new URL is generated with the selected method, and the page reloads without cache, correctly reflecting the sorting choice.
Additionally, if a cached page is served and a cookie exists with a different sorting method, the cookie is ignored or deleted. This prevents inconsistencies between the visible product list and the selected sorting option.