← Blog

Migration

Joomla 3.10 to WordPress: rescuing a half-done migration

We took over a stalled Joomla 3.10 to WordPress migration for a Dutch trade union of 28,000 members. Here is what we found, what we shipped, and the 16 minutes of cold sweat at cutover.

Jacob Molkenboer· Founder · A Brand New Company· 8 Jun 2026· 9 min
Open leather logbook, brass key on cream card, green ribbon, red wax seal on ivory paper in side light.

The handover meeting lasted 23 minutes. A shared Drive folder, three half-finished Trello cards, and one sentence that set the tone for the next eight weeks: "The member login is a bit flaky but most of it works." The client was a Dutch trade union with 28,000 active members. The previous vendor had pulled the plug on the migration three months in. Joomla 3.10 was already past its end-of-life support window (August 2023). The WordPress staging environment had a half-imported article tree. The membership login was sitting in a "works on Tuesdays" state.

This is what we walked into, what we found in the code, and what we shipped. The structure is useful for any team taking over a stalled CMS migration.

What the inheritance actually looked like

Two repos. The live Joomla 3.10 site, last touched in production six weeks earlier. A WordPress 6.4 staging build with the FG Joomla to WordPress plugin run once, then abandoned. Articles imported, categories flattened into the wrong hierarchy, media library missing about 40% of attachments because the plugin had timed out partway through.

The previous vendor's plan, reconstructed from a single architecture diagram in the Drive folder, was to:

  1. Move content with FG Joomla to WordPress.
  2. Rebuild the member area as a custom WordPress plugin.
  3. "Bridge" the SSO with the union's separate ticketing system on a Laravel backend.

Step 1 had been done badly. Step 2 had been started and was 30% of a plugin with no admin UI. Step 3 had a stub controller and a TODO comment.

The first thing we did was not write code. We spent two days reading the live Joomla site as a user, in three roles: anonymous visitor, paying member, branch-level administrator. That walkthrough exposed eight workflows the vendor's diagram did not mention, including a payment-arrears notice flow tied to direct-debit returns, and a branch-level moderation queue used by 14 regional coordinators. If we had started coding against the diagram we would have shipped a site that broke the union's regional structure on day one.

The undocumented membership component

The custom "membership module" turned out to be a full Joomla component, not a module. Roughly 11,000 lines of PHP spread across /components/com_lidmaatschap/ and /administrator/components/com_lidmaatschap/. Six MySQL tables prefixed jos_lid_. No README, no migrations folder, no comments above the gnarly bits. The original author had left the agency that built it in 2019.

We started by mapping the schema. A useful one-liner if you ever do this:

mysql -u root -p joomla_prod \
  -e "SELECT TABLE_NAME, TABLE_ROWS FROM information_schema.TABLES \
      WHERE TABLE_SCHEMA='joomla_prod' AND TABLE_NAME LIKE 'jos_lid_%';"

That gave us row counts. The interesting one was jos_lid_subscriptions at 41,883 rows for 28,000 members, which meant either historical data was preserved or someone had been double-inserting on renewal. Both turned out to be true. About 4,200 of those rows were duplicates from a 2021 cron that had run twice for six weeks before anybody noticed.

To map foreign keys without a schema diagram we generated one from SHOW CREATE TABLE output and fed it through dbdiagram.io. That bought us a picture in an afternoon.

The component had four entry points worth knowing about: a frontend controller serving the member profile pages, a backend admin view listing members with a 12-column table, a cron job at /cli/lid_renewals.php that ran nightly and posted to the direct-debit provider, and a REST-ish endpoint at ?option=com_lidmaatschap&task=check_membership&format=json used by the Laravel ticketing system to verify a member's status before opening a case.

That last one was the SSO. Sort of.

The SSO that wasn't really SSO

What the union called "SSO" was a session-cookie passing trick. After a member logged into Joomla, a custom plugin set a second cookie called union_uid on the parent domain, signed with HMAC-SHA1 and a secret stored in a PHP constant. The Laravel ticketing app on a subdomain read that cookie, validated the signature, and trusted the user. No OAuth, no SAML, no token refresh. One shared secret in two repos.

The "flaky" behavior the client mentioned came from three causes, in order of how much hair we lost. First, the cookie was being set on www.union.nl instead of .union.nl, so it never reached the ticketing subdomain unless the user typed the URL with the www prefix. Roughly 60% of members did not. Second, the signature used PHP's hash_hmac with default raw_output=false, but the Laravel side was decoding as binary. Half the time the strings matched by coincidence of base64 alphabet collisions. Third, the secret was hard-coded in two places that had drifted apart in 2022 when the Laravel app was last rebuilt. None of this was in the documentation, because there was no documentation. We learned it by following the cookie with Chrome DevTools and reading the Laravel middleware line by line.

Takeaway

When a client says "the SSO is a bit flaky," assume it is not SSO. It is almost always a shared cookie with a secret in a constant. Find the secret first, then plan the rebuild.

The rebuild plan we actually shipped

We rejected the previous vendor's plan of rewriting the membership component as a WordPress plugin. With 11,000 lines of business logic, six tables, an active cron, and a direct-debit integration tied to specific bank file formats, rewriting was three to four months of work and a high risk of breaking the renewals flow. Renewals were the union's revenue. You do not break renewals.

Instead we split the system in two. WordPress handled the public site: content, news, branch pages, member-facing static pages. We rebuilt the imports cleanly using a custom WP-CLI script rather than the FG plugin, because the FG plugin had no way to preserve the branch-to-author mapping we needed.

The membership component moved to a small standalone PHP 8.2 service behind members.union.nl, reusing the original tables, the cron, and the direct-debit integration unchanged. We did rewrite the four entry points as a clean Slim 4 app, but the core business logic stayed put. That decision saved roughly ten weeks and removed the renewals flow from the risk surface entirely.

SSO became real SSO. We stood up a single OAuth 2.0 provider on the members service, with the WordPress site and the Laravel ticketing app both registering as clients. We used league/oauth2-server on the provider side because it is boring, well-maintained, and audited. The WordPress side uses a small custom plugin that hooks into the wp_authenticate action and stores the OAuth user-id in wp_usermeta.

For the migration of credentials we did not try to preserve passwords. Joomla 3 uses bcrypt and so does modern WordPress, but the user identifier and salt boundaries differ enough that a verification shim costs more than it saves. We sent a one-time password-reset link by email to every active member, staggered over four days, with a fallback "reset by phone" line for the older members who do not check email. The union's call centre handled 412 reset calls over that window, which they had budgeted for.

Here is the verify-and-link function we used on the WordPress side once a member completed OAuth login the first time:

add_action('oauth_callback_verified', function (array $token) {
    $remote_uid = (int) $token['sub'];
    $email      = sanitize_email($token['email']);

    $user = get_user_by('email', $email);
    if (!$user) {
        $user_id = wp_insert_user([
            'user_login' => $email,
            'user_email' => $email,
            'user_pass'  => wp_generate_password(32, true, true),
            'role'       => 'member',
        ]);
        $user = get_user_by('id', $user_id);
    }

    update_user_meta($user->ID, 'union_remote_uid', $remote_uid);
    wp_set_auth_cookie($user->ID, true);
}, 10, 1);

Short, boring, easy to audit. That was the whole point.

Cutover night and the one thing that nearly broke

Cutover ran on a Sunday at 02:00 Amsterdam. We had a 90-minute window before the cron at 03:30 fired the direct-debit batch for the upcoming Wednesday. Missing that batch meant 1,400 members would not be charged on time, which meant the union would have to issue manual invoices, which meant we would never be invited back.

The DNS flip went clean. The OAuth handshake worked on the first try. WordPress served the new site in 340ms median. We checked the membership service health endpoint, green. We checked the cron schedule, green. At 03:31 we tailed the cron log.

Nothing.

The cron had not fired. The new PHP 8.2 service was missing the systemd timer we had configured on the staging box and forgotten to add to the production Ansible playbook. We wrote the timer file by hand, enabled it, fired it manually, and watched 1,400 SEPA direct-debit instructions land in the bank's SFTP inbox at 03:47. Sixteen minutes of cold sweat.

The lesson is the one every migration writeup ends with and everyone forgets next time. Treat the cron schedule as a first-class artifact. Put it in the repo, in the playbook, in the runbook, and in the checklist. Diff it against production before cutover. "Make sure cron still works" is not a task. "Diff /etc/cron.d and systemd timers between staging and prod, attach the diff to the cutover ticket" is a task.

What we would do differently

Three things, in order. We would spend three days, not two, walking the live site as a real user before writing a line of code. The branch-moderation workflow we missed cost a week of rework. We would build the membership service first and migrate WordPress on top of it second. We did it in parallel and the two teams kept tripping over schema assumptions. Sequential would have been slower on paper and faster in practice. And we would budget the cron audit as a named ticket with an owner, not as a checklist line item.

The five-minute audit you can do today

When we rebuilt this legacy migration for the union, the hardest part was never the framework swap. It was the undocumented business logic sitting underneath, and the only way through was to read every line of it before we touched anything.

If you are sitting on a stalled migration today, open the live site in an incognito window, log in as a real user, and write down every workflow you touch in the first ten minutes. That list is your real migration scope. Whatever the previous vendor's plan says, your list is the truth.

Key takeaway

When a client says the SSO is a bit flaky, assume it is not SSO. It is almost always a shared cookie with a secret in a constant.

FAQ

How long does a Joomla 3 to WordPress migration usually take?

For a content-only site, two to four weeks. For a site with custom components, member areas, or payment flows, plan three to six months and budget the legacy code audit as its own phase before any rewriting starts.

Can you preserve user passwords from Joomla to WordPress?

Technically yes via a custom check_password filter, but for any non-trivial migration the cleaner path is a one-time password-reset email to every active user. It removes a whole class of edge cases.

Is the FG Joomla to WordPress plugin enough on its own?

For articles, categories, and basic users, often yes. For custom components, member data, branch hierarchies, or media-heavy sites, no. Plan a custom WP-CLI script for anything the plugin cannot reach.

What happens if we leave Joomla 3.10 in production?

Joomla 3 reached end of life in August 2023. No security patches are being released. Any unfixed CVE published since then sits on your server until you migrate or upgrade to a supported major version.

joomlawordpressmigrationlegacy sitesphpcase study

Building something?

Start a project