Joomla
Joomla 3.10 to Strapi: a base64 K2 trap cost us 10 days
On day three of a Joomla 3.10 retirement, our redirect map went quiet. No 404s, no errors. A K2 plugin was resolving base64 JSON at runtime, and nobody knew.

On a Tuesday afternoon in March, our project manager opened Slack and asked, with the studied calm of someone who already knows the answer: "So, are we live this weekend?" The team lead on a Joomla 3.10 to Strapi migration was staring at a 612-row redirect map that, against all reason, worked in staging and silently broke in production. We were on day three. We would not ship for two more weeks.
The client was a 19-person SaaS vendor in Utrecht. Mature product, 8,000 organic visits a month, a marketing site kept alive by three successive freelancers since 2014. They had hired us to retire the Joomla 3.10 stack before the next round of CVEs caught up with them. Joomla 3 reached end of life in August 2023, and Akeeba had stopped backporting fixes the client could rely on. We picked Strapi as the new CMS and Astro for the marketing site, the same setup we have shipped for half a dozen agencies this year.
The site we inherited
Joomla 3.10 with K2, a content construction kit popular between roughly 2012 and 2018. K2 is still maintained by JoomlaWorks, but most teams who built on it in 2014 have since stopped touching the integration code. Our client's site had K2 categories, K2 items, K2 extra fields, plus four custom plugins written by a freelancer who left in 2017. None of the plugins had READMEs. Two had no comments. One had been edited in production via FTP, which we discovered because the file modification time on the server was three years newer than the file in the client's git repo.
The catalogue: 412 articles, 38 category pages, 22 landing pages, around 600 legacy URL aliases that had accumulated through redesigns. SEO traffic distributed in the long tail, exactly the shape where any URL slip costs you compounding revenue.
The plan that should have worked
Standard playbook. We had run this six or seven times.
- Crawl the live Joomla site and capture every URL that returns 200, plus its canonical and final destination.
- Export K2 content via the JSON API into a normalised shape Strapi can ingest.
- Build the Astro site route-for-route against staging Strapi.
- Generate an Nginx redirect map from the URL inventory.
- Cut DNS over a weekend.
We crawled. We exported. We built. We staged against the live Joomla so the team could spot drift before cut-over. By day three, the staging build was pixel-equivalent on every URL we tested. We had a 612-line redirect map. We were on schedule.
Then we asked our SEO contractor to run a final crawl against staging, with the legacy site simulated offline. Forty-seven URLs returned 404. None of them were in our redirect map. None of them appeared in Joomla's menu manager, in the K2 item table, or in the site's .htaccess.
Where the URLs actually lived
The forty-seven URLs were old campaign landers from 2015 to 2018. They had been redirected to current pages for years. They drove roughly a third of the site's pricing-page traffic. We could see the redirects firing in production. We could not find the source.
We did the obvious things first. Grep the codebase for the URL slugs. Nothing. Grep the database dump. Nothing visible. Open .htaccess, examine every RewriteRule. Nothing matched. Check Joomla's #__redirect_links table. Empty.
The breakthrough came from a junior on the team who pointed at the database and asked, "What is this?" The #__extensions row for a plugin called plg_system_legacyseo had a params field that was 84 KB. Inside, one key looked like this:
{
"canonical_map": "eyJ2MSI6eyJyb3V0ZXMiOlt7ImZyb20iOiIvcHJpY2luZy1xY3AtMjAxNiIsInRvIjoiL3ByaWNpbmciLCJjb2RlIjozMDF9XX19",
"version": "1.2.0",
"last_updated": "2017-11-04T14:22:08Z"
}
The value of canonical_map was a base64-encoded JSON object containing the redirect rules. The plugin decoded it at runtime, on every request, then matched the current URL against the patterns and emitted a 301. There were 247 rules in that blob. Twelve were broken patterns that had simply never matched anything. Forty-seven were the missing URLs.
If you are auditing a Joomla site for migration, dump every plugin's params field and grep it. Legacy plugins routinely store routing rules, feature flags, and API tokens in there, sometimes encoded.
The reason for the base64 wrapper
We rebuilt enough of the freelancer's commit history (from a tarball backup the client found on a NAS) to answer this. The original plugin stored rules as plain JSON. In 2016, the freelancer added support for redirect rules that contained ampersands and bracketed query strings. Joomla's params storage at the time wrapped values in a JSON-in-JSON-in-INI structure, and certain characters triggered htmlentities double-encoding on save inside the admin form. The freelancer's fix was to base64-encode the inner blob before storing it. It worked. He moved on.
Nine years later, three migration projects had failed to surface this map. The reason is that the K2 export tools, the Joomla redirect manager, and every off-the-shelf Joomla audit script we know about (including those from Akeeba) read the redirect tables, not the plugin params. Unless you enumerate every plugin and inspect its config, you will not find it.
The ten-day cost
Day four to six: we chased the wrong hypothesis. We assumed the missing URLs lived in some K2 extra field or a #__menu alias we had not parsed. We rewrote our crawler twice. Forty-eight hours of nothing.
Day seven: we found the plugin. We spent the rest of the day understanding the resolver, because the rules included regex patterns and a precedence order that mattered.
Day eight: we decoded the blob, parsed the rules, ran them through a deduplication pass against the redirects we already had. We wrote a small PHP script that mimicked the resolver and tested every campaign URL against both the legacy plugin and our new Nginx map. We found twenty-three more disagreements we had not caught on day one.
Day nine to twelve: re-run the full crawl, fix the redirect map, get the SEO contractor to sign off, schedule the cut-over.
Day thirteen: ship. The site went live on a Friday at 22:30 CET. Organic traffic the following Monday was within 4% of the prior Monday, which is the only number that matters when you swap a CMS.
The script we wish we had on day one
Here is the helper we now run on every Joomla site before we quote a migration. It dumps the params field of every plugin, attempts to JSON-decode it, recursively walks every string value, and flags anything that smells like base64-encoded JSON or a URL list.
<?php
// audit-joomla-plugin-params.php
// Usage: php audit-joomla-plugin-params.php > report.txt
$db = new PDO('mysql:host=127.0.0.1;dbname=joomla;charset=utf8mb4', 'user', 'pass');
$rows = $db->query("SELECT name, element, params FROM j_extensions WHERE type='plugin'");
foreach ($rows as $r) {
$params = json_decode($r['params'], true);
if (!is_array($params)) continue;
walk($r['element'], $params);
}
function walk($plugin, $node, $path = '') {
foreach ($node as $k => $v) {
$p = $path ? "$path.$k" : $k;
if (is_array($v)) { walk($plugin, $v, $p); continue; }
if (!is_string($v) || strlen($v) < 40) continue;
// Base64 of JSON?
$decoded = @base64_decode($v, true);
if ($decoded !== false && @json_decode($decoded) !== null) {
echo "[$plugin] $p: base64 JSON, " . strlen($decoded) . " bytes\n";
continue;
}
// Raw URL list?
if (preg_match('#https?://|^/[a-z0-9\-/]+$#im', $v)) {
echo "[$plugin] $p: contains URLs (" . strlen($v) . " bytes)\n";
}
}
}
Run it on a fresh database dump, not on the live site. On the Utrecht project this script would have flagged the canonical_map blob in under a second. We now run it as the very first command on any Joomla audit, before we even open the admin panel.
What we changed in our playbook
Three things, none of them dramatic. The kind of process changes you only adopt after you have lost ten days.
First: a Joomla migration intake now includes a dedicated 90-minute "plugin params" audit. We grep, decode, and inventory every config blob in the plugins table, the modules table, and the templates table. We assume any plugin written before 2018 is hiding something.
Second: our crawl-and-diff harness runs against the legacy site with the new redirect map applied via a local proxy, not just against staging. If a legacy URL exists in Google's index and would 404 under the new config, we want to know that before DNS changes.
Third: we generate the Astro _redirects map (or Nginx config, depending on hosting) directly from a single source of truth that includes the plugin-params-derived rules. No more side-channel redirect lists living in someone's spreadsheet.
On any Joomla site over five years old, assume routing rules are hiding outside the redirect manager. Dump every plugin's params and grep it before you quote the project.
The Astro side, briefly
For completeness, here is the shape of the redirects file we generate after the audit. Astro reads this at build time and emits both static-host redirect headers and a small middleware fallback for hosts that need it. The pattern is verbatim from the Astro routing docs.
# Auto-generated from joomla-redirect-inventory.json
# Source: legacy K2 menu (412 rules)
# Source: plg_system_legacyseo.canonical_map (247 rules, decoded)
# Source: .htaccess RewriteRule (53 rules)
# Generated: 2026-03-22T08:14:22Z
/pricing-qcp-2016 /pricing 301
/campaign/saas /product/main 301
/old-blog/:slug /blog/:slug 301
/legacy/help/:topic /docs/:topic 302
One file. One source of truth. Generated, never hand-edited. The day this lives in version control is the day a migration stops being a series of small surprises.
If you are about to do this
Joomla 3 is done. Joomla 4 and 5 are fine if you want to stay on the platform, but the marketing-site teams we work with mostly want out: static or near-static, headless CMS, faster builds, fewer plugins to babysit. The migration is straightforward only when the legacy site is also straightforward. The site you inherit almost never is.
When we built the Strapi and Astro migration for the Utrecht client, the surprise was not in the new stack. It was in the nine-year-old hack one freelancer left in a plugin params field. We have since shipped four more legacy migrations using the audit script above as the first command. None of the four have run over the original quote.
The smallest thing you could do today: open your Joomla database and run SELECT element, LENGTH(params) FROM your_prefix_extensions WHERE type='plugin' ORDER BY LENGTH(params) DESC LIMIT 10. If anything over 5 KB surprises you, decode it. That is where your migration risk lives.
Key takeaway
On any Joomla site older than five years, assume routing rules are hiding outside the redirect manager. Dump every plugin's params and grep it before quoting.
FAQ
Is Joomla 3.10 still secure to run in production?
Joomla 3 reached end of life in August 2023. The Joomla project no longer issues security patches. Any site on 3.x should plan a migration or upgrade soon.
Should I migrate to Joomla 4 or to a headless stack like Strapi and Astro?
If your team wants to keep editing in a familiar admin and the site is content-heavy, Joomla 4 or 5 works well. If you want faster builds and fewer plugins to babysit, headless is usually a better fit.
How long does a typical Joomla marketing-site migration take?
For a 400-article site without surprises, two to four weeks. The variability lives in the legacy plugins, not in the new stack. Audit the plugin params before quoting.
What is K2 and is it still safe to use?
K2 is a content construction kit for Joomla, still maintained but with a small remaining user base. It runs on Joomla 4 with the official update. For new builds we recommend core articles or a headless CMS.
How do I find redirect rules hidden inside a Joomla plugin?
Dump the params column from the plugins table, JSON-decode each row, and walk every string value. Flag any value that base64-decodes into valid JSON or contains URLs. A 30-line PHP script is enough.