← Blog

Joomla

Joomla 3 to 5 migration: six PHP traps that cost weeks

A Joomla 3 to 5 upgrade is also a PHP 7 to 8 jump. Here are the six version traps we hit on every legacy migration, with code, fixes, and the audit that costs nothing.

Jacob Molkenboer· Founder · A Brand New Company· 28 Apr 2024· 7 min
Leather logbook with brass key, green ribbon, and cracked red wax seal on ivory paper by a window.

It's a Tuesday in November. Your agency sold a "Joomla 3 to 5 refresh" as a weekend job. The client expected to be back online by Monday with a faster admin and a tidier dashboard. You ran the upgrade preflight, saw a green checklist, and felt good about it. By Friday the staging site throws a 500 on every article view, the login form silently rejects valid credentials, and your error log carries 4,200 deprecation warnings from a CCK extension last touched in 2017.

This is the gap between "Joomla migration" and "PHP 7 to 8 migration." They are the same job, but only one of them is on the quote.

Joomla 3 sites usually run on PHP 7.x or, more often than we'd like, PHP 5.6. Joomla 5 requires PHP 8.1 or higher. That single jump touches every line of third-party code on the site. The six traps below are the ones we hit, ranked by how much time they steal.

Trap one: the string-to-number comparison flip

This is the one that breaks logins without crashing anything. In PHP 7, the expression "abc" == 0 evaluated to true, because PHP silently cast the string to 0 before comparing. In PHP 8, the same expression evaluates to false. String-to-number comparison semantics changed in PHP 8.0 and the shift rippled into thousands of plugins.

You'll see it in token checks, role checks, and any spot where someone wrote loose equality against a string returned from the database.

// In an old Joomla 3 user-role helper
if ($user->access_level == 0) {
    // PHP 7: matched "public", "guest", any string
    // PHP 8: only matches integer 0 or string "0"
    return $this->deny();
}

On PHP 7 the function used to wave logged-in users through, because their access level string compared loosely as 0. On PHP 8 it now denies them. The login form looks like it accepts the password, then bounces straight back. No error, no log entry, no clue.

Fix it by grepping for == 0, == "", and == null across every third-party folder under components/, modules/, and plugins/. Cast explicitly, or switch to ===.

Trap two: removed functions that old extensions hard-code

PHP 8.0 removed each(), create_function(), and the entire mcrypt_* family. The first two were already deprecated in 7.2, but plenty of Joomla 3 extensions never got the memo. mcrypt tended to live inside encryption helpers in component installers and license-check stubs you can't even see from the admin.

// Found in a 2016-era custom auth plugin
$cipher = mcrypt_encrypt(MCRYPT_RIJNDAEL_256, $key, $payload, MCRYPT_MODE_ECB);
$check  = create_function('$a,$b', 'return $a === $b;');
while (list($k, $v) = each($map)) { /* ... */ }

On PHP 8 the parser does not get past line one. Fatal error, white screen. The fix is mechanical, but it has to happen for every extension, not just the ones you care about. each() becomes a foreach. create_function becomes a closure. mcrypt becomes openssl_encrypt with an explicit IV, and you re-key every stored record, which is its own evening of work.

Trap three: dynamic properties trigger a deprecation flood

PHP 8.2 deprecated writing to undeclared object properties. Joomla 3 extensions did this in nearly every model:

// Component model
$item = new stdClass();
$item->id      = $row->id;
$item->custom1 = $row->custom1;   // fine, stdClass is allowlisted

$model = JModelLegacy::getInstance('Article', 'ContentModel');
$model->customFlag = true;        // DEPRECATED in 8.2, fatal in a future version

stdClass and classes marked #[AllowDynamicProperties] are fine. Everything else throws a deprecation. The Joomla core was patched. The forty extensions on your client's site were not. Logs explode. Page load drops as the deprecation handler runs on every request. The fix is either declaring the properties on each class or adding the attribute, which means touching every extension.

Trap four: PHP 4 style constructors

Joomla 1.5 era code used class-named constructors:

class MyComponentHelper {
    function MyComponentHelper() {
        // this was a constructor in PHP 4
    }
}

PHP 7 deprecated this. PHP 8 removed it. The method now runs as a normal method, which means the object never initialises and you get null property reads ten frames deep. Symptom: an extension loads, the page renders blank, the log says "call to a member function on null" inside code that hasn't been edited since 2014.

Find them with one ripgrep pass:

rg -nU 'class\s+(\w+)[^{]*\{[^}]*function\s+\1\s*\(' \
  components/ modules/ plugins/ libraries/

Rename them all to __construct. The fix is five minutes once you've found them. Finding them is the work.

Trap five: curly brace string access

Old extensions reached into strings with curly braces:

// Slug generator from an SEF extension
for ($i = 0; $i < strlen($title); $i++) {
    $char = $title{$i};
    // ...
}

Removed in PHP 7.4, hard error in 8.0. The site won't load. Replace with square brackets. Worth grepping every third-party library, not just the ones you suspect, because this pattern shows up in routers, language helpers, and image-resize utilities.

Trap six: the PHP 8.4 implicit-nullable trap waiting for late projects

You planned the migration for two weeks. It became four. In the meantime your hosting provider rolled the staging server from PHP 8.2 to 8.4. Now you see this in every other extension log:

// Deprecated in PHP 8.4
function load(string $key = null) { /* ... */ }

// Correct in 8.4 and up
function load(?string $key = null) { /* ... */ }

The change is documented in the implicitly nullable types RFC. It's a deprecation in 8.4 and a fatal in 9.0. It does not break the site today, but it fills logs during a launch window when you need clean output to read.

Warning

Pin your staging PHP version to the production target the day you start the audit. A mid-migration PHP point bump turns a known set of fixes into a moving target.

The audit that costs you nothing

Before you quote a Joomla 3 to 5 job, run PHPStan at level 0 against the entire components/, modules/, plugins/, and libraries/ tree. Then run Rector with the PHP_80, PHP_82, and PHP_84 sets in dry-run mode. The numbers it prints are your real scope.

Rough calibration from our last twelve Joomla migrations:

  • Under 50 issues across all third-party code: this is the weekend job your client expected.
  • 50 to 500 issues: budget one focused week.
  • 500 or more issues, or any abandoned commercial extension: budget three weeks and have the replace-or-fork conversation before you start.

The number you do not want to discover after the deposit is paid is the count of abandoned extensions. An extension whose developer stopped shipping in 2019 is not getting a Joomla 5 release. You either find a maintained replacement, fork it, or rebuild that feature on core Joomla 5. All three are real work that does not appear on the original quote.

The conversation that saves the project

When we ran a legacy migration for a Dutch publishing client on Joomla 3.10 last quarter, the field-guide audit above turned a quoted "ten days" into an honest twenty-two day estimate, with two of those days set aside for replacing a discontinued ACL extension. The client agreed to the longer number once they saw the PHPStan output. That conversation is much easier to have on Monday morning than on launch night.

If you do one thing today: run vendor/bin/rector process --dry-run --config rector-php82.php against your site's third-party extension folders. The summary at the bottom is your migration estimate, in hours.

Key takeaway

A Joomla 3 to 5 upgrade is a PHP 7 to 8 migration in disguise. Audit third-party code with PHPStan and Rector before you quote, not after.

FAQ

Can I jump straight from Joomla 3 to Joomla 5?

No. You migrate Joomla 3 to 4, then 4 to 5. Each hop has its own database changes and extension compatibility pass, and skipping the middle step is not supported.

What PHP version should I target on the new server?

PHP 8.2 is the safest production target for Joomla 5 today. 8.1 is the floor. 8.4 still trips third-party extensions on implicit-nullable parameters.

How do I scope an extension audit before I quote?

Run PHPStan at level 0 and Rector with the PHP_80, PHP_82, and PHP_84 sets in dry-run mode against components, modules, plugins, and libraries. The issue count is your scope in hours.

What if a commercial extension has been abandoned?

Find a maintained replacement, fork the original and patch it forward, or rebuild the feature on core Joomla 5. All three belong on the quote before work starts.

joomlamigrationphplegacy sitessecurityarchitecture

Building something?

Start a project