#15664·opencart

[4.x.x.x] OpenCart 4.1.0.4 — Core Bugs Found During a 1.5.x → 4.1 Migration

Author: labbs1Created Aug 21, 2026Updated Aug 21, 2026

We recently migrated a live store from a legacy OpenCart 1.5.x-era install to a clean OpenCart 4.1.0.4 installation — full data migration (customers, orders, products, categories) plus a custom theme, working with AI-assisted debugging throughout the project. In the process of getting checkout, admin order management, and SEO URLs working correctly with our setup (a custom database table prefix, and an optional/non-required zone field, which many merchants outside zone-heavy markets like the US need), we ran into the following four core bugs. All are reproducible on a clean 4.1.0.4 install and are not specific to our customizations — we've included the exact trigger condition for each.


1. Hardcoded oc_ table prefix in catalog/model/catalog/product.php

File: catalog/model/catalog/product.php Affects: Any installation using a custom DB_PREFIX (e.g. oc_mystore_) other than the default oc_.

In getProducts() and getTotalProducts(), the INNER JOIN to the product table inside the filter-matching subquery is hardcoded as `oc_product` instead of using the DB_PREFIX constant like every other table reference in the same query.

php
// Current (broken for custom prefixes):
$sql .= " INNER JOIN (SELECT `pf`.`product_id` FROM `" . DB_PREFIX . "product_filter` `pf` WHERE ... ) `f` ON `f`.`product_id` = `p2s`.`product_id` INNER JOIN `oc_product` `p` ON (...)";

// Fix:
$sql .= " INNER JOIN (SELECT `pf`.`product_id` FROM `" . DB_PREFIX . "product_filter` `pf` WHERE ... ) `f` ON `f`.`product_id` = `p2s`.`product_id` INNER JOIN `" . DB_PREFIX . "product` `p` ON (...)";

This only triggers when at least one product filter is selected (the "Filter" module), so it's easy to miss in testing with a default oc_ prefix. With a custom prefix it throws a hard SQL error: Table 'yourdb.oc_product' doesn't exist.

Occurs twice in the same file — once in getProducts(), once in getTotalProducts().


2. Missing (int) cast before Zone::getZone() causes a TypeError when zone is optional

Files:

  • catalog/controller/checkout/register.php (2 occurrences: payment and shipping)
  • catalog/controller/api/payment_address.php
  • catalog/controller/api/shipping_address.php
  • extension/opencart/catalog/controller/checkout/shipping.php

Opencart\Catalog\Model\Localisation\Zone::getZone() is declared with a strict int $zone_id parameter. In all five call sites above, the zone ID is passed straight from $post_info without casting:

php
// Current:
$zone_info = $this->model_localisation_zone->getZone($post_info['payment_zone_id']);

// Fix:
$zone_info = $this->model_localisation_zone->getZone((int)$post_info['payment_zone_id']);

Under normal conditions payment_zone_id/shipping_zone_id is always a non-empty numeric string because the zone field is required on the front end, so this never surfaces. As soon as a merchant makes the zone field optional (a very common request for markets — like ours — where county/state data isn't practically used), an empty string reaches getZone() and PHP 8's strict typing throws:

Opencart\Catalog\Model\Localisation\Zone::getZone(): Argument #1 ($zone_id) must be of type int, string given

This is a fatal error, not a warning — it breaks checkout entirely for any customer without a zone selected, and breaks the admin "change payment/shipping method" flow on existing orders (sale/order.call) the same way.


3. catalog/controller/startup/seo_url.php never infers route for a bare SEO URL

File: catalog/controller/startup/seo_url.php, index() method

When a URL is decoded via the rewrite engine, the code correctly matches recognized keys (e.g. product_id, path) against the seo_url table and strips them from the path — but it never sets $this->request->get['route'] based on which key was matched. It falls straight through to:

php
if (!isset($this->request->get['route'])) {
    $this->request->get['route'] = $this->config->get('action_default');
}

So a clean SEO URL with no query string at all (https://example.com/product-name, as opposed to https://example.com/product-name?route=product/product&language=en-gb) always resolves to the homepage instead of the intended page. Suggested fix — infer the route from the matched key before falling back to default:

php
if (!isset($this->request->get['route']) && $matched_keys) {
    if (in_array('product_id', $matched_keys, true)) {
        $this->request->get['route'] = 'product/product';
    } elseif (in_array('path', $matched_keys, true)) {
        $this->request->get['route'] = 'product/category';
    } elseif (in_array('manufacturer_id', $matched_keys, true)) {
        $this->request->get['route'] = 'product/manufacturer';
    } elseif (in_array('information_id', $matched_keys, true)) {
        $this->request->get['route'] = 'information/information';
    }
}

if (!isset($this->request->get['route'])) {
    $this->request->get['route'] = $this->config->get('action_default');
}

This means every generated SEO URL only ever actually works when accessed via a link OpenCart itself generated (which happens to include ?route=... alongside the pretty path); a bare, shared, or bookmarked clean URL silently serves the homepage instead of a 404 or the correct page.


4. Missing null-safety for payment_method in invoice generation

File: admin/controller/sale/order.php, around line 1567 (in the invoice-building method)

php
'shipping_method'  => ($order_info['shipping_method'] ? $order_info['shipping_method']['name'] : ''),
'payment_method'   => $order_info['payment_method']['name'],

The shipping_method line correctly guards against a null/empty value before indexing into it. The very next line, payment_method, does not have the same guard, despite being populated identically (json_decode() of an order column, defaulting to []/null when empty or invalid). Printing an invoice for any order whose payment_method column is empty or not valid JSON throws:

Warning: Trying to access array offset on null in admin/controller/sale/order.php on line 1567

Suggested fix, matching the existing pattern one line above:

php
'payment_method' => ($order_info['payment_method'] ? $order_info['payment_method']['name'] : ''),

Environment

  • OpenCart 4.1.0.4
  • PHP 8.x (strict types relevant to bug #2)
  • MySQL/MariaDB on shared hosting
  • Reproduced with a non-default DB_PREFIX for bug #1; bugs #2–#4 are prefix-independent and reproducible on any install once the corresponding condition is met (optional zone field, bare SEO URL, empty payment_method).

Happy to share more detail — reproduction steps, a minimal test store, or a full diff/patch — for any of the above if it's useful for a fix.