Article
PHP 5 to Modern PHP: Complete Incremental Migration Guide
A deep, production-minded path from PHP 5.x codebases to supported PHP 8.x: removed extensions, charset, PDO, namespaces, and testable cutovers.
PHP 5 applications still appear inside VPN-only servers, reseller panels, and “do not touch” billing modules. Security support for PHP 5 ended years ago; hiring and package ecosystems assume PHP 8. This guide treats the jump as a controlled program—not a weekend rewrite—anchored to official migration notes from PHP 7.0 through 8.5 and the supported versions table.
What “modern” means in 2026
As of 2026-08-09, supported branches are PHP 8.2 (security until 2026-12-31), 8.3 (security until 2027-12-31), 8.4 (active until 2026-12-31), and 8.5 (active until 2027-12-31). Landing on an EOL 8.0/8.1 image is not modernization. Target a supported minor your dependencies can declare in composer.json. Intermediate CI on PHP 7.4 or 8.0 is a bridge, not a destination.
Phase 0 — inventory before opinions
Catalog every execution surface: public front controllers, admin panels, cron, queue workers, SOAP/XML-RPC endpoints, and one-off CLI importers. For each, record the SAPI (mod_php, php-fpm, CLI), the effective php.ini, and required extensions. Capture production truth before editing code.
php -v
php -m
composer show -t 2>/dev/null || true
find . -name '*.php' -print0 | xargs -0 grep -nE 'mysql_|mcrypt_|ereg|split\(|each\(|create_function|mysql_real_escape|get_magic_quotes|register_globals'
Feed suspicious snippets into the version compatibility checker and deprecated checker. Pair inventory with legacy PHP audit and inherited application playbook when you did not write the original system.
The fatal blockers: ext/mysql
The entire mysql_* extension was removed in PHP 7.0. Calls that once “worked” become Call to undefined function mysql_connect(). There is no polyfill that restores the extension as a supported path—migrate to mysqli or preferably PDO with prepared statements. See mysql_connect removed and detail pages for mysql_query, mysql_connect, and mysql_real_escape_string.
# LEGACY (PHP 5 era — removed in 7.0)
$conn = mysql_connect('localhost', $user, $pass);
mysql_select_db('app', $conn);
$id = mysql_real_escape_string($_GET['id'], $conn);
$res = mysql_query("SELECT * FROM users WHERE id=$id", $conn);
$row = mysql_fetch_assoc($res);
# MODERN — PDO + prepared statements + utf8mb4
$pdo = new PDO(
'mysql:host=localhost;dbname=app;charset=utf8mb4',
$user,
$pass,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([(int)$_GET['id']]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
Transitional strategy that works in large trees: wrap data access behind a thin repository that returns arrays/objects your controllers already expect. Replace hot paths first (login, payments, admin search). Avoid a global search-and-replace of mysql_query that still concatenates SQL—prepared statements are the security win, not merely swapping APIs. Broader database guidance: database modernization and PHP security modernization.
mcrypt is gone — plan cryptography, not a polyfill
ext/mcrypt was deprecated in PHP 7.1 (documented as abandonware complexity) and removed from the PHP core in PHP 7.2. Replacing mcrypt_encrypt is not a search-and-replace; ciphertext may depend on padding modes that OpenSSL handles differently. Prefer authenticated encryption (libsodium / sodium_* or carefully reviewed OpenSSL AEAD). Detail: mcrypt_encrypt migration.
# LEGACY
$ct = mcrypt_encrypt(MCRYPT_RIJNDAEL_128, $key, $plain, MCRYPT_MODE_CBC, $iv);
# TRANSITIONAL note
# Decrypt historical ciphertext with a one-off tool on an isolated host,
# then re-encrypt with modern AEAD for new writes.
# MODERN (libsodium secretbox)
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ct = sodium_crypto_secretbox($plain, $nonce, $key);
Operational tip: keep a read-only decryptor for old ciphertext until every stored blob has been rotated. Do not ship mcrypt PECL into production as a permanent modernization strategy unless you have an explicit, time-boxed containment plan.
ereg / split — move to PCRE
POSIX regex functions (ereg, eregi, split, and related) were removed in PHP 7.0. Use preg_*. Watch delimiter choice, case-sensitivity (eregi → preg_match with i), and the fact that split() is not the same as explode() when the separator is a regex. Guides: ereg, split.
# LEGACY
if (ereg('^[0-9]+$', $s)) { /* ... */ }
$parts = split(':', $line);
# MODERN
if (preg_match('/^[0-9]+$/', $s)) { /* ... */ }
$parts = preg_split('/:/', $line);
# If the separator is a literal string, explode() is clearer:
$parts = explode(':', $line);
each() and create_function()
Both were deprecated in PHP 7.2 and removed in PHP 8.0. each() loops become foreach; create_function() becomes closures (or named functions when the dynamic body was only a string for historical reasons). Error pages: each removed, create_function removed.
# LEGACY
while (list($k, $v) = each($map)) {
process($k, $v);
}
$cb = create_function('$a,$b', 'return $a+$b;');
# MODERN
foreach ($map as $k => $v) {
process($k, $v);
}
$cb = static fn(int $a, int $b): int => $a + $b;
PHP 4–style constructors and class landmines
Methods named after the class were treated as constructors in PHP 5. PHP 7 deprecated that behavior; PHP 8.0 no longer treats them as constructors—only __construct() counts. Rename carefully when a class also had an intentional same-named method used as a normal method. Static calls to non-static methods were removed in PHP 8.0. Inheritance signature mismatches that once warned can fatal under modern engines.
# LEGACY
class Widget {
function Widget($name) { $this->name = $name; }
}
# MODERN
class Widget {
public function __construct(private string $name) {}
}
Magic quotes and register_globals — history, carefully
These features are gone. Treat remaining code paths as archaeology, not configuration you can restore.
- Magic Quotes (
magic_quotes_gpcand friends) were removed in PHP 5.4. Code that calledstripslashes()“just in case,” or assumed automatic escaping of$_GET/$_POST/$_COOKIE, is residue. If you still seeget_magic_quotes_gpc(), delete the branch—the function is long gone. Escape at the boundary that needs it (SQL via parameters, HTML via context-aware escaping). - register_globals was removed in PHP 5.4. It injected request variables into the global symbol table—an infamous source of security bugs. Modern PHP will not recreate it. Any code that assumes
$usernameappears magically from a query string must be rewritten to read$_GET/$_POSTexplicitly (and then validate).
Do not invent “compatibility layers” that reintroduce these behaviors. They paper over bugs you need to see during migration.
Short open tags
Short open tags (<? instead of <?php) depend on short_open_tag. The short echo form <?= is always available in modern PHP, but bare short opens remain a portability hazard across hosts and containers. Prefer full <?php open tags in application code and templates you control. Scan views that mix HTML and PHP for short tags before moving images between environments.
# LEGACY (fragile across hosts)
# <? echo $title; ?>
# MODERN
# <?php echo htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); ?>
# or short echo (always available on modern PHP):
# <?= htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') ?>
String offsets and curly-brace syntax
PHP 7.4 deprecated curly-brace offset access; PHP 8.0 removed it. Code using $str{0} or $arr{"key"} must switch to square brackets. Additionally, assigning multiple bytes to a string offset warns under PHP 8. Prefer substring helpers (substr / mb_substr) when manipulating UTF-8 text rather than byte-index tricks from PHP 5 tutorials.
# LEGACY (removed in PHP 8.0)
$ch = $name{0};
$map{'id'} = 12;
# MODERN
$ch = $name[0];
$map['id'] = 12;
count(), type errors, and undefined constants
Several “soft” PHP 5 habits become hard failures later:
count()on non-countables — passingnullor a non-countable tocount()escalated over time (warnings / TypeError depending on version). Guard withis_countable()(PHP 7.3+) or ensure the value is an array/Countable before counting.- TypeErrors from internal functions — arithmetic and bitwise operators throw
TypeErrormore consistently when operands are arrays, resources, or non-overloaded objects (PHP 8). Code that relied on silent coercion needs explicit casts. - Undefined constants — historically, a bare undefined constant could be treated as a string of its own name (with a notice). Under modern defaults this becomes a louder failure. Prefer quoted strings or true defined constants; use
defined()when configuration keys are optional.
# LEGACY assumptions
if (FEATURE_FLAG) { /* might have meant the string 'FEATURE_FLAG' */ }
$n = count($maybeNull);
# MODERN
if (defined('FEATURE_FLAG') && FEATURE_FLAG) { /* ... */ }
$n = is_countable($maybeNull) ? count($maybeNull) : 0;
Map runtime signatures through PHP Errors as you raise error_reporting.
Dynamic properties
PHP 8.2 deprecates creation of dynamic properties on most classes (with opt-outs via AllowDynamicProperties or the presence of __get/__set). PHP 5 code that stuffed arbitrary fields onto models will emit deprecations on 8.2+ and may become errors later. Declare properties, use DTOs/arrays, or consciously annotate classes that must remain bag-like during transition. Error guide: creation of dynamic property deprecated.
# LEGACY
class Row {}
$r = new Row();
$r->email = $email; // dynamic
# MODERN
class Row {
public function __construct(public string $email) {}
}
$r = new Row($email);
# TRANSITIONAL (explicit bag during migration)
#[AllowDynamicProperties]
class LegacyRow {}
Serialization and sessions
Object serialization and session payloads are frequent silent breakers when PHP versions and class definitions drift.
- Prefer JSON or explicit DTOs for new cross-process data. If you must keep PHP serialization, ensure class definitions remain loadable and that you understand
Serializable/__serialize/__unserializechanges across versions. - Session cookie flags belong in modernization too:
HttpOnly,Secureon HTTPS, andSameSite. Regenerate IDs on privilege changes. See session modernization. - Do not store untrusted serialized strings from users—object injection is a classic PHP risk.
# TRANSITIONAL — safer session cookie defaults on modern PHP
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
Error handling across the ladder
PHP 7 converted many fatal errors into Error exceptions implementing Throwable. Handlers typed as Exception alone will miss Error. PHP 8 default error_reporting is E_ALL; the @ operator no longer silences fatal errors. Custom handlers that checked error_reporting() == 0 must use a mask check—see official PHP 8 migration notes.
# LEGACY (PHP 5 era)
function handler(Exception $e) { log($e); }
# MODERN
function handler(Throwable $e) { log($e); }
# TRANSITIONAL error handler mask (PHP 8 @ behavior)
function app_error_handler(int $no, string $msg, string $file, int $line): bool {
if (!(error_reporting() & $no)) {
return false;
}
log_error($no, $msg, $file, $line);
return true;
}
Production must keep display_errors=Off with logging enabled. Generate safe ini snippets with the Error Reporting Generator and read error handling modernization.
Namespaces, autoloading, and Composer
PHP 5.3 introduced namespaces; many PHP 5 apps never adopted them. Introduce Composer with a PSR-4 root for new code while keeping a transitional classmap for legacy trees. Avoid rewriting every include on day one—stabilize runtime first. See Composer modernization center, adding Composer, and legacy Composer dependencies.
# TRANSITIONAL composer.json fragment
{
"autoload": {
"psr-4": { "App\\": "src/" },
"classmap": ["legacy/lib/"]
},
"config": { "platform": { "php": "8.3.0" } }
}
Pin config.platform.php to the PHP version you actually run in staging/production so developer laptops do not resolve packages you cannot install on the server. Validate with Composer platform checker and composer.json validator.
PDO / mysqli choices
Both mysqli and PDO are supported successors to ext/mysql. Prefer PDO when you want a consistent API across drivers and straightforward prepared statements; mysqli is fine when you need MySQL-specific features and your team already knows it. Either way:
- Use prepared statements for user input—never rebuild
mysql_real_escape_stringculture. - Set connection charset to
utf8mb4(PDO DSNcharset=utf8mb4or mysqliset_charset). - Enable exceptions (
PDO::ERRMODE_EXCEPTIONor mysqli exception mode) so failures are not silent.
Character sets: utf8mb4 end-to-end
PHP 5 apps often mixed Latin-1 pages, MySQL utf8 (3-byte) columns, and utf8_encode band-aids. Modernize to UTF-8 everywhere: connection charset utf8mb4, tables/columns converted with a rehearsal on a copy, HTML charset=utf-8, and mbstring for user-facing string ops. Note: utf8_encode/utf8_decode are deprecated as of PHP 8.2—see utf8_encode/decode.
# MODERN PDO DSN fragment
mysql:host=db;dbname=app;charset=utf8mb4
# TRANSITIONAL rehearsal checklist
# 1) mysqldump copy database
# 2) convert tables/columns on the copy
# 3) compare row counts and spot-check emoji / CJK samples
# 4) only then schedule production conversion windows
Testing discipline that makes the ladder safe
Without tests, every version bump is a coin flip. Minimum viable harness:
- Smoke scripts for login, checkout/payment stub, nightly import, and password reset.
- PHPUnit for pure domain logic you extract during PDO migration.
- A staging matrix that runs the same suite on current production PHP and the next candidate.
- Error-rate dashboards for 48 hours post-cutover with a documented FPM image rollback.
Expand coverage as you touch modules. Related: testing legacy PHP.
Step-by-step runtime ladder that actually ships
- Inventory and scanners — map SAPIs, extensions, Composer, and removed APIs.
- Delete fatal blockers for PHP 7 —
mysql_*, POSIX regex, anything that cannot even parse/boot. - Boot under PHP 7.4 in CI — often the last forgiving bridge after removing
mysql_*, ereg, and mcrypt. Fix exception handlers forThrowable. - Clear 7.2–7.4 deprecations that become 8.0 fatals —
each,create_function, real constructors, curly offsets. - Move to PHP 8.0 in CI, then a supported 8.2–8.5 target; read the deep PHP 7→8 guide and the checklist.
- Absorb later deprecations — null-to-internal-params (8.1), dynamic properties and
utf8_encode(8.2), then continue via 8.0→8.1, 8.1→8.2, and onward as needed. - Only then pursue architecture refactors (microservices, full framework swaps).
Rollback plan (write it before cutover)
- Keep the previous PHP-FPM/container image tagged and deployable.
- Keep database migrations backward-compatible across the cutover window, or have a restore rehearsal.
- Document who flips the pool selector / orchestrator tag and who watches error rates.
- Define abort criteria (fatal spike, payment failure rate, login failure rate)—not vibes.
Scanners and workbench tools
Automate discovery before heroic reading:
- PHP Version Compatibility Checker
- PHP Deprecated Checker
- Composer Platform Checker
- PHP Environment Compare
- Config Diff
- php.ini Generator
Official references to keep open while you work: migration70, migration71, migration72, migration74, migration80, and later 8.x notes.
Related hubs: version migration center, removed functions, PHP 5 to PHP 7, inherited applications, legacy audit.
Indirect variables and list() surprises from the PHP 7 boundary
When your ladder includes PHP 7 as a bridge, remember that PHP 7 changed evaluation order for indirect variables/properties/methods and changed list() assignment order. Code that depended on PHP 5 right-to-left quirks needs explicit curly-brace disambiguation for dual-compatible transitions. Official details live in migration70 incompatible changes. Fix these while you are already bootstrapping on 7.4 rather than discovering them only after a leap toward PHP 8.
Related reading
- Composer Modernization Center Add Composer to legacy PHP projects, migrate includes to autoloading, set platform constraints, and replace a…
- PHP Security Modernization for Legacy Applications Upgrade inherited PHP security practices: prepared statements, password hashing, sessions, CSRF, XSS escaping…
- PHP 7 to PHP 8 Migration Guide Deep guide to PHP 8.0 breaking changes that matter for PHP 7 applications, with upgrade tactics through suppo…
- Inheriting a Legacy PHP Application A first-30-days playbook for developers handed an unfamiliar PHP codebase: runtime truth, risk triage, and sa…
- Manual Includes to Composer Autoloading Stage a migration from require/include trees to Composer classmap and PSR-4 without deleting every include on…