A migration strategy for old legacy systems
A leap from PHP 5.6 straight to PHP 8 skips four major versions, each with its own breaking changes, removed functions and altered error handling. This article shows a multi-stage migration strategy with intermediate versions, a compatibility layer and a test safety net, instead of a risky single-step jump.
Table of Contents
- 1. Why PHP 5.6 still exists today and why that is risky
- 2. Why intermediate versions beat the direct leap
- 3. mysql_* functions: the most common blocker
- 4. Changed error handling: from warnings to throwables
- 5. Type juggling and changed comparison rules
- 6. Rector as an automated tool per version step
- 7. Updating dependencies and Composer packages
- 8. Building a test safety net before the leap begins
- 9. Direct leap vs. staged migration compared
- 10. Summary
- 11. FAQ
1. Why PHP 5.6 still exists today and why that is risky
PHP 5.6 reached its official end of life back in late 2018, yet production systems still run on this version today, usually because migration was considered too risky or too expensive for years. The real risk, however, is not staying on PHP 5.6 itself, it is the growing distance: every year without migration increases the number of breaking changes to be skipped over while simultaneously shrinking the number of developers still familiar with the quirks of this old version.
The path from PHP 5.6 directly to PHP 8 means tackling four major version jumps at once: PHP 7.0 with its new error handling system and return type declarations, PHP 7.1 through 7.4 with gradually tightened type rules, and finally PHP 8.0 through 8.4 with union types, attributes and once again changed error handling for internal functions. Each of these versions removed functions, changed default behavior, or tightened implicit type conversions that PHP 5.6 code often used unknowingly.
The core message of this article: a migration from PHP 5.6 to PHP 8 does not succeed as a single step, but as a chain of smaller, each manageable jumps through intermediate versions, each secured by tests and automated refactoring tools. This strategy is at the center of the following sections.
2. Why intermediate versions beat the direct leap
A direct leap from PHP 5.6 to PHP 8 means that every error occurring during migration must be attributed to one of potentially dozens of breaking changes across four major versions. This debugging is extremely time consuming, because the error message rarely points to the actual cause, for example when a silently changed sort behavior from PHP 7 only produces a visible but hard to trace symptom in PHP 8.
The proven approach is to first lift the system to PHP 7.4, the last 7.x version with a long support window, fix all issues that surface there and reach a stable intermediate version before the next jump to PHP 8.x begins. This intermediate stop drastically reduces the number of breaking changes to handle at once, because PHP 7.4 already contains many of the PHP 8 precursors, such as deprecation warnings for features removed later, which become visible in the console before they turn into real errors.
It is important to actually run each intermediate version in production, rather than treating it as a mere pass-through stage in the CI pipeline. Only real production operation on PHP 7.4 uncovers edge cases that synthetic tests miss, such as unusual character encodings from old database entries or rare but real user input.
3. mysql_* functions: the most common blocker
By far the most common blocker in a PHP 5.6 migration are the mysql_* functions, already marked deprecated in PHP 5.5 and completely removed in PHP 7.0. Systems still running on PHP 5.6 almost always still actively use these functions, because removing them is one of the most laborious individual tasks of the entire migration. Switching to mysqli or PDO is unavoidable before a jump to PHP 7.0 is even possible.
A pragmatic intermediate step is a compatibility layer that maps the old mysql_* API onto mysqli, so the rest of the code can initially remain unchanged while the modern extension already works internally. This layer is not a target architecture, it is a tool to enable the first version jump, before callers gradually migrate to direct PDO usage.
<?php
declare(strict_types=1);
// Compatibility shim: maps legacy mysql_* calls onto mysqli
// Transitional only — replace call sites with PDO over time
final class MysqlCompat
{
private static ?\mysqli $connection = null;
public static function connect(string $host, string $user, string $password): void
{
self::$connection = new \mysqli($host, $user, $password);
if (self::$connection->connect_errno) {
throw new \RuntimeException(
'Connection failed: ' . self::$connection->connect_error
);
}
}
public static function query(string $sql): \mysqli_result|bool
{
if (self::$connection === null) {
throw new \RuntimeException('MysqlCompat::connect() was not called');
}
return self::$connection->query($sql);
}
}
// Legacy call site — signature stays familiar, implementation is modern
function mysql_query_compat(string $sql): \mysqli_result|bool
{
return MysqlCompat::query($sql);
}
This transitional solution allows solving the most urgent blocker without immediately touching every single database query in the project. After the successful jump to PHP 7.0, the compatibility layer is gradually replaced by direct, parameterized PDO calls, ideally combined with introducing prepared statements, which also reduce SQL injection risk at the same time.
4. Changed error handling: from warnings to throwables
One of the most fundamental differences between PHP 5.6 and PHP 8 concerns error handling. In PHP 5.6, many fatal errors, such as calling a method on null, produced an uncatchable E_ERROR that terminated the script immediately. Starting with PHP 7, such errors were converted into Error objects that can be caught via catch (\Error $e), a fundamental structural shift that subtly changes code that previously relied on immediate script termination.
For migration this means: every place in the code that implicitly assumed a certain error would end the script, for example to skip subsequent cleanup, must be reviewed. PHP 8 takes this development a step further, because significantly more internal functions now throw a TypeError on invalid argument types instead of issuing a warning and continuing with null, as was common in PHP 5.6.
<?php
// PHP 5.6: calling a method on null was an uncatchable fatal error,
// the script simply stopped, no cleanup ran afterward
$user = find_user($id); // returns null if not found
$user->getName(); // Fatal error: Call to a member function on null
declare(strict_types=1);
// PHP 8: the same mistake throws a catchable Error object
function processUser(?User $user): string
{
try {
return $user->getName();
} catch (\Error $e) {
// Code that relied on immediate script termination
// must now explicitly handle this case
return 'Unknown user: ' . $e->getMessage();
}
}
5. Type juggling and changed comparison rules
A particularly treacherous difference between PHP 5.6 and PHP 8 concerns comparison rules between strings and numbers. In PHP 5.6, a comparison like 0 == "abc" first converted the string to a number, resulting in 0 == 0 and thus true, behavior that regularly led to subtle security vulnerabilities in practice, for example when a password hash was compared to a numeric string. Since PHP 8, the number is instead converted to a string, so 0 == "abc" now evaluates to false, a much more intuitive but behavior changing adjustment.
This change particularly affects code using loose comparisons (==) instead of strict comparisons (===), a very common pattern in PHP 5.6 projects. The pragmatic migration step is to systematically search for loose comparisons with mixed types before the jump to PHP 8, for example using a PHPStan rule or Rector rule, and convert them deliberately to strict comparisons instead of relying on the new behavior without understanding it.
<?php
// PHP 5.6 behavior: "abc" is cast to 0, so 0 == "abc" was true —
// a classic source of authentication bypass bugs
if ($storedHash == $userInput) { // DANGEROUS on PHP 5.6 with mixed types
grantAccess();
}
declare(strict_types=1);
// PHP 8 behavior: 0 is cast to "0", so 0 == "abc" is now false —
// but the safe fix is still an explicit strict comparison
function verifyHash(string $storedHash, string $userInput): bool
{
return hash_equals($storedHash, $userInput); // strict, timing-safe
}
6. Rector as an automated tool per version step
Manually searching the code for every single breaking change is not practical for larger projects. Rector automates a significant part of this work by providing predefined rule sets per target PHP version, which automatically adjust the code, for example replacing deprecated function calls with modern equivalents or making implicit type conversions explicit.
<?php
declare(strict_types=1);
use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\LevelSetList;
// rector.php — apply one version step at a time, never skip levels
return static function (RectorConfig $rectorConfig): void {
$rectorConfig->paths([
__DIR__ . '/src',
]);
// Step 1: PHP 5.6 codebase targeting PHP 7.4 first
$rectorConfig->sets([
LevelSetList::UP_TO_PHP_74,
]);
// Run 'vendor/bin/rector process --dry-run' first to review the diff,
// then 'vendor/bin/rector process' to apply. Repeat for UP_TO_PHP_80,
// UP_TO_PHP_81, and so on — one level set per migration stage.
};
It is essential to apply Rector step by step per target version, not in a single pass directly to PHP 8.4. Every pass should first be reviewed in dry run mode, the diff manually inspected, and only applied after a test round. Rector does not replace human review, but drastically reduces the number of findings to handle manually, especially for mechanical changes such as adjusting function signatures.
7. Updating dependencies and Composer packages
An often underestimated part of the migration is updating third party packages. Many libraries that ran on PHP 5.6 have since gone through several major versions with their own breaking changes, or were abandoned entirely and receive no active support anymore. The Composer command composer outdated --direct provides a first overview, but does not replace checking whether a package is still actively maintained at all.
#!/usr/bin/env bash
# audit-composer-packages.sh — flag direct dependencies with no PHP 8 support
set -euo pipefail
composer outdated --direct --format=json | \
jq -r '.installed[] | select(.latest != .version) | "\(.name): \(.version) -> \(.latest)"'
# Check platform requirement declared by each package
composer show --direct --format=json | \
jq -r '.installed[] | "\(.name): requires \(.requires.php // "no PHP constraint")"'
For abandoned packages without a successor, there are usually three options: fork the package and maintain it yourself, replace the functionality with an actively maintained alternative, or in rare cases reimplement the used functionality yourself if it is small enough. This decision should be made early in the migration process, because it often causes more effort than adapting your own code to new PHP versions.
8. Building a test safety net before the leap begins
The riskiest situation in a PHP 5.6 migration is a project with no automated tests at all, which is the rule rather than the exception for systems of this age. Before the first version jump even begins, a minimum level of coverage should exist, typically in the form of end to end tests that verify the most important business flows through the interface, even if no granular unit tests exist.
These tests do not need to be elegant or complete, they only need to cover the critical paths: login, order process, payment handling, depending on the application. Such a test safety net, even if it only covers ten or twenty scenarios, turns every version jump from a matter of trust into a verifiable fact: either the tests still pass after the jump to PHP 7.4, or they show concretely where a problem lies, long before customers notice it in production.
9. Direct leap vs. staged migration compared
The following table compares the risky direct leap from PHP 5.6 to PHP 8 with the recommended staged migration through intermediate versions.
| Criterion | Direct leap PHP 5.6 → 8 | Staged migration via 7.4 |
|---|---|---|
| Error diagnosis | Four versions simultaneously as possible cause | One manageable version jump per stage |
| Production risk | All-or-nothing deployment | Each stage individually validated in production |
| Rector usage | One giant, barely reviewable diff | Small, reviewable diffs per stage |
| Total duration | Appears shorter, often with rework | Planned longer, fewer surprises |
The seemingly shorter total duration of a direct leap usually loses its appeal quickly once the first unclear production errors appear, whose cause can span four major versions. The staged migration requires more individual deployments, but delivers a verifiable, stable intermediate version at every phase.
Mironsoft
PHP legacy modernization and Magento development
Still running PHP 5.6 and ready for the leap?
We plan and guide the multi-stage migration of your legacy system to PHP 8, with Rector automation, mysql_ removal and a solid test safety net for every version stage.
Migration Plan
Define intermediate versions and the order of version jumps
Automation
Configure and apply Rector rule sets per target version
Test Safety Net
Build end to end tests for critical business flows
10. Summary
Migrating from PHP 5.6 to PHP 8 differs fundamentally from an ordinary version upgrade, because it skips four major versions each with their own breaking changes: removed mysql_* functions, error handling changed from warnings to throwables, tightened comparison rules between strings and numbers, and significantly stricter type checks in internal functions. A direct leap makes error diagnosis practically impossible, because every error that occurs must be attributed to one of potentially dozens of causes across four versions.
The defensible strategy runs through PHP 7.4 as an intermediate stop, with Rector as an automated tool per version stage, a compatibility layer for mysql_* calls during the first phase, and a minimum of end to end tests before the first jump even begins. Following this order turns a months-long risk project into a sequence of manageable, individually validatable migration steps.
Migrating from PHP 5.6 to PHP 8 — Key Takeaways
Intermediate Version
PHP 7.4 as an intermediate stop drastically reduces the number of breaking changes to handle at once.
mysql_* Removal
A compatibility layer onto mysqli or PDO is a prerequisite for the first jump to PHP 7.0.
Automation
Apply Rector rule sets per target version, never jump directly to the highest target version.
Safety Net
Build end to end tests for critical business flows before the first version jump.