← Blog

Joomla

Joomla 1.5 to Laravel 12: a five-week dual-write playbook

A 19-year-old Joomla 1.5 portal, 6,200 live SSO sessions, an iDEAL renewal flow due next quarter. Here is the five-week dual-write cutover that kept all three alive.

Jacob Molkenboer· Founder · A Brand New Company· 20 May 2025· 10 min
Open green leather logbook, brass key on cream card, rubber stamp with chartreuse ribbon, red wax fragment on ivory paper.

The portal we walked into

Monday morning, mid-January. A 44-person trade association in Hilversum has a board meeting in nine days. The board wants a clean answer about the member portal. The portal is Joomla 1.5 on PHP 5.4, behind Apache 2.2 on a Debian 7 box that has not had a security update since 2019. It is also where 6,200 active members log in to download the collective-bargaining agreements, vote on policy, and renew their membership via iDEAL.

Joomla 1.5 went end of life in September 2012. PHP 5.4 followed in September 2015. The custom com_members component on top of that carries fourteen years of business rules: pro-rated renewal pricing, deputy-member nesting, the special invoice trail for the three honorary members. None of it is documented outside the code.

The board wanted the portal modernised. The members wanted it not to break. We had five weeks.

Why a lift-and-shift to Joomla 5 was off the table

The obvious move is Joomla 1.5 to Joomla 5. We costed it and walked away. Three reasons.

First, the migrator. There is no first-party path from 1.5 to 5. You go 1.5 to 2.5 to 3 to 4 to 5, each hop a half-broken extension graveyard. Most of the third-party components from 2010 are dead. The ones that survived now want yearly subscriptions and a polite chat about a maintenance retainer.

Second, the back office. The treasurer of a trade association is not a Joomla admin. She is an accountant who wants a screen that looks like Exact. Filament gives her that screen in a week. The Joomla admin in 5 is still the Joomla admin.

Third, the data shape. Renewal payloads from Mollie come back as JSON with nested refund objects and chargeback events. Postgres jsonb indexes those. MySQL 5.5 does not.

So the plan was a rebuild. Laravel 12, Filament 4, Postgres 16, Mollie for iDEAL, Cloudflare in front. The old Joomla portal stayed live next to it for five weeks while we moved traffic piece by piece.

The shadow database

The first week was nothing a member could see. We stood up Postgres next to live MySQL and built what we called the shadow.

Every write the old portal made to MySQL had to land in Postgres within a second. We tried two paths. A read replica with pg_chameleon doing CDC against the binlog worked but lagged under spike load. We ended up with a smaller shim: a Laravel HTTP endpoint, called from com_members via a thin PHP class we wedged into the existing component.

// /joomla-root/components/com_members/helpers/shadow.php
class MembersShadow {
    public static function mirror($table, $row, $op = 'upsert') {
        $payload = json_encode(['t' => $table, 'r' => $row, 'op' => $op]);
        $ch = curl_init('https://shadow.portaal.internal/v1/mirror');
        curl_setopt_array($ch, [
            CURLOPT_POST           => 1,
            CURLOPT_POSTFIELDS     => $payload,
            CURLOPT_HTTPHEADER     => [
                'Content-Type: application/json',
                'X-Shadow-Token: ' . getenv('SHADOW_TOKEN'),
            ],
            CURLOPT_TIMEOUT_MS     => 250,
            CURLOPT_RETURNTRANSFER => 1,
        ]);
        curl_exec($ch);
        curl_close($ch);
        // Fire and forget. Drift is backfilled hourly.
    }
}

250 ms timeout. Fire and forget. A nightly job diffed MySQL against Postgres on the four tables that mattered (members, renewals, committee_seats, invoices) and replayed the diff. We accepted that for short windows the two stores would drift. We did not accept silent drift, so the diff job posted to Slack the moment any row count crossed 0.1% delta.

The reconciliation job ran at 03:00 every night. It compared row counts and a hashed sample of payload columns on each side, and surfaced three classes of drift: missing rows in Postgres (the mirror call dropped on the network), missing rows in MySQL (impossible by definition, which is exactly why we tested for it), and value mismatches on the same primary key. The third class flagged twice in five weeks. Both were renewal records the legacy code mutated twice inside one request without committing between calls, so only the last write reached the shadow. We patched the mirror to flush at request shutdown rather than per call, and the diff went quiet.

Keeping 6,200 SSO sessions alive

The portal also fronted three internal tools: a CBA archive, a committee voting tool, and a payroll-benchmark dashboard. All three trusted a Joomla session cookie. If we cut the cookie, 6,200 people would have to log in again across the lunch hour they all read their email. That was the trigger for a hostile board meeting.

So Laravel had to read the same session Joomla wrote, for five weeks. Two parts.

First, the cookie name and the secret. Joomla derives its session key per host. We rebuilt that derivation in a Laravel middleware and read jos_session over a read-only MySQL connection.

Second, the bridge. New logins coming through Laravel wrote to both the Joomla session table and to Laravel's own session store. Old logins kept writing only to Joomla. Both apps read from both stores via a small guard:

// app/Auth/JoomlaBridgeGuard.php
public function user()
{
    if ($this->laravelSession()->has('member_id')) {
        return $this->resolve($this->laravelSession()->get('member_id'));
    }

    $joomlaCookie = request()->cookie($this->joomlaCookieName());
    if (! $joomlaCookie) {
        return null;
    }

    $row = DB::connection('joomla_mysql')
        ->table('jos_session')
        ->where('session_id', $joomlaCookie)
        ->where('time', '>', now()->subHours(4)->timestamp)
        ->first();

    return $row ? $this->resolve($row->userid) : null;
}

When a member hit a Laravel page during the cutover window, the guard saw their Joomla cookie, found the row, hydrated a Laravel user, and dropped a fresh Laravel cookie alongside. At no point were they logged out. We measured: 27 forced re-logins across the full five weeks, all from members whose browsers refused the new cookie because they had third-party cookies blocked on a same-origin context. Two phone calls cleared those.

We staged the guard against a copy of production sessions for ten days before pointing real users at it. The harness pulled a daily snapshot of jos_session, replayed each cookie through the Laravel guard, and asserted that the resolved Laravel user matched the Joomla-side user id. We caught one bug there: an older Joomla version padded the session id with trailing whitespace that the Laravel cookie parser stripped, so a small share of cookies failed to resolve until we matched the trim behaviour on the read side. Without the harness that bug would have shown up on day one of the canary, in production, on the lunch-hour spike.

Warning

If your old portal sets HttpOnly on the session cookie but not SameSite, the new app reading the same cookie will work in testing and then a browser update mid-cutover will start dropping it. Set SameSite=Lax on the legacy session a week before you start the migration, not during. We learned this on day three of week two.

The iDEAL renewal flow during the cutover

Membership renewals run on a Q1 spike. Roughly 70% of the 6,200 members renew between mid-January and end of March. The trade association uses iDEAL because every Dutch business expects it. The old portal posted to a legacy XML endpoint. The new one posts to Mollie's REST API. We had to do the cutover during the spike.

The trick was to make the renewal initiator pick the gateway by a single feature flag, and to let webhooks from both gateways land safely no matter which side currently owned the truth.

From week two onward, every new renewal session opened against Mollie. In-flight legacy transactions (paid but not yet webhook-confirmed) kept resolving against the old portal, which wrote both to MySQL and to Postgres via the shadow. We never had two systems trying to mark the same renewal as paid because the renewal record carried a gateway column from week one. The webhook handler routed by that column.

// routes/webhooks.php
Route::post('/webhooks/payments/{gateway}', function (string $gateway, Request $r) {
    abort_unless(in_array($gateway, ['mollie', 'sisow']), 404);

    $renewalId = match ($gateway) {
        'mollie' => Mollie::resolveRenewalId($r->input('id')),
        'sisow'  => Sisow::resolveRenewalId($r->input('trxid')),
    };

    $renewal = Renewal::lockForUpdate()->find($renewalId);
    abort_if($renewal->gateway !== $gateway, 409, 'gateway mismatch');

    dispatch(new ConfirmRenewal($renewal, $gateway));
    return response()->noContent();
});

The 409 on gateway mismatch turned out to be the single most useful line. Three members opened a Mollie session, abandoned it, then went back to the old portal via a stale tab and started a legacy session. The 409 caught it cleanly. We invoiced the one that paid, refunded the other.

Observability during the cutover

Two dashboards stayed up on a TV next to my desk for five weeks. The first showed shadow drift: row-count delta per table per minute, with a red line at 0.1%. The second showed the gateway split: number of renewal sessions opened against each gateway and number of webhooks resolved, both as a 15-minute trailing average. Watching live counters beats reading post-hoc logs. Most of the small wobbles we caught in week three (Cloudflare retries doubling a webhook, a Mollie status update arriving out of order) showed on those two screens before any alert fired.

Sentry carried the rest. We tagged every exception with the cutover stage (1 through 5) and the surface (back office, member front, webhook) so triage stayed sharp. The single most expensive bug we shipped was a missing index on members.email_lower that turned a 2 ms query into a 600 ms query the moment the canary hit 10%. Sentry's slow-query report surfaced it in twelve minutes. The fix was one CREATE INDEX CONCURRENTLY on Postgres and a redeploy.

The week-by-week schedule

The five weeks ran in a strict order. Each week had one read or write surface that moved. Nothing moved twice in the same week.

Week 1: shadow up, no user-visible change

Postgres alongside MySQL. The mirror shim posting on every write. Nightly diff job green for five consecutive nights before we touched anything else. Filament back office in staging only.

Week 2: back office cuts over

The treasurer, the secretary, and two board admins logged into Filament instead of the Joomla back end. The member-facing portal still 100% Joomla. New iDEAL renewals routed to Mollie from this point.

Week 3: 10% canary

Cloudflare Workers split traffic by a stable hash of the member ID. 10% of members served by Laravel for read-only pages (CBA archive, profile, renewal history). All writes still went via the bridge into the shadow. We watched Filament audit logs for the back office and Sentry on the front for everything else.

Week 4: 100% read, Joomla owns writes

Every member now read from Laravel. Joomla still owned writes for one specific reason: the committee voting tool had a websocket on the old box that was scheduled to be rebuilt last. We left it.

Week 5: writes flip, Joomla goes read-only

The voting tool moved on Tuesday. Joomla went read-only on Wednesday night at 22:00 with a 24-hour notice on the dashboard. By Friday the box was off and the DNS pointed only at the new stack.

The cut-back plan we never used

For the first 14 days after the Friday cutover, a reverse-CDC job streamed Postgres writes back into MySQL. If something had gone visibly wrong, we could have flipped DNS back to the Joomla box and lost no data. We never used it. We left it running the full two weeks anyway, because the day we tore it down was always going to be the day a board member discovered something we missed.

The awkward part of the reverse path was the schema mismatch. Postgres carried richer types than MySQL would accept: jsonb on renewal payloads, timestamptz on every audit column, a generated email_lower column on members. The reverse CDC accepted a lossy view on those columns, flattening jsonb to longtext, dropping the timezone, and skipping the generated column entirely. We listed each lossy column in the runbook so that, if we had ever needed to roll back, nobody would treat the MySQL copy as authoritative on those fields. Spelling out what the rollback could and could not preserve made the keep-it-warm-for-two-weeks decision feel responsible rather than paranoid.

Nothing turned up. We tore it down on day 15.

The playbook, stripped down

Stand up the new database next to the old one. Mirror writes from day one. Bridge sessions on a guard, not on a redirect. Route webhooks by a column the renewal already carries. Move surfaces one at a time, never two in the same week. Keep the reverse path warm for two weeks past the cutover and resist the urge to celebrate before then.

When we built the member portal rebuild for the trade association, the piece we underestimated was the cookie behaviour mid-cutover. We solved it by setting SameSite=Lax on the legacy Joomla session a week before traffic started moving, which is the kind of detail a legacy migration only teaches you the hard way. The dual-write architecture itself was the boring part. Boring was the point.

The smallest thing you can do today, if you are staring at a Joomla 1.5 portal or any equivalent ancient stack: run mysqldump --no-data against it and read the foreign keys out loud. Half the business logic is in the constraints, and you will not see it any other way until you have it on one page.

Key takeaway

Mirror writes from day one, bridge sessions on a guard instead of a redirect, and move one surface per week. The boring playbook ships.

FAQ

Why not just upgrade Joomla 1.5 to Joomla 5 in place?

There is no direct path. You go 1.5 to 2.5 to 3 to 4 to 5, with mostly dead third-party components at each step. Rebuilding on a modern stack was cheaper than fighting four sequential upgrades.

How did you keep 6,200 SSO sessions live across the cutover?

A Laravel guard read the old Joomla session table over a read-only MySQL connection for the full five weeks. Members carried both cookies until the new one took over. 27 forced re-logins total.

Why Postgres instead of staying on MySQL?

The renewal payloads from Mollie are nested JSON with refund and chargeback objects. Postgres jsonb indexes them. MySQL 5.5 cannot, and the back office reports needed those indexes.

Did you have a rollback if the cutover went wrong?

Yes. For 14 days after cutover a reverse-CDC job streamed Postgres writes back into MySQL. We never used it but kept it running anyway. It was torn down on day 15.

How did you avoid double-charging members during the payment-gateway switch?

Every renewal record carried a gateway column from week one. The webhook handler routed by that column and returned 409 on mismatch, so a stale tab on the old gateway could not collide with a new session.

joomlaphpmigrationlegacy sitesarchitecturemysql

Building something?

Start a project