Article
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 supported 8.x branches.
PHP 8.0 is the hard edge for many PHP 7 codebases: removed functions, engine warnings promoted to exceptions, and stricter typing around internal APIs. This page expands the checklist into the failure modes you will actually debug. Primary source: migration80 incompatible changes.
Target a supported 8.x, not “any 8”
Verified 2026-08-09: PHP 8.2–8.5 remain on the supported list; 8.0 and 8.1 are EOL. Plan Composer and images for 8.2+ (or 8.3/8.4/8.5 if dependencies allow). Intermediate CI on 8.0 is fine for isolating 8.0-only breaks, but do not stop there. Pair this guide with PHP 7.4 to PHP 8 when 7.4 is your current production pin.
Removed in 8.0 that still lurks in PHP 7 apps
These APIs often still compile under PHP 7.4 and explode on the first 8.0 request:
each()— useforeach(detail, error guide)create_function()— use closures (detail, error guide)money_format()— useNumberFormatter/ intl (detail)- PHP 4-style constructors — only
__construct()remains - Non-static methods called statically — fatals;
is_callable('Class::method')style checks for instance methods fail (real)and(unset)casts removed__autoload()removed — usespl_autoload_register()/ Composer- Curly-brace offsets
$a{0}removed — use$a[0] - Case-insensitive constants via
define(..., true)removed array_key_exists()on objects removed — useisset()/property_exists()
# LEGACY (runs on 7.4, fatal on 8.0)
while (list($k, $v) = each($rows)) {
export_row($k, $v);
}
$cb = create_function('$x', 'return $x * 2;');
$first = $name{0};
# MODERN
foreach ($rows as $k => $v) {
export_row($k, $v);
}
$cb = static fn($x) => $x * 2;
$first = $name[0];
Stricter types and engine TypeErrors
PHP 8 makes many previously soft failures into TypeError / Error exceptions. Arithmetic and bitwise operators consistently throw when operands are arrays, resources, or non-overloaded objects (array + array merge remains). Internal functions that once coerced odd inputs more forgivingly now reject them. Practical impact:
- Auth and pricing code that did
$total + $couponwhen a coupon could be an array will fatal. - Helpers that returned
falseon failure and were later used as numbers need explicit guards. - Enable exceptions in your database layer so you see failures instead of empty result sets.
# LEGACY — silent-ish coercion culture
$sum = $price + $discount; // $discount might be unexpected type
# MODERN — validate before arithmetic
if (!is_numeric($discount)) {
throw new InvalidArgumentException('discount must be numeric');
}
$sum = (float)$price + (float)$discount;
Warnings promoted toward errors; notices toward warnings
Per the official incompatible-changes page, a number of former warnings became Error exceptions (for example invalid operations that previously only warned), and many former notices became warnings (undefined variables, properties, and array keys among them). Combined with default error_reporting=E_ALL, staging suddenly looks “noisy” in a useful way. Do not lower production reporting to hide the migration—fix the call sites, then keep logging.
Map concrete messages through PHP Errors as they appear. Common companions include undefined array key patterns and type mismatches that only fire on rare request shapes.
Named arguments (adopt after baseline boots)
PHP 8.0 introduces named arguments. They are powerful for optional parameters, but they couple you to parameter names in the callee. During migration:
- Get the application booting and tests green before mass-adopting named args in your own APIs.
- Be careful calling into vendor code with named arguments—renames in minor library upgrades can break you.
- Prefer named arguments at boundaries where skipping optional parameters clarifies intent (for example HTML escaping flags).
# MODERN — clarifying call sites after baseline is green
htmlspecialchars(
$value,
flags: ENT_QUOTES | ENT_SUBSTITUTE,
encoding: 'UTF-8'
);
String and number comparisons
Non-strict comparisons between numbers and non-numeric strings changed: the number is cast to string and compared as strings. Notably, 0 == "not-a-number" is now false. Numeric strings continue to compare as before. Hunt loose comparisons in authentication, pricing, feature flags, and “empty means zero” shortcuts. Prefer strict comparisons (===) and explicit casts.
# LEGACY assumption (PHP 7)
var_dump(0 == "not-a-number"); // true historically in many cases
# MODERN reality (PHP 8)
var_dump(0 == "not-a-number"); // bool(false)
# MODERN preference
if ($status === 0) { /* ... */ }
if ((string)$code === (string)$incoming) { /* ... */ }
Undefined constants and array offsets
Undefined constants and undefined array keys become much more visible under PHP 8 defaults. Barewords that once quietly became strings of their own name are migration landmines. Prefer quoted strings, real constants, or defined() checks. For arrays, use null coalescing and explicit isset patterns instead of silencing with @.
# LEGACY
if (ENABLE_UPLOADS) { /* risky if undefined */ }
$path = $config['path']; // notice/warning if missing
# MODERN
if (defined('ENABLE_UPLOADS') && ENABLE_UPLOADS) { /* ... */ }
$path = $config['path'] ?? '/var/app';
Dynamic properties (plan for 8.2+ while you are here)
Dynamic properties are not the main 8.0 break, but if your target is a supported 8.2–8.5 branch you will meet them soon. PHP 8.2 deprecates creating dynamic properties on most classes. Inventory bag-like models now so the 8.0→8.2 climb is not another surprise. Guide: dynamic property deprecated and 8.1→8.2.
Resource to object migrations (especially 8.1)
Several extension APIs migrated from resources to objects across the 8.x line (notably in 8.1). Code that used is_resource() checks, or assumed resource semantics for curl/gd/etc., needs updates when you leave 8.0. Treat “boots on 8.0” as a checkpoint, then read 8.0→8.1 before calling the job done. Keep extension lists identical across CI and production with environment compare.
Error handling and the @ operator
Default error_reporting is E_ALL. display_startup_errors is enabled by default (still keep browser display off in production). The @ operator no longer silences fatal errors. Custom handlers that checked error_reporting() == 0 must use a mask check—see the official migration example.
# LEGACY handler assumption
function app_error_handler($no, $msg, $file, $line) {
if (error_reporting() == 0) {
return false;
}
log_error($no, $msg, $file, $line);
return true;
}
# TRANSITIONAL / MODERN handler fix
function app_error_handler(int $no, string $msg, string $file, int $line): bool {
if (!(error_reporting() & $no)) {
return false; // silenced
}
log_error($no, $msg, $file, $line);
return true;
}
Also note: the errcontext argument is no longer passed to handlers set with set_error_handler(). Exception handlers should accept Throwable, not only Exception. Tools: Error Reporting Generator, guide: error handling modernization.
Concatenation precedence and other syntax traps
"Sum: " . $a + $b now groups as "Sum: " . ($a + $b). Add parentheses in mixed arithmetic/string expressions. match and mixed are reserved. Attributes use #[...], so #[ is no longer a comment start. Arguments whose default resolves to null at runtime no longer implicitly mark the parameter nullable—declare ?Type or = null explicitly.
# LEGACY ambiguity
echo "Sum: " . $a + $b;
# MODERN clarity
echo "Sum: " . ($a + $b);
# MODERN nullable defaults
function test(?int $arg = null): void {}
Composer, frameworks, and extensions
Runtime upgrades fail when dependencies cannot declare PHP 8. Before flipping FPM pools:
- Set
config.platform.phpto your intended minor; validate with platform checker. - Run
composer updateon a branch (not directly on production) and read changelogs for Symfony/Laravel/Laminas/ZF components you still carry. - Confirm required extensions exist on the new image (
intlfor money formatting replacements,sodium/opensslfor crypto, etc.). - Avoid permanent
--ignore-platform-reqs—it hides the mismatch you must fix. - If you are mid Zend Framework migration, complete or sequence ZF→Laminas deliberately; do not assume framework renames fix PHP 8 engine breaks.
Composer hub: Composer modernization.
Testing strategy for 7→8
- Freeze a green suite on current PHP 7.x.
- Add CI job on PHP 8.0 with the same suite; fix removed APIs and comparison bugs first.
- Raise static analysis (PHPStan/Psalm) after the suite is green—not before you can boot.
- Promote CI to your supported target (8.2+), fixing 8.1/8.2 deprecations.
- Staging soak with production-like data; watch logs for newly promoted warnings.
- Cut over with a tagged rollback image and 24–48h error-rate watch.
Smoke the money paths, authentication, file uploads, and cron/CLI workers—web-only tests miss half of PHP 7 apps.
From 8.0 baseline to a supported minor
After the app boots on 8.0 in CI:
- Absorb 8.1 resource→object migrations and null-parameter deprecations (8.0→8.1).
- Clear 8.2 dynamic properties and
utf8_encodedeprecations (8.1→8.2). - Continue through 8.3, 8.4, 8.5 as your platform target requires.
Scan early, link the error guides
Use compatibility and deprecation tools before the first staging deploy. Keep these error/detail pages nearby while greenlighting tickets:
- each() removed
- create_function() removed
- dynamic property deprecated
- removed functions hub
- version migration center
Official entry points: migration80, incompatible changes, deprecated in 8.0, and subsequent 8.x migration chapters.
Attribute syntax and reserved words in real codebases
PHP 8.0 introduces attributes with #[...]. That means #[ is no longer the start of a comment. Legacy code generators or docblock experiments that emitted #[ sequences can suddenly parse differently. Likewise, match and mixed are reserved—rename classes, interfaces, traits, and namespaces that collided with those words before the upgrade window, not during the outage.
Inheritance errors due to incompatible method signatures (LSP violations) now always generate a fatal error; some cases previously only warned. If your PHP 7 code relied on “close enough” child method signatures, PHP 8 will stop the request. Static analysis helps, but integration tests that exercise subclass overrides are the real safety net.
Null defaults, nullable types, and internal parameter tightening
Arguments with a default value that resolves to null at runtime no longer implicitly mark the argument type as nullable. Declare ?Type or an explicit null default. Separately, as you move past 8.0 toward 8.1+, passing null to internal function parameters that are not nullable becomes a deprecation—and later an error. Clean call sites while you are already touching them for PHP 8.0 breaks so the next minor is quieter.
# LEGACY implicit nullable
function test(int $arg = CONST_RESOLVING_TO_NULL) {}
# MODERN
function test(?int $arg = null): void {}
Float-to-string and locale independence
Float to string casting is locale-independent in PHP 8. Code that depended on setlocale changing (string)$float separators will break silently in reports and CSV exports. Use number_format(), printf family functions, or NumberFormatter when presentation must follow a locale. This pairs with the money_format() removal—plan intl-based formatting as one work item.
Practical greps before the first PHP 8 staging deploy
grep -RInE 'each\s*\(|create_function\s*\(|money_format\s*\(|__autoload\s*\(' .
grep -RInE '\(real\)|\(unset\)|\{[0-9]+\}|\{"[^"]+"\}' --include='*.php' .
grep -RInE 'error_reporting\s*\(\s*\)\s*==\s*0' --include='*.php' .
grep -RInE '\bmatch\b|\bmixed\b' --include='*.php' . | head
Feed hotspots into compatibility checker. When a fatal signature appears in logs, jump to the matching PHP Errors page and fix with a test that would have failed on 7.4 if the behavior change is semantic (comparisons) rather than merely removed API.
Deploy checklist unique to 7→8
- Composer platform set to the intended supported minor, not only “8.0”
- Extension parity verified (intl, mbstring, sodium/openssl as needed)
- OPcache reset strategy after release
- Xdebug 3 path maps updated if developers still debug against staging
- Rollback image tagged; abort criteria written (fatal rate, checkout failures)
- Cron/CLI binaries pointed at the same major/minor as FPM
If you are coming from PHP 5 still, do not skip the removals documented in PHP 5 to modern PHP—PHP 8 will not resurrect mysql_* or ereg.
Why intermediate 8.0 CI still matters
Even when production will land on 8.3 or 8.4, a temporary CI matrix row on PHP 8.0 isolates classic 8.0 removals from later resource-to-object and dynamic-property work. Fix the 8.0 row first, then advance the matrix. That ordering shortens bisects when a failure could otherwise be blamed on “PHP 8” generically. Keep the release checklist as the gate document for humans signing off the cutover.
Related reading
- Composer Modernization Center Add Composer to legacy PHP projects, migrate includes to autoloading, set platform constraints, and replace a…
- 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,…
- PHP Security Modernization for Legacy Applications Upgrade inherited PHP security practices: prepared statements, password hashing, sessions, CSRF, XSS escaping…
- 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…