Joomla
Joomla 3 to 5 migration: the K2 and Falang playbook
At 11pm in February, a Joomla 3.10 EOL banner stared back at the marketing lead of a 2,800-article tourism site. Here is the playbook we ran from there.

At 11pm on a Tuesday in February, the marketing lead of a city tourism board forwarded us a screenshot of her Joomla admin. Version 3.10.12. A red banner across the top reading this release has reached end of life. Underneath it, 2,800 articles in three languages, a K2 catalogue of about 600 venues with seven custom fields each, and a Falang routing layer that nobody had benchmarked since 2019. The hosting provider had given her 90 days before they would force a PHP upgrade that the site would not survive.
We have run this migration shape often enough that we keep a checklist. This post is that checklist, written out long, with the parts that bite spelled out. If you are sitting in front of the same red banner, you can use it.
The cliff you are standing on
Joomla 3.10 reached end of life on 17 August 2023. The official notice is clear: no more security patches, no more bug fixes, no more compatibility guarantees. Joomla 5.0 shipped in October 2023 and requires PHP 8.1 as a hard floor. Joomla 3 was happiest on PHP 7.4 and tolerated 8.0 if you held its hand.
That gap is where most legacy Joomla sites die quietly. The host bumps PHP to 8.2 because a CVE forces them to, the site throws a white page, the agency that built it in 2017 has been gone for four years, and the marketing lead spends a weekend trying to find a backup that still boots. Whatever security advisories land between now and your migration date are advisories you will not receive, because the Joomla security team only publishes for supported branches. If your install runs a vulnerable Phoca, JCE, or Akeeba build alongside core, you are reading about the exploit on a forum after the fact, not in your inbox before.
You cannot go from 3.10 to 5.x in a single step. The supported path is 3.10 to 4.4 to 5.x. K2 and Falang both have to hop with you, and both can break in ways that look fine in the admin and only show up on a public URL three weeks later.
Inventory before you touch anything
The first day of any of these migrations is read-only. We clone production to a staging subdomain, lock it behind basic auth, and write down what is actually on the site. Most clients are surprised by what comes back. This client's site had 41 extensions installed. Twelve had not been updated since 2018. Three were forks of dead projects. One was a SOAP client that talked to a tourism API that had been replaced by REST in 2020 and was returning HTTP 410 on every request. A custom plugin from a long-departed freelancer was hooking onContentPrepare on every article load and silently swallowing exceptions, which is why the marketing team had been seeing the occasional blank article for years without anybody catching the root cause.
For K2, we start with a content audit straight against the database. This query tells you which custom fields are actually carrying weight and which are dead schema:
SELECT
ef.id,
ef.name,
ef.type,
COUNT(DISTINCT i.id) AS items_using
FROM jos_k2_extra_fields ef
LEFT JOIN jos_k2_items i
ON i.extra_fields LIKE CONCAT('%"id":"', ef.id, '"%')
AND i.trash = 0
AND i.published = 1
GROUP BY ef.id, ef.name, ef.type
ORDER BY items_using DESC;
On the tourism site this query returned 14 custom fields. Seven were in heavy use. Two had never been filled in on any item. Five were ghosts left over from a 2018 redesign. We deleted nothing yet, but the report drove every decision afterwards: which fields had to migrate cleanly, which we could quietly retire, and which front-end templates would need to be re-tested item by item.
The Falang URL list nobody had
Falang is the multilingual extension by Faboba. It hooks into Joomla's router and rewrites article aliases per language. When it works, you do not notice it. When it breaks during a migration, every Dutch and French URL on the site quietly 404s and your sitemap reports green because the canonical English URLs are still fine.
Before we touched anything, we built a benchmark. The team had three sitemaps generated by OSMap, one per language. We hit every URL and stored the status code, final URL after redirects, and the SHA1 of the body:
#!/usr/bin/env bash
# Benchmark every Falang-routed URL before the migration
set -euo pipefail
for lang in nl fr en; do
curl -s "https://www.example.be/sitemap-${lang}.xml" \
| xmllint --xpath '//*[local-name()="loc"]/text()' - \
| tr ' ' '\n' \
> urls-${lang}.txt
xargs -a urls-${lang}.txt -P 8 -I {} sh -c '
code=$(curl -o body.tmp -s -w "%{http_code}" -L "$1")
sha=$(sha1sum body.tmp | cut -d" " -f1)
printf "%s\t%s\t%s\n" "$code" "$sha" "$1"
' _ {} \
> baseline-${lang}.tsv
done
The baseline came back with 8,412 URLs across three languages. 27 were already returning 404 in production. Those got logged and ignored. Everything else became the contract: after migration, every one of those URLs had to return 200 from the same canonical path or be 301-redirected somewhere sensible. The SHA1 column matters as much as the status code. A URL that quietly returns the language fallback page instead of the translated content still returns 200, and the only way you notice is the body hash drifting between baseline and post-migration.
The version path
Joomla 3.10 to Joomla 4.4 is the painful hop. K2 versions before 2.11 will not boot on Joomla 4. Falang stopped shipping a Joomla 3 build in 2023 and now ships only J4 and J5 builds. Anything custom that touches JFactory, JRequest, or MooTools needs to be rewritten before you press the update button.
Do not run Joomla's built-in 3 to 4 updater until every installed extension has a confirmed J4-compatible release. The updater will go ahead and complete with incompatible extensions still installed, leaving you with a site that boots into a fatal error and a database half-converted.
Our pre-flight on the tourism site killed eight extensions outright. Five had J4 builds we could install. Three were rebuilt in-house as small custom components because the original maintainers were gone. One was a SuperUser-only utility that was replaced with a 12-line CLI script. Two more extensions were officially J4-compatible but pulled in jQuery globals from a hardcoded CDN path; we replaced them with the bundled Joomla version and lost a flash of unstyled content that the marketing team had assumed was a hosting bug.
From there the order is fixed:
- Bump PHP on staging to 8.1 with the J3 site still installed. Fix every deprecation that throws.
- Upgrade K2 to the latest J3-compatible 2.x build. Confirm the catalogue still renders.
- Upgrade Falang to the latest J3-compatible build. Re-run the URL benchmark.
- Run the Joomla 3 to 4 update through the admin. Walk away for ten minutes.
- Reinstall K2 with the J4 build over the top. K2 ships its own updater that handles the schema delta.
- Reinstall Falang with the J4 build. Reimport the language definitions if Falang asks.
- Run the J4 to J5 update. This one is usually quiet if step 4 was clean.
Joomla 5 ships a real CLI console. After the first hop, every subsequent action ran from the command line:
php cli/joomla.php core:check-updates
php cli/joomla.php extension:installfile /tmp/com_k2_v2.11.5.zip
php cli/joomla.php extension:installfile /tmp/com_falang_v3.0.2.zip
php cli/joomla.php finder:index
php cli/joomla.php session:gc
Between each hop we took a full mysqldump and tarballed the codebase. Disk is cheap and the third time you have to undo a half-converted schema by hand, you learn to spend the two minutes on a snapshot.
The K2 custom fields trap
K2 stores custom field values as a serialised blob in jos_k2_items.extra_fields. Older K2 builds wrote it as a custom string format halfway between PHP serialize() and pipe-delimited values. The 2.11 build switched to a clean JSON array. The K2 maintenance panel will convert the old format to the new one when you trigger a resave, but the read path in the new build does not detect the old format gracefully: it tries to json_decode, gets null, and renders the field as empty. Editors will not notice for a week because the front-end falls back to the article body.
The defence is a smoke test that runs after every migration step. We wrote a tiny PHP CLI that loads K2's own model and asserts that a sample of 50 items still returns the expected fields:
<?php
// cli/k2-smoke.php — run from Joomla root
define('_JEXEC', 1);
define('JPATH_BASE', __DIR__ . '/..');
require_once JPATH_BASE . '/includes/defines.php';
require_once JPATH_BASE . '/includes/framework.php';
$app = Joomla\CMS\Factory::getApplication('site');
$db = Joomla\CMS\Factory::getDbo();
$ids = $db->setQuery(
'SELECT id FROM #__k2_items WHERE published=1 AND trash=0 ORDER BY RAND() LIMIT 50'
)->loadColumn();
$failures = 0;
foreach ($ids as $id) {
$item = K2Items::getInstance()->getItem($id);
if (empty($item->extra_fields) || !is_array($item->extra_fields)) {
fwrite(STDERR, "item {$id}: extra_fields empty\n");
$failures++;
}
}
exit($failures > 0 ? 1 : 0);
We ran that smoke test after every step. The first time it failed was after the J4 hop. K2 had bumped its field schema and required a one-off resave of every item to rewrite the blob. The K2 admin has a button for this under Maintenance. On 600 items it ran in 90 seconds. We re-ran the smoke test, got a clean exit code, and moved on.
The Falang routes nobody benchmarked
Falang in J4 and J5 changed how it interacts with the new Joomla router. The biggest visible change: aliases that were previously case-insensitive became case-sensitive on certain server configurations. The site had a Dutch URL that read /nl/Bezoekers/parkeren with a capital B from a 2017 menu edit. After the J4 hop, that URL 404'd and the lowercase version worked.
We did not catch this in the admin. We caught it because the baseline we built at the start flagged 31 URLs that had silently changed status code. Twenty-eight were the case-sensitivity issue. We solved them with a single rewrite rule in the vhost config (Apache only honours RewriteMap in server or vhost context, not in .htaccess):
# In the site's vhost config, NOT in .htaccess
<VirtualHost *:443>
ServerName www.example.be
RewriteEngine On
RewriteMap lowercase int:tolower
RewriteCond %{REQUEST_URI} [A-Z]
RewriteRule ^(.*)$ ${lowercase:$1} [R=301,L]
</VirtualHost>
If you do not control the vhost (shared hosting, managed Joomla platforms), the fallback is a flat redirect list inside .htaccess with one Redirect 301 per affected URL. Ugly, but it works and ships in five minutes.
The other three drifted URLs were Falang language definitions that had been edited by hand in 2019 to point at a microsite that no longer existed. Those got proper 301s in a redirect table. We also found two menu items in the Dutch menu that were assigned to a Falang language definition whose source article had been trashed years ago; the J4 router refused to build the route at all and returned a 500. Fixing those took five minutes once the benchmark made them visible.
The staging dance
We never run a migration like this in place. The flow is:
- Clone production to staging on Friday evening. Lock behind basic auth.
- Run the full migration on staging Saturday and Sunday.
- Re-run the URL benchmark. Diff against baseline. Fix every red row.
- Hand staging to the client Monday morning for content review.
- Cut over Tuesday night during the lowest-traffic hour.
The cutover itself is small. We freeze the production database, take a final mysqldump, restore it onto staging, run the migration scripts one more time end to end (now warm and tested), swap DNS, and watch the access log for an hour. The benchmark gets re-run from the public internet the moment DNS resolves. Anything that has drifted gets a redirect rule before sunrise.
The rollback plan is one line: keep the frozen production DB and codebase tarball on a hot standby host with DNS pointed at the same IP. If anything in the first hour goes wrong that we cannot patch live, we flip DNS back, lose at most an hour of edits (the freeze prevented writes), and reschedule. We have used this escape hatch exactly once across roughly thirty migrations, on a site where a third-party reservation widget loaded a fixed Joomla 3 admin path. Knowing it is there is worth more than the chance of using it.
The migration risk is not in Joomla. It is in K2's silent schema delta and Falang's URL aliases. Benchmark every public URL before you start, and treat the benchmark as the contract.
What we shipped on the tourism site
Eleven working days from kickoff to cutover. 8,412 URLs benchmarked, 31 redirects added, 8 extensions retired, 3 small replacements built in-house. K2 carried over with all seven live custom fields intact. Falang carried all three languages without a single article losing its translated slug. The marketing lead got her admin back without the red banner and PHP 8.2 stopped being a death threat. As a side effect of dropping the dead extensions and switching to bundled jQuery, the home page Largest Contentful Paint dropped from 3.4s to 1.9s on a cold cache, which the team noticed in Search Console before we got round to telling them.
This was a legacy migration we shipped under deadline pressure earlier this year. The thing we ran into that was not in any documentation was the K2 2.10-to-2.11 blob format change biting after the J4 hop, not before it. We solved it with the smoke test above and a one-click resave inside K2's own maintenance panel. If you are running a Joomla site that still shows 3.x in the admin footer, the smallest useful thing you can do today is run the URL benchmark script against your sitemap and write down the result. Once you know what you have, the rest of the playbook follows.
Key takeaway
Joomla 5 itself rarely breaks the migration. K2's silent custom-field schema delta and Falang's URL aliases do. Benchmark every URL first.
FAQ
Can I update directly from Joomla 3.10 to Joomla 5?
No. The supported path is 3.10 to 4.4 to 5.x. You need to land on the latest 4.4 release and confirm the site boots before triggering the 5.x update.
Will K2 survive a Joomla 5 migration?
Yes, if you upgrade K2 to 2.11 or later for the Joomla 4 hop and resave every item once K2 prompts you. The schema delta on item extra_fields is the most common silent failure.
Does Falang still ship a Joomla 3 build?
No. Faboba dropped the Joomla 3 build in 2023. You have to make the J3 to J4 hop with the last compatible Falang version, then install the J4 or J5 build over the top.
What PHP version does Joomla 5 require?
Joomla 5 requires PHP 8.1 as a minimum. PHP 8.2 is recommended. The Joomla 3.10 site you are migrating from is usually on PHP 7.4 or 8.0.
How long does a migration like this take end to end?
For a 2,800-article site with K2 and Falang, plan eleven working days from inventory to cutover, with the heavy work compressed into one weekend on staging.