Call it a migration, not an update
OpenCart 4 is not a point release with a new coat of paint. The directory layout, the class structure, the template paths and the way extensions are packaged all changed at once. There is no dependable in-place upgrade button for a busy 3.0.x store, and every project that starts by looking for one loses a weekend. The method that works is boring: stand up a clean OpenCart 4 install next to the live store, move data into it under your own control, and switch over only after the new stack has survived a full rehearsal.
- Minimum PHP for 4.0.x
- 8.0
- Theme template path
- catalog/view/template/
- Extension package root
- extension/<code>/
The checklist below is the sequence we follow on real stores. It assumes shell and database access, a staging environment you are allowed to break, and a maintenance window you can defend.
What actually changed under the hood
Before you estimate anything, know what the platform did to your code. These are the changes that generate the work:
- Namespaces everywhere. Controllers and models are now classes such as Opencart\Catalog\Controller\Product\Product extending \Opencart\System\Engine\Controller. Custom files get rewritten, not patched.
- Template paths lost the theme folder. catalog/view/theme/default/template/product/product.twig became catalog/view/template/product/product.twig, and themes ship as extension packages instead of folders you drop in.
- Extensions live in one place. Instead of files scattered through admin/ and catalog/, a package owns extension/<code>/ with its own admin/, catalog/ and system/ trees plus an install.json manifest.
- Routes carry the package name. extension/payment/bank_transfer became extension/opencart/payment/bank_transfer, which quietly breaks every hardcoded link, template include and API call you wrote.
- config.php is not portable. OpenCart 4 adds constants such as DIR_OPENCART and DIR_EXTENSION; copying the 3.0.x file into a 4.x install is one of the fastest ways to reach a white screen.
- The event API moved. What you called through model_extension_event in 3.0.x is model_setting_event in 4.x.
Two things people expect to lose survive the move: you can still rename the admin folder, and still keep system/storage outside the web root. Both are constants in config.php, and both belong on day one rather than in a later hardening pass.
<?php
// OpenCart 4 - admin/config.php, the lines that matter after a rename
define('DIR_OPENCART', '/home/store/public_html/');
define('DIR_APPLICATION', DIR_OPENCART . 'yonetim/'); // renamed admin folder
define('DIR_EXTENSION', DIR_OPENCART . 'extension/');
define('DIR_STORAGE', '/home/store/storage/'); // outside the web rootExtensions and theme decide your timeline
The platform migration is predictable. Your extension list is not. An OCMOD patch written for 3.0.x searches for file paths, class names and method signatures that no longer exist in 4.x, so it either fails to apply or applies into the wrong place. Every commercial extension needs a 4.x build from its vendor, and some will not have one. Make that a week-one business decision, not a week-four surprise.
Themes are the same story. Journal 3 is built for OpenCart 3.x; the OpenCart 4 line is a separate product with its own licence and its own settings storage, so budget for rebuilding the homepage layout rather than exporting it. Before you price anything, take the inventory from the database instead of the admin screen:
-- Run on the live 3.0.x store
SELECT type, code FROM oc_extension ORDER BY type, code;
SELECT extension_install_id, filename, status
FROM oc_extension_install ORDER BY filename;
-- Which extension settings are actually switched on
SELECT code, `key`, value FROM oc_setting
WHERE `key` LIKE '%\_status' AND value = '1' ORDER BY code;In 4.x, oc_extension gained an extension_install_id column that ties each enabled extension back to the package it came from. It is a small change with a practical consequence: the installed-package list and the enabled-extension list can disagree, so check both in SQL when an extension refuses to reinstall cleanly.
PHP 8 is not a checkbox
OpenCart 4.0.x needs PHP 8.0 or newer. Custom code written against PHP 7.x does not simply carry over: 8.0 turned a long list of tolerated sloppiness into fatal errors. The failures we hit most often:
- Passing null where an internal function wants a string — trim(null) and htmlspecialchars(null) now emit deprecations and behave differently.
- Removed functions: create_function() and each() are gone, and both were common in older OpenCart extensions.
- Curly brace string offsets such as $string{0} are a parse error, so a single stale file kills the whole request.
- Stricter argument types on internal functions throw TypeError instead of quietly returning false.
- Undefined array keys and undefined properties are warnings that fill the log — noisy, but each one is a real bug somebody deferred.
Do the sweep mechanically before you promise a date, and run it against the PHP version you will actually deploy on:
# Parse-check every custom file against the target runtime
find . -name '*.php' -not -path './vendor/*' -print0 \
| xargs -0 -n1 php8.1 -l | grep -v 'No syntax errors'
# Deeper: compatibility sniff with PHP_CodeSniffer
phpcs -p . --standard=PHPCompatibility \
--runtime-set testVersion 8.1 --extensions=phpMoving the data
Install OpenCart 4 clean, then bring data across table by table. Restoring a 3.0.x dump into a 4.x install does not work: several tables changed shape, and the ones that did not still reference ids that only mean something next to their own schema. The table that catches everyone is oc_seo_url. In 3.0.x it holds a single query column such as product_id=42; in 4.x that is split into key and value, with a sort_order alongside.
-- 3.0.x -> 4.x: rebuild SEO keywords into the new shape
INSERT INTO oc4.oc_seo_url (store_id, language_id, `key`, `value`, keyword, sort_order)
SELECT store_id,
language_id,
SUBSTRING_INDEX(query, '=', 1),
SUBSTRING_INDEX(query, '=', -1),
keyword,
0
FROM oc3.oc_seo_url
WHERE query LIKE '%=%';Three more things to check by hand before you trust the migration:
- Passwords. Compare the schemes on both sides with SHOW COLUMNS FROM oc_customer LIKE 'salt'; — if the old store still carries a salt column, plan a password reset mailing instead of a silent copy.
- Order statuses. The defaults (1 Pending, 2 Processing, 3 Shipped, 5 Complete, 7 Canceled) are only defaults. Read oc_order_status on both stores and map by id deliberately, especially where an ERP or a shipping integration writes statuses.
- Abandoned orders. Rows in oc_order with order_status_id = 0 are unconfirmed baskets, not sales. Migrate them if you want the history, but never let them into a report or an ERP feed.
The rehearsal, in order
This is the part that keeps the store online. Run the sequence end to end on staging at least once before you schedule a cutover:
- 1Take a full file and database dump of the live store and prove the restore works somewhere else. An untested backup is a rumour.
- 2Install OpenCart 4 clean on a staging subdomain, behind HTTP auth and a noindex header, so it can never be crawled.
- 3Migrate catalog, customers and orders, then count rows on both sides — products, descriptions, categories, orders, order products — and reconcile every difference.
- 4Install only the 4.x extensions you decided to keep, one at a time, checking the storefront after each one.
- 5Rebuild the theme and walk the storefront on a phone: home, category, filtered category, product, cart, checkout, account.
- 6Place a full test order through every live payment method in sandbox mode and confirm the callback writes the order status you expect.
- 7Point invoicing, shipping and ERP integrations at staging and push a realistic day of traffic through them.
- 8Diff the SEO URLs against the live site and build a 301 map for anything that changed — before launch, not after the rankings move.
- 9Check every transactional e-mail, the contact form and the cron jobs, including any custom job quietly running on the old server.
- 10Load-test category and search pages at peak traffic levels before DNS changes hands.
Cutover and the rollback you hope not to use
Lower the DNS TTL a day ahead. Put the old store into maintenance mode for the freeze window, migrate the delta — orders, customers and reviews created since the rehearsal dump — then flip. Keep the 3.0.x stack running and reachable on an internal hostname for at least a week; the cost of the extra server is trivial next to the cost of not having it.
Agree the rollback triggers in writing before you start, so nobody has to argue about them at two in the morning. Ours are usually: checkout completion drops below its normal rate for thirty minutes, payment callbacks fail, admin cannot process orders, or the ERP feed stops. Any one of those, and you go back — the migration will still be there next weekend.
When to write to us
If the store carries real order volume, or the extension inventory has rows nobody recognises any more, a second pair of eyes on the plan is cheap insurance. We work at the launch rate of $10 per hour plus VAT, quote the hours before anything starts, and answer first messages within two hours between 09:00 and 22:00, Monday to Saturday. Send the extension list and the current PHP version — that is enough for a first read.
REST, GraphQL and gRPC backend architecture; code review and ADRs.