← Blog

Joomla

Joomla 3 to 5 migration: 17 gotchas the upgrader skipped

We migrated a 14,000-article Antwerp publisher from Joomla 3.10 to Joomla 5.2 last quarter. Here are the seventeen component gotchas the official tool skipped, in order of damage.

Jacob Molkenboer· Founder · A Brand New Company· 26 Oct 2024· 9 min
Leather logbook, brass key on cream card, wax-sealed envelope with green ribbon, red rubber stamp on ivory paper.

It is a Tuesday morning in Berchem. The editor-in-chief opens the staging site. The article list loads. The article body loads. The byline does not. The publish date reads 01-01-1970. The related-articles module throws a fatal in Dutch. We are six weeks into a Joomla 3.10 to Joomla 5.2 migration on a Belgian publisher with 14,000 articles, eleven custom components, and a print workflow that touches every one of them.

The official Joomla migration guide tells you to run the pre-update check, install the database fixer, and trust your extensions. The post-install messages flag the easy stuff. What follows is the rest, ordered by how loud the bug was when we found it. If you are about to rewrite a component for Joomla 4 or 5, paste this above your editor.

1. The dispatcher is now mandatory

In Joomla 3, a component's entry point was components/com_foo/foo.php calling JControllerLegacy::getInstance(). In Joomla 5 you need a Dispatcher class and a service provider that registers it. The old entry point still loads, but every modern API call assumes the container is wired. The migration tool does not generate either file.

// services/provider.php
defined('_JEXEC') or die;

use Joomla\CMS\Extension\Service\Provider\ComponentDispatcherFactory;
use Joomla\CMS\Extension\Service\Provider\MVCFactory;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;

return new class implements ServiceProviderInterface {
    public function register(Container $c): void
    {
        $c->registerServiceProvider(new MVCFactory('\\Acme\\Component\\News'));
        $c->registerServiceProvider(new ComponentDispatcherFactory('\\Acme\\Component\\News'));
    }
};

2. PSR-4 layout, or nothing autoloads

Joomla 4 introduced a strict namespaced layout: administrator/components/com_news/src/Controller/, src/Model/, src/View/. Your old controllers/article.php is invisible to the new container. The compatibility plugin will load classic JModelLegacy children for a while, but anything that calls $app->bootComponent('com_news') returns a stub. The migration tool restructures core. It does not restructure you.

3. JFactory works, until it does not

The backwards-compatibility shim keeps JFactory::getApplication() alive in 4.x. In 5.0 it goes away. Search-and-replace is straightforward except for one pattern: JFactory::getDbo() inside a model that already extends BaseDatabaseModel needs $this->getDatabase(), because the global DBO can resolve to a different connection during CLI runs. We lost an afternoon to that on the print-export cron.

// before
$db = JFactory::getDbo();

// after, in a model
$db = $this->getDatabase();

// after, anywhere else
$db = Factory::getContainer()->get('DatabaseDriver');

4. HTMLHelper rewrites the asset registry

Every JHtml::_('behavior.formvalidator') is now HTMLHelper::_('behavior.formvalidator'), fine. The trap is HTMLHelper::_('jquery.framework'). Joomla 5 ships without jQuery on the front end. The call still resolves, but the script is not actually loaded unless your template opts in. Two of our modules expected $.fn.colorbox at DOMContentLoaded and failed silently in production.

5. Bootstrap 2 markup is dead markup

The core moved from Bootstrap 2 to Bootstrap 5, which is two major rewrites of class names per the official Bootstrap 5 migration guide. .span6 is now .col-md-6, .well is gone, data attributes are prefixed data-bs-. None of this is auto-converted in your component views. We wrote a one-off regex pass, reviewed it by eye, then re-reviewed it with the designer before any newsroom-facing template shipped.

6. jQuery is opt-in

If a module needs jQuery in Joomla 5, you load it yourself:

$wa = $this->getApplication()->getDocument()->getWebAssetManager();
$wa->useScript('jquery');

The Web Asset Manager deduplicates across the page, so loading it in two places is cheap. Forgetting it in any place is a console error and a broken module.

7. Text class, and the constant files moved

JText::_ becomes Text::_, and the language files now live under language/en-GB/com_news.ini rather than language/en-GB/en-GB.com_news.ini. The migration installer keeps the old path readable, but new strings added there are ignored. We rewrote our editor's keyboard-shortcut overlay three times before noticing the file we were editing was no longer the file being read.

8. addfieldprefix grew a namespace

Custom field types in form XML have shifted twice. Joomla 3 used addfieldprefix="JFormFieldFoo". Joomla 4 introduced addfieldpath and a namespaced field location. Joomla 5 expects the fully qualified namespace. Old XML files load without warning and quietly render text inputs where you had a category picker.

9. ACL asset rules on nested categories

Asset rules used to be keyed on com_news.category.42. After the database fixer rebuilt the asset table, our editorial-board ACL on a four-level-deep category had vanished. Re-grant the role on the parent and the rules cascade again. The fix is one click; finding the cause took a morning.

10. com_content::getItem changed shape

If your component reuses ArticleModel::getItem($pk) from core com_content, the returned object now lazy-loads custom fields. Calling $item->jcfields before the article view runs returns null. Force the load:

PluginHelper::importPlugin('content');
Factory::getApplication()->triggerEvent(
    'onContentPrepare',
    ['com_content.article', &$item, &$params, 0]
);

11. TinyMCE 6 dropped half your plugins

TinyMCE 4 to 6 is its own migration inside the Joomla migration. paste, contextmenu, textcolor, and spellchecker are gone or merged. The Joomla editor profile UI keeps the old checkboxes for one release, but unchecked plugins silently disappear. Editors complained that copy-paste from Word dropped formatting overnight. The fix was to enable the new paste settings explicitly in the profile.

12. Routing requires a RouterView

SEF URL parsing in Joomla 3 was a procedural router.php. Joomla 5 expects a Site\Service\Router extending RouterView, with explicit RouterViewConfiguration per view and per nested view. Old router.php files still execute for inbound requests, but the new menu builder ignores them, so any new menu item points at the raw query string. Rewriting the router from scratch took us a day; rewriting from the legacy file would have taken three.

13. MySQL 8 strict mode finds your loose quotes

Joomla 5's installer enables sql_mode=STRICT_TRANS_TABLES on new installs. Migrating onto a fresh MySQL 8 box exposed every WHERE id = '12' against an integer column. The query builder is fine; raw SQL passed to $db->setQuery() is not. Run your slow-query log for a week after release and grep for truncated incorrect.

14. CacheController is built via factory only

new JCache(...) and JCache::getInstance() are removed. The replacement:

use Joomla\CMS\Cache\CacheControllerFactoryInterface;

$cache = Factory::getContainer()
    ->get(CacheControllerFactoryInterface::class)
    ->createCacheController('output', ['defaultgroup' => 'com_news']);

Any plugin that cached its own output via the old API simply did not cache anymore. We noticed when the front-page TTFB climbed by 380 ms after release.

15. Toolbar buttons are objects now

JToolBarHelper::save('article.save') is now $toolbar->standardButton('save', 'JTOOLBAR_SAVE', 'article.save'). The static facade ToolbarHelper still works for the common buttons. Anything custom needs the new fluent API and a custom button class extending Joomla\CMS\Toolbar\Button. The compatibility plugin renders nothing for unknown calls.

16. Custom field types need a class attribute

Form XML now wants an explicit class on custom field elements, otherwise the renderer falls back to text:

<field
    name="region"
    type="region"
    class="Acme\Component\News\Administrator\Field\RegionField"
    label="COM_NEWS_FIELD_REGION_LABEL" />

17. Tag plugin events were renamed

If you wrote a tag plugin against onContentPrepare with the context com_tags.tag, that context still fires. The new onTagsBeforeDisplay event was added in 4.2 and several core plugins moved to it. Mixed-context plugins double-rendered tags in our related-articles module until we audited every $context string in every plugin we owned.

Warning

The post-install compatibility check is a smoke alarm, not a sprinkler. It flags deprecated calls in core. It will not flag your missing service provider, your Bootstrap 2 view, or a TinyMCE plugin that has gone quiet. Plan a full QA cycle per component.

What we would do differently

Three things. First, build the service provider and dispatcher on day one, even before porting any views. The container shape forces every other decision downstream. Second, write the Bootstrap 2 to 5 regex pass, run it against a copy, then read every diff out loud with a designer. We caught two layout regressions that the regex would have happily shipped to staging. Third, do not migrate the editor on the same release as the component rewrite. Give the newsroom a week to get used to the new TinyMCE toolbar before the URLs and templates change underneath them.

When we ran this legacy migration for the Antwerp publisher, the thing we ran into was that the official compatibility plugin gave us a green light on the database schema while staying silent on the seventeen issues above. We solved it by writing the one-page checklist you just read and walking every custom component through it before staging. That checklist is now the first artefact on every Joomla project that crosses our desk.

If you are starting this migration today, take ten minutes and grep your codebase for JFactory::, JHtml::, JText::, and JToolBarHelper::. The count is your homework.

Key takeaway

Joomla's post-install compatibility check is a smoke alarm, not a sprinkler. It flags core, not your code. Audit every custom component by hand.

FAQ

Does Joomla's built-in compatibility check catch broken component code?

It flags deprecated core API calls and known database schema issues. It does not parse your custom controllers, views, or modules. Plan a manual QA pass per component.

Is jQuery still loaded by default in Joomla 5?

No. The front end ships without jQuery. If a module needs it, call $wa->useScript('jquery') on the Web Asset Manager, or your scripts will fail silently in production.

Can I keep my old router.php through to Joomla 5?

It still executes for inbound requests, but the new menu builder ignores it. New menu items point at raw query strings until you migrate to a RouterView class.

What is the safest order to migrate a Joomla 3 component?

Build the service provider and dispatcher first, then the namespaced PSR-4 layout, then views, then the router. Routing depends on every other piece being in place.

joomlamigrationlegacy sitesphpmysqlcase study

Building something?

Start a project