PHP upgrade guide

How to upgrade PHP 5.6 (or 7.x) to PHP 8.3

PHP 5.6 stopped receiving security patches on 31 December 2018. PHP 7.4 — the last of the 7.x line, and still the most common "we're on a modern version, right?" answer — reached end of life on 28 November 2022. If you are running either, every known vulnerability disclosed since those dates is unpatched in your runtime, your OS vendor has almost certainly dropped its packages, and a growing share of Composer libraries simply refuse to install. This is not a tidiness problem; it is an unpatched-attack-surface problem, and it compounds monthly.

The good news: PHP 8.3 is fast, strict, and genuinely pleasant to write, and the path there is well-trodden. The bad news teams keep learning the hard way: the way you get there matters enormously. Do it wrong and you spend three weeks in a branch that won't boot. Below is the accurate, honest version — what breaks, the code to change, and the tooling to make the machine do most of the work.

Climb the ladder — never jump

The single most important decision is to upgrade one minor version at a time, get green, deploy, and only then move on:

5.6 → 7.0 → 7.1 → 7.2 → 7.3 → 7.4 → 8.0 → 8.1 → 8.2 → 8.3

It is tempting to point your Dockerfile at php:8.3, run the test suite, and fix whatever explodes. That "big-bang" approach fails because the breaking changes stack: a mysql_* call removed in 7.0, an each() removed in 8.0, and a string-to-number comparison that flipped meaning in 8.0 all surface at once, on top of every dependency that won't install. You cannot tell which failure caused which. Climbing the ladder isolates each change set to a single, reviewable, deployable step — and each rung individually is a small diff.

Rule of thumb: each rung of the ladder should be its own pull request that ships to production before you start the next. If a rung can't ship on its own, the migration isn't done — it's paused. Never let an "upgrade branch" live longer than a sprint.

The two rungs that hurt most are 5.6 → 7.0 (the mysql_* extension and other legacy functions are removed) and 7.4 → 8.0 (dozens of warnings became fatal errors and exceptions). Budget accordingly.

What actually gets removed and changed

The mysql_* extension is gone (removed in 7.0)

The entire mysql_* family (mysql_connect, mysql_query, mysql_fetch_assoc …) was deprecated in 5.5 and removed in 7.0. Move to PDO (recommended) or mysqli, and use prepared statements while you're in there — string-concatenated queries are how the old code got breached in the first place.

$link = mysql_connect($host, $user, $pass);
mysql_select_db($db, $link);
$q   = mysql_query("SELECT * FROM users WHERE email = '$email'");
$row = mysql_fetch_assoc($q);
$pdo = new PDO("mysql:host=$host;dbname=$db;charset=utf8mb4", $user, $pass,
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$email]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);

each() and create_function() — removed in 8.0

each() (deprecated 7.2) and create_function() (deprecated 7.2, a code-injection hazard) were both removed in 8.0. Replace loops using each() with foreach, and replace create_function() with a real closure or a 7.4+ arrow function.

$double = create_function('$n', 'return $n * 2;');
$double = fn($n) => $n * 2;

while (list($k, $v) = each($items)) { /* ... */ }
foreach ($items as $k => $v) { /* ... */ }

split() — removed in 7.0

The POSIX split() was removed in 7.0. Use explode() for literal delimiters (faster) or preg_split() for a regular expression.

$parts = split(',', $csvLine);      // literal comma
$parts = explode(',', $csvLine);
$words = split('[\s,]+', $text);    // regex
$words = preg_split('/[\s,]+/', $text);

Superglobals, magic quotes, and curly-brace offsets

The long-deprecated $HTTP_POST_VARS / $HTTP_GET_VARS / $HTTP_SERVER_VARS arrays and magic_quotes_gpc were already removed in 5.4, so if you're on 5.6 you should be clean — but legacy apps that predate 5.6 often still carry the compatibility shims. Replace the old arrays with $_POST / $_GET / $_SERVER, and delete any get_magic_quotes_gpc() stripping logic (the function itself was removed in 8.0). Curly-brace string and array offset access ($str{0}) was deprecated in 7.4 and removed in 8.0 — use square brackets.

$name  = $HTTP_POST_VARS['name'];
$name  = $_POST['name'];
$first = $str{0};
$first = $str[0];

Warnings became errors (the 8.0 wall)

PHP 8.0 is the rung where behaviour, not just syntax, changes. Many conditions that were silent Notices or Warnings now throw Error or TypeError and will halt a request that previously limped along. The big ones: reading an undefined array key is a warning (not silent); calling a method on null throws Error; passing a non-numeric string to a maths operation throws TypeError; and 0 == "foo" is now false (in 7.x it was true, because the string was coerced to 0). Wrong argument counts to internal functions throw ArgumentCountError, and @ no longer suppresses fatal errors. This is why runtime testing on the 8.0 rung is non-negotiable — static tools can't catch all of it.

Adopt the modern language while you're in there

Upgrading only to stand still is a waste. The features added across 7.0–8.3 remove entire categories of bug. Turn on declare(strict_types=1); and lean in:

class Money {
    private $amount;
    private $currency;
    public function __construct($amount, $currency) {
        $this->amount = $amount;
        $this->currency = $currency ? $currency : 'USD';
    }
    public function label() {
        if ($this->currency == 'USD') return '$';
        if ($this->currency == 'EUR') return '€';
        return $this->currency;
    }
}
final class Money {
    public function __construct(
        public readonly int $amount,          // typed + promoted + immutable
        public readonly string $currency = 'USD',
    ) {}
    public function label(): string {
        return match ($this->currency) {      // strict, exhaustive-ish
            'USD' => '$',
            'EUR' => '€',
            default => $this->currency,
        };
    }
}

The toolkit worth reaching for: scalar and return type declarations (7.0), nullable types ?int (7.1), the null-coalescing ?? (7.0) and ??= (7.4) operators, constructor property promotion (8.0), match (8.0, uses strict === and has no fall-through), the nullsafe operator ?-> (8.0), enums (8.1) to replace class constants and magic strings, and readonly properties (8.1) for value objects. Adopt them incrementally — they're not required to run on 8.3, but they're why you did this.

The upgrade, as a repeatable procedure

  1. Inventory and pin a CI matrix. Record your real PHP version, extensions, and framework versions. Add a CI job that runs your suite against every rung you'll climb (7.4, 8.0, 8.1, 8.2, 8.3) so "does it work on the next version" becomes a button, not a guess. If you're unsure whether you're even out of support yet, run your version through the Codsy EOL checker first.
  2. Flag issues with PHPCompatibility. Install squizlabs/php_codesniffer plus the PHPCompatibility standard and scan against a target: phpcs -p . --standard=PHPCompatibility --runtime-set testVersion 8.3. It statically reports every removed/deprecated construct per target version — your to-do list, generated.
  3. Auto-fix with Rector. Point rector/rector at the level set for your next rung (e.g. LevelSetList::UP_TO_PHP_74, then UP_TO_PHP_80, and so on). Rector rewrites a large fraction of the mechanical changes — list/each, ternaries to null-coalescing, property promotion — automatically. Run it one level at a time and review the diff.
  4. Add PHPStan or Psalm, and raise the level. Introduce static analysis at a low level, get green, then increase strictness. It finds the null-method-call and type-mismatch bugs that will become fatal on 8.0 before a user does.
  5. Fix the warnings-that-are-now-errors. On the 8.0 rung, run the app with error_reporting(E_ALL) and exercise real paths (and your test suite). Fix undefined keys, null property reads, and string/number comparisons. Static tools miss the dynamic ones — this step is manual and unavoidable.
  6. Bump Composer and enforce the platform. Update dependencies, then set config.platform.php in composer.json to your target and run composer update; use composer why-not php 8.3 to see exactly which package blocks the jump. Composer's platform check will refuse installs that can't run on the target — treat that as a feature.
  7. Climb one rung behind CI. Merge, deploy, watch, then repeat for the next version. Each production deploy shrinks the risk of the next.

Breaking-changes reference

Feature / constructPHP 5.6PHP 8.3Action
mysql_* functionsDeprecated, worksRemoved (since 7.0)Move to PDO/mysqli + prepared statements
each()WorksRemoved (8.0)Use foreach
create_function()WorksRemoved (8.0)Use closures / arrow fn()
split() / ereg_*WorksRemoved (7.0)explode() or preg_split() / preg_*
$HTTP_POST_VARS etc.Removed (5.4)RemovedUse $_POST / $_GET / $_SERVER
Magic quotesRemoved (5.4)RemovedDelete stripping shims; use prepared statements
Curly-brace offsets $s{0}WorksRemoved (8.0)Use $s[0]
Method call on nullWarning, continuesThrows Error (8.0)Guard with ?-> / null checks
0 == "foo"truefalse (8.0)Use ===; review loose comparisons
Non-numeric string mathsWarningTypeError (8.0)Cast/validate input types

The same discipline — ladder, tools, CI, ship each rung — applies to the frameworks on top. If you're on Laravel, its supported version is tightly coupled to your PHP version, so do them together (see the Laravel upgrade guide). Migrating other stacks? We have companion guides for .NET Framework → .NET 8 and Python 2 → 3.

Frequently asked questions

Is PHP 7.4 end of life?

Yes. PHP 7.4 reached end of life on 28 November 2022 and receives no security patches from php.net. The entire 7.x line and 8.0 are also EOL. As of 2026 the actively supported branches are 8.1 (security only, through the end of 2025), 8.2, 8.3 and 8.4 — so 8.3 is a solid, well-supported target with years of support ahead. Some Linux distributions backport fixes to their own 7.4 packages, but that only covers the interpreter, not your application or its libraries.

Can I jump straight to 8.3?

Your final destination is 8.3, but you should not point production at it in one move. Upgrade one minor version at a time (7.0 → 7.1 → … → 8.3), getting your test suite green and deploying at each rung. Big-bang jumps pile every breaking change on top of every dependency conflict at once, making failures impossible to isolate. The ladder turns one terrifying migration into a series of small, reviewable, individually shippable ones.

Will my Laravel, WordPress or Symfony app work on 8.3?

Modern versions of all three support PHP 8.3, but only if the framework itself is current. Laravel 10 and 11 support 8.1–8.3 (older Laravel does not), Symfony 6.4/7.x supports 8.2+, and current WordPress runs on 8.3 (though many older plugins and themes lag and are the usual blockers). In practice you upgrade the framework and PHP in lockstep, climbing both ladders together and letting Composer's platform check and why-not tell you which package is holding you back.

How long does it take?

Honestly: it depends on codebase size, test coverage, and how much mysql_* and untyped legacy code you carry. A small, well-tested app with a modern framework can be a few days. A large, test-thin monolith on 5.6 with hand-rolled database access is more often several weeks to a few months of engineering time — most of it spent on the 5.6 → 7.0 and 7.4 → 8.0 rungs and on writing the characterisation tests you'll need to trust the result. Rector and PHPStan compress the mechanical work dramatically; the runtime "warnings that became errors" pass and thin test coverage are what actually consume the calendar.

Don't want to do this by hand?

Mira runs this exact migration for you — detect, modernize, add tests, run a security pass — on your own servers, in hours, not months.

Scan your repo free → Check your version's EOL →