How to Upgrade a Legacy PHP Application to PHP 8.5
Author: Jeffery L. Paris
Date: July 25, 2026
Time: 9:28 PM EDT
Website: https://phpog.com/
------------------------------------------------------------
Changing the PHP version in a hosting control panel may take only a few seconds, but safely upgrading the application behind that setting can take considerably longer. That difference matters because the version selector isn’t the upgrade. It’s only the moment when the server begins running years of work under a newer set of rules.
An older PHP application may have served visitors reliably for a decade or more. It may process registrations, send email, resize images, manage customer records, run scheduled jobs and support an entire business without drawing much attention to itself. Then a hosting provider announces that an older PHP branch is nearing the end of support, somebody selects a newer version and parts of the application suddenly stop working.
The first reaction is often to blame the old code, the new PHP version or the person who originally built the system. I don’t believe that helps anybody. A better approach is to understand what the application depends on, find what the newer runtime will interpret differently and repair those areas before production users are asked to discover the problems for us.
At the time this article was prepared in July 2026, PHP 8.5 was the current stable branch and PHP 8.5.8 was the current production release. PHP 8.6 was still in alpha testing and wasn’t intended for production. PHP 8.2 was still receiving security fixes, but that support was scheduled to end on December 31, 2026. Those dates give us a reason to prepare, not a reason to panic.
A responsible PHP upgrade begins by understanding the application before asking a newer runtime to judge it.
There’s No Victory in Breaking Working Software
Legacy code is often discussed as though its age alone makes it defective. That’s too simple. An application that has remained useful for ten or fifteen years has already proven something important. It solved a real problem well enough that people continued depending on it.
The code may contain outdated functions, assumptions that newer PHP versions no longer accept or patterns we wouldn’t choose today. That doesn’t erase the value the application created or the work that went into building it. Modernization should preserve that value while removing the weaknesses that time has revealed.
I don’t believe every older application needs to be rewritten from the ground up. A rewrite may eventually be the right decision, but it shouldn’t become the automatic answer simply because repairing the existing system requires patience. Sometimes a developer learns more by tracing the old system carefully than by replacing it with something new and repeating the same misunderstandings under cleaner syntax.
A building doesn’t become worthless when its wiring needs to be replaced. The wiring is inspected, the dangerous sections are identified and the work is completed without pretending the rest of the structure never mattered. A PHP upgrade should be approached with the same discipline.
The Upgrade Begins Before PHP Changes
The first step isn’t installing PHP 8.5. The first step is learning what the application is doing today while it still works.
That means identifying:
- Which PHP version serves public web requests
- Which PHP version serves the administration area
- Which PHP binary runs command-line scripts
- Which PHP binary runs scheduled jobs
- Which extensions are installed
- Which configuration files are loaded
- Which directories the PHP process can read and write
- Which external services the application contacts
- Which database version and character set are in use
- Which third-party packages are installed
It’s possible for the command line and web server to use different PHP versions on the same machine. A developer may run php -v and see PHP 8.5 while the website is still being processed by PHP 8.2 through PHP-FPM. The reverse can happen too. A hosting panel may upgrade the website while an old cron command continues calling another binary.
That’s why assumptions aren’t enough. An upgrade should begin with evidence gathered from the same paths the application actually uses.
Record the Current Environment
Before changing anything, record the current environment while the application is still working. From the command line, these commands provide a useful starting point:
php -v
php --ini
php -m
php -i
The first command shows the command-line PHP version. The second identifies the loaded configuration files. The third lists installed extensions and the fourth produces a much larger configuration report.
For a web request, create a temporary protected diagnostic file:
<?php
echo 'PHP version: ';
echo PHP_VERSION;
echo PHP_EOL;
echo 'SAPI: ';
echo PHP_SAPI;
echo PHP_EOL;
echo 'Loaded configuration: ';
echo php_ini_loaded_file();
echo PHP_EOL;
echo 'Additional configuration: ';
echo php_ini_scanned_files();
echo PHP_EOL;
Don’t leave a public phpinfo() page online. It can expose server paths, extension details, environment settings and other information that doesn’t need to be available to strangers. Run the diagnostic through the same route and web server that process the application, save the results securely and remove the file when the inspection is complete.
I also recommend recording the output in a dated text file. Six months later, when somebody asks what changed, a real baseline will answer more clearly than memory.
Map the Application Before Testing It
An application can’t be meaningfully tested if nobody has identified what it’s supposed to do. Start by listing the important parts of the system.
That list may include:
- Public page controllers
- User registration
- Login, logout and password recovery
- Administrative authentication
- Role and permission checks
- Contact forms
- Email delivery
- File and image uploads
- Image resizing
- Search
- Payment processing
- Order management
- Import and export tools
- API endpoints
- Webhook receivers
- Scheduled maintenance
- Queue workers
- Database backup tools
- Reporting and analytics
Don’t assume the visible website represents the entire application. A public page may work perfectly while a scheduled invoice task fails every night. The administration panel may load while its image uploader silently rejects every file. A contact form may show a success message even though the mail transport failed.
The parts that operate quietly are often the easiest to forget and the most expensive to lose. A good inventory brings those quiet responsibilities into the light before the upgrade begins.
Find Every Entry Point
An entry point is any script the server, user or another system can call directly. Common examples include:
index.php
admin/index.php
api/index.php
cron.php
worker.php
webhook.php
download.php
upload.php
image.php
Some applications contain individual controllers that can also be requested directly. Others use one front controller and route every request through it. Document both the expected routes and the actual executable files.
This becomes useful later when you need to answer questions such as:
- Did every controller receive a syntax check?
- Did every protected route receive an authentication test?
- Did every state-changing route receive a CSRF test?
- Did every command-line script run under PHP 8.5?
- Did every webhook still return the expected status code?
You can’t safely upgrade what you haven’t mapped. The map doesn’t need to be fancy, but it should be complete enough that nothing important is left depending on luck.
Preserve a Restorable Baseline
Before repairing the application, create a complete baseline. That usually includes:
- Application files
- Database contents
- Configuration files
- Web server rules
- Cron definitions
- Environment variables
- Uploaded media
- Private storage directories
- Composer lock files
- The current PHP and extension inventory
A backup isn’t proven because an archive exists. It’s proven when the files can be extracted, the database can be restored and the application can be started from the restored copy.
This distinction matters during an upgrade because rollback isn’t always as simple as changing the PHP version back. A failed deployment may have already altered database rows, regenerated caches, changed file formats or upgraded third-party packages. Your baseline should give you a path back to the complete known state, not only the old interpreter.
I’ve learned to respect backups the way a traveler respects water. You may carry them for a long time without needing them, but the day you do need them isn’t the day to discover the container was empty.
Create a Production-Like Test Environment
The safest place to discover upgrade problems is an environment where those problems can’t harm real users. A useful staging environment should resemble production closely enough that successful testing means something.
Match as many of these conditions as possible:
- PHP version
- PHP configuration
- Installed extensions
- Web server
- Rewrite rules
- Database engine and version
- Database character set and collation
- Directory structure
- File ownership and permissions
- Scheduled commands
- Environment variables
- Mail transport
- Image-processing library
A local environment can still be useful, but a Windows development machine won’t reveal every permission, path and filename issue that appears on a Linux production server. A staging system that uses different extensions, permissive file ownership and an unrelated web server may give false confidence.
The closer the environment is to production, the more trustworthy the results become. Perfect duplication isn’t always possible, but every known difference should be documented so it doesn’t quietly become a blind spot.
Protect Private Data During Testing
A production database often contains information that shouldn’t be copied casually. Before placing production-derived data into staging, consider removing or replacing:
- Passwords and authentication tokens
- Email addresses
- Names
- Addresses
- Telephone numbers
- Payment references
- Private messages
- API credentials
- Reset tokens
- Remember-me tokens
- Session records
Testing needs realistic structure and edge cases. It doesn’t always need real identities. A good sanitized dataset preserves useful conditions such as long strings, empty values, old dates, unusual characters and duplicate records while removing the information that could harm someone if the staging system were exposed.
Privacy isn’t separate from technical quality. A system can pass every compatibility test and still fail the people it was supposed to serve if their information is handled carelessly.
Enable Complete Error Reporting in Development
A legacy application may appear stable partly because warnings and deprecations aren’t visible. That silence can be useful for visitors, but it isn’t useful during an audit.
In staging, enable complete reporting:
<?php
error_reporting(E_ALL);
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
ini_set('log_errors', '1');
This code should be limited to a development or staging environment. Production should normally log errors without displaying internal paths, query details or stack traces to visitors:
<?php
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('display_startup_errors', '0');
ini_set('log_errors', '1');
Make certain the error log is actually writable and monitored. An application that hides errors from visitors but also fails to record them hasn’t solved the problem. It has only made the failure harder to find.
Run this audit on the currently working PHP version before switching staging to PHP 8.5. If the application is on PHP 7.4, PHP 8.0 or PHP 8.1, complete error reporting may expose deprecations and warnings that predict where the newer runtime will become less forgiving. The old runtime can tell us which assumptions are already weakening before the new runtime turns some of them into exceptions or hard failures.
Clear or rotate the staging log before each test pass, record the time the pass begins and watch the log while exercising the application. On a Linux server, that may be as simple as:
tail -f /path/to/php-error.log
The correct path may differ between PHP-FPM pools, Apache modules, virtual hosts, command-line jobs and hosting panels. Don’t assume the web application, cron jobs and queue workers all write to the same file. Capture standard error from scheduled commands when necessary and test each execution path under the runtime that actually serves it.
Repeat the same manual test pass after switching staging to PHP 8.5, then compare the two logs. The goal isn’t merely to find new PHP 8.5 messages. It’s to distinguish old application defects from behavior introduced by the runtime change.
Warnings Are Evidence, Not Annoyances
Developers sometimes become accustomed to warnings because the application continues producing a page afterward. That can make warnings feel cosmetic, but they aren’t.
A warning often tells us that the application received data it didn’t expect, depended on an uninitialized value or asked PHP to make an ambiguous decision.
Common examples include:
- Reading an undefined array key
- Passing null to a function expecting a string
- Trying to access an array offset on a non-array value
- Sending headers after output began
- Using an uninitialized variable
- Converting an invalid numeric string
- Calling a function with the wrong argument type
- Writing an undeclared object property
Older PHP versions may have tolerated some of these conditions quietly or reported them less forcefully. Newer releases increasingly expose ambiguous behavior. That isn’t PHP becoming unreasonable. It’s PHP showing us where the application hasn’t clearly stated what it expects.
Don’t turn off the warning because it’s inconvenient. Follow it backward until the unclear assumption is found. The warning is usually smoke coming from a fire that may be small today and expensive tomorrow.
Audit the Journey, Not Only the Destination
An application moving from PHP 7.4 to PHP 8.5 isn’t making one compatibility jump. It’s crossing every change introduced in PHP 8.0, 8.1, 8.2, 8.3, 8.4 and 8.5.
Reading only the PHP 8.5 migration guide can miss the earlier removals and behavior changes that will break a much older application. Review the migration guide for every version between the application’s current runtime and its destination.
If the application is still running on PHP 5, the gap is wider and deserves even more care. Removed extensions, changed error behavior, stricter function signatures and differences in type handling may all be involved.
This is one reason I prefer deliberate upgrades over sudden leaps made under pressure. A bridge is easier to cross when we inspect each section instead of assuming the far bank is the only place that matters.
What to Hunt for in a Manual PHP 8.x Code Audit
Once the application and its entry points are mapped, the next pass should search for patterns whose meaning changed anywhere between the current runtime and PHP 8.5. This isn’t a substitute for reading each migration guide. It’s a practical way to turn those guides into a code-review plan.
Dynamic Properties
PHP 8.2 deprecated creating object properties that weren’t declared in the class. Older applications often depended on this behavior intentionally, but a misspelled property name could create one just as easily.
class Account {
public $email;
}
$account = new Account();
$account->status = 'active';
In this example, status should normally be declared in the class:
class Account {
public $email;
public $status;
}
Search for assignments to object properties, then compare each assignment with the property declarations in the class and its parents. Don’t add #[AllowDynamicProperties] everywhere simply to quiet the warning. That attribute may be a temporary bridge for a class that truly requires dynamic fields, but it can also preserve spelling mistakes and unclear object contracts.
Loose Comparisons and Numeric Strings
PHP 8 changed how numbers are compared with non-numeric strings. Code that once treated 0 == 'disabled' as true no longer does. That change is safer, but an older application may have built decisions around the earlier result.
Search for ==, !=, switch comparisons and arithmetic involving request values or database strings. Then decide whether the application expects an integer, decimal, identifier, empty value or literal text. Validate the permitted format and use strict comparison when the type is part of the rule.
if ($submitted_status === '0') {
// Handle the literal string value.
}
Don’t replace every loose comparison mechanically. A database driver may return a numeric column as a string, and changing == to === without tracing the data can break valid behavior. The audit should make the expected type explicit at the boundary, then compare values according to that contract.
Warnings That Became Exceptions
PHP 8 converted several conditions that older versions reported as warnings into TypeError, ValueError, ArgumentCountError or other Error exceptions. Internal functions are especially important because calls that once returned false or emitted a warning may now stop the request unless the input is validated or the exception is handled.
Review calls where a value may be null, an array, a resource, an object or an invalid number. Common examples include string functions, array functions, arithmetic, count(), callback functions and functions that accept a length, offset or range.
$display_name = $row['display_name'];
if (is_string($display_name) === false) {
$display_name = '';
}
$display_name = trim($display_name);
The point isn’t to wrap every function call in a try block. It’s to validate data where it enters the system and preserve exceptions for conditions the application genuinely can’t resolve.
Reserved Names
PHP 8 introduced new language words, including match and mixed. Search for old classes, interfaces, traits and functions using those names before the linter discovers them one file at a time.
class Match {
// Invalid under PHP 8.
}
Also inspect generated files, cached code, plugin directories and rarely loaded administration tools. A reserved name in an optional module may remain hidden until the first request that loads it.
Removed Calling and Syntax Patterns
During the same review, look for:
- Old-style constructors named after the class instead of __construct()
- Non-static methods called statically
- Incompatible parent and child method signatures
- Magic methods with incorrect signatures
- Curly-brace string or array offsets such as $value{0}
- Nested ternaries without explicit parentheses
- Removed functions such as each() and create_function()
- Undefined constants that older PHP versions treated as strings
- Calls that depend on the error-suppression operator hiding fatal failures
Simple searches can narrow the review:
grep -RIn --include='*.php' -E '==|!=' .
grep -RIn --include='*.php' -E '\b(class|interface|trait|function)[[:space:]]+(match|mixed)\b' .
grep -RIn --include='*.php' -E '\$this->[A-Za-z_][A-Za-z0-9_]*[[:space:]]*=' .
These commands produce leads, not verdicts. The dynamic-property search, for example, will also find assignments to properly declared properties. Every result still requires a developer who understands the class, the source of the data and the behavior the application is supposed to preserve.
PHP 8.5 Changes Worth Searching For
PHP 8.5 introduces useful language features, but a compatibility audit should begin with the changes that may affect existing code.
Noncanonical Cast Names
The longer cast forms are deprecated:
$enabled = (boolean) $value;
$count = (integer) $value;
$price = (double) $value;
$data = (binary) $value;
Use the canonical forms instead:
$enabled = (bool) $value;
$count = (int) $value;
$price = (float) $value;
$data = (string) $value;
This is usually a simple repair, but the audit may uncover hundreds of occurrences in an older codebase. Make the change carefully and let the surrounding tests confirm that the original intent was preserved.
Backtick Command Execution
Using backticks as an alias for shell_exec() is deprecated:
$result = `command --option`;
If the application legitimately needs to run an operating-system command, make that behavior explicit and validate every argument:
$command = 'command --option';
$result = shell_exec($command);
Don’t treat this as a simple search-and-replace without inspecting where the command comes from. Shell execution involving request data can become a command-injection vulnerability. Some old backtick calls should be redesigned or removed rather than translated.
Null Array Offsets
Using null as an array key or passing it to array_key_exists() is deprecated.
Code like this deserves inspection:
$key = null;
$value = $items[$key];
if (array_key_exists($key, $items)) {
// Continue.
}
Decide what null actually means. Should it become an empty string? Should the operation be skipped? Is the missing key evidence that earlier validation failed?
Don’t silence the deprecation by converting the value until the intended behavior is understood. A clean warning log isn’t worth much if the repair quietly changes the meaning of the application.
Case Statements Ending With Semicolons
Older code may contain:
switch ($status) {
case 'active';
echo 'Active';
break;
}
Use a colon:
switch ($status) {
case 'active':
echo 'Active';
break;
}
Destructuring Non-Array Values
PHP 8.5 warns when code attempts to destructure a non-array value other than null.
list($first, $second) = $result;
Before destructuring, verify the type and shape of the value:
if (is_array($result) === false) {
throw new RuntimeException('Expected an array result.');
}
if (array_key_exists(0, $result) === false) {
throw new RuntimeException('The first result value is missing.');
}
if (array_key_exists(1, $result) === false) {
throw new RuntimeException('The second result value is missing.');
}
list($first, $second) = $result;
Unsafe Float-to-Integer Conversion
PHP 8.5 warns when a float, or a string that looks like a float, can’t be represented safely as an integer. Don’t assume every numeric-looking value is appropriate for an integer cast.
$quantity = (int) $submitted_value;
Validate the permitted format and range first:
if (filter_var($submitted_value, FILTER_VALIDATE_INT) === false) {
throw new InvalidArgumentException('Quantity must be an integer.');
}
$quantity = (int) $submitted_value;
Legacy Serialization Methods
The __sleep() and __wakeup() magic methods are soft-deprecated in favor of __serialize() and __unserialize().
This may affect applications that serialize objects into sessions, caches, queues or database fields. Object serialization deserves extra caution because changing class definitions can make old serialized data difficult or unsafe to restore.
Document where serialized objects are stored before altering the format. The visible method may be easy to replace, but the older data waiting in a database or session store may be the part that needs the most thought.
Run a Syntax Audit Across the Entire Project
PHP’s built-in linter can check one file:
php -l path/to/file.php
On systems with the standard Unix tools available, a project-wide check may look like:
find . -type f -name '*.php' -print0 | xargs -0 -n1 php -l
This is useful, but it only proves that PHP can parse the files. It doesn’t prove that database queries succeed, redirects reach the correct location, sessions survive between requests, email is delivered, JSON responses keep the same structure, file uploads remain writable or scheduled jobs use the expected PHP binary.
Syntax checking is one gate, not the finish line. A sentence can be grammatically correct and still say the wrong thing. Code can be syntactically valid and still fail the people using it.
Build a Small Compatibility Scanner
A pattern scanner can help locate code that deserves human review. The following procedural PHP script recursively scans a project for several legacy patterns:
<?php
$project_directory = __DIR__;
$patterns = array(
'Removed mysql_* API' => '/\bmysql_[a-z_]+\s*\(/i',
'create_function()' => '/\bcreate_function\s*\(/i',
'each()' => '/\beach\s*\(/i',
'utf8_encode()' => '/\butf8_encode\s*\(/i',
'utf8_decode()' => '/\butf8_decode\s*\(/i',
'FILTER_SANITIZE_STRING' => '/\bFILTER_SANITIZE_STRING\b/',
'Noncanonical boolean cast' => '/\(\s*boolean\s*\)/i',
'Noncanonical integer cast' => '/\(\s*integer\s*\)/i',
'Noncanonical double cast' => '/\(\s*double\s*\)/i',
'Noncanonical binary cast' => '/\(\s*binary\s*\)/i',
'Legacy __sleep() method' => '/function\s+__sleep\s*\(/i',
'Legacy __wakeup() method' => '/function\s+__wakeup\s*\(/i',
'Possible reserved match declaration' => '/\b(?:class|interface|trait|function)\s+match\b/i',
'Possible reserved mixed declaration' => '/\b(?:class|interface|trait|function)\s+mixed\b/i',
'Possible loose comparison' => '/(?<![=!])==(?!=)|(?<![=!])!=(?!=)/',
'Possible backtick execution' => '/`[^`\r\n]+`/'
);
function collect_php_files($directory, &$php_files) {
$items = scandir($directory);
if ($items === false) {
return;
}
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $directory . DIRECTORY_SEPARATOR . $item;
if (is_dir($path)) {
collect_php_files($path, $php_files);
continue;
}
if (!is_file($path)) {
continue;
}
if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) !== 'php') {
continue;
}
$php_files[] = $path;
}
}
$php_files = array();
collect_php_files($project_directory, $php_files);
sort($php_files);
foreach ($php_files as $php_file) {
$contents = file_get_contents($php_file);
if ($contents === false) {
echo 'Unable to read: ' . $php_file . PHP_EOL;
continue;
}
foreach ($patterns as $label => $pattern) {
if (preg_match($pattern, $contents) !== 1) {
continue;
}
echo $label . PHP_EOL;
echo ' ' . $php_file . PHP_EOL;
}
}
This scanner is deliberately simple. It can produce false positives and it won’t detect every compatibility problem. It also can’t understand the application’s intent.
Its purpose is to reduce the amount of code that must be found manually, not to certify the application as compatible. A tool can point toward a problem, but understanding still belongs to the developer.
Search for Dependencies Hidden Outside PHP Files
Compatibility problems don’t live only inside .php files. Inspect:
- Web server configuration
- .htaccess files
- Shell scripts
- Cron definitions
- System service files
- Deployment scripts
- Container or virtual-machine configuration
- Environment files
- Composer configuration
- JavaScript requests pointing to PHP endpoints
A cron entry may explicitly call an old binary:
/usr/bin/php8.2 /home/example/public_html/cron.php
Changing the website’s PHP-FPM version won’t change that command. The scheduled job may continue using PHP 8.2 until the binary disappears, then fail without affecting the visible website.
This is the kind of failure that makes an application seem haunted when it’s really only undocumented. The cure isn’t superstition. It’s a complete inventory.
Audit the Database Layer Carefully
Database code is one of the most important parts of a legacy PHP upgrade because database failures can damage data while the page appears to continue running.
Don’t stop after searching for mysqli_*, PDO or removed mysql_* calls. Legacy applications often place the real connection and query work behind names such as db_query(), sql_fetch() or a custom database class. Trace those wrappers until you reach the actual PHP extension and confirm what they return on success, no rows and failure. A friendly wrapper name doesn’t make the behavior underneath it compatible.
Inspect how the application:
- Creates the connection
- Selects the character set
- Prepares statements
- Binds parameters
- Checks execution results
- Reads nullable columns
- Handles transactions
- Reports failures
- Retries operations
A connection should establish the intended character set explicitly:
<?php
$db = mysqli_connect(
DB_HOST,
DB_USER,
DB_PASS,
DB_NAME
);
if ($db === false) {
throw new RuntimeException('Unable to connect to the database.');
}
if (mysqli_set_charset($db, 'utf8mb4') === false) {
throw new RuntimeException('Unable to set the database character set.');
}
Don’t rely on whatever default happens to be configured on the server. Defaults are convenient until the day they change and the application begins storing text differently.
Stop Depending on Silent Query Failure
Older code may execute a query and assume the result is valid:
$result = mysqli_query($db, $sql);
$row = mysqli_fetch_assoc($result);
Check the operation:
$result = mysqli_query($db, $sql);
if ($result === false) {
throw new RuntimeException('Unable to execute the database query.');
}
$row = mysqli_fetch_assoc($result);
Production error messages shouldn’t expose raw SQL or credentials to visitors. Log the technical details in a protected location and show the user a safe message.
Inspect Bind Types
Prepared statements still require the application to understand the data being bound.
mysqli_stmt_bind_param(
$stmt,
'isi',
$user_id,
$email_address,
$is_active
);
The bind string should match the intended values. Don’t cast every submitted value until the query stops complaining. Validate first, then convert into the type the application actually permits.
Check Nullable Values
Database fields that contain NULL may reach PHP code that assumes a string:
$display_name = trim($row['display_name']);
Handle the possibility deliberately:
$display_name = '';
if ($row['display_name'] !== null) {
$display_name = trim($row['display_name']);
}
The correct response may be different depending on the field. The important part is that the application makes the decision instead of leaving it to implicit conversion.
Protect Transactions
When several writes form one logical operation, verify that the transaction still behaves correctly under the upgraded runtime.
mysqli_begin_transaction($db);
try {
// Perform related writes.
mysqli_commit($db);
} catch (Throwable $throwable) {
mysqli_rollback($db);
throw $throwable;
}
Test both the successful path and an intentional failure halfway through the operation. A transaction that works only when every query succeeds hasn’t been fully tested.
Audit Sessions and Authentication
A PHP upgrade can expose problems in session handling that are easy to mistake for random logouts.
Verify:
- The session save path exists
- The PHP process can write to it
- The session cookie name hasn’t changed unexpectedly
- The cookie domain and path are correct
- The Secure, HttpOnly and SameSite settings remain appropriate
- The session ID is regenerated after authentication
- The application doesn’t store passwords in the session
- Logout destroys the complete authenticated session
- Idle and absolute expiration still work
Test login through several requests, not only the initial form submission. A login controller may accept the password correctly while the next page fails to load the session. That’s a session persistence problem, not an authentication failure.
Authentication sits close to the front door of the application. When it fails, users don’t care that the rest of the house is in perfect condition because they can’t get inside.
Audit Files, Paths and Uploads
Filesystem behavior often changes when an application moves to a new server configuration, PHP-FPM pool or operating-system package. Inspect every path used by the application.
Prefer paths anchored to the current file:
$storage_directory = __DIR__ . '/data/uploads';
rather than depending on the current working directory:
$storage_directory = 'data/uploads';
The relative version may behave differently when called by a web request, command-line script or scheduled job.
Verify Writable Directories
Before writing a file:
if (is_dir($storage_directory) === false) {
throw new RuntimeException('The storage directory does not exist.');
}
if (is_writable($storage_directory) === false) {
throw new RuntimeException('The storage directory is not writable.');
}
Don’t automatically make every directory world-writable to solve a permission problem. Identify which system user runs PHP and assign the narrowest permissions the application needs.
Recheck Upload Limits
Compare:
- upload_max_filesize
- post_max_size
- max_file_uploads
- memory_limit
- max_execution_time
A form may reject uploads before application code runs if the request exceeds PHP’s limits. The application can’t explain an error it never received.
Verify File Types on the Server
Don’t trust the browser’s reported MIME type.
$file_info = finfo_open(FILEINFO_MIME_TYPE);
if ($file_info === false) {
throw new RuntimeException('Unable to initialize file type detection.');
}
$detected_type = finfo_file(
$file_info,
$_FILES['upload']['tmp_name']
);
finfo_close($file_info);
Confirm that the fileinfo extension exists in the new environment and test the application’s allowlist with real sample files.
Verify Every Required Extension
A PHP application may be compatible with PHP 8.5 while still failing because an extension is missing.
Common dependencies include:
- mysqli
- mbstring
- intl
- curl
- openssl
- fileinfo
- gd
- imagick
- zip
- sodium
Create an explicit requirement check:
<?php
$required_extensions = array(
'mysqli',
'mbstring',
'curl',
'openssl',
'fileinfo',
'json'
);
$missing_extensions = array();
foreach ($required_extensions as $required_extension) {
if (extension_loaded($required_extension)) {
continue;
}
$missing_extensions[] = $required_extension;
}
if (count($missing_extensions) > 0) {
echo 'Missing extensions:' . PHP_EOL;
foreach ($missing_extensions as $missing_extension) {
echo '- ' . $missing_extension . PHP_EOL;
}
exit(1);
}
echo 'All required extensions are available.' . PHP_EOL;
Adapt the list to the application. Don’t require an extension simply because another project commonly uses it and don’t omit one because the application reaches it only through a rarely used feature.
A dependency should be named because the application needs it, not because somebody assumed it would always be there.
Verify Compiled and PECL Extensions Against PHP 8.5
An extension appearing in php -m on the old runtime doesn’t prove that a compatible build exists for PHP 8.5. This matters especially for PECL packages, vendor-provided loaders and extensions compiled locally against a particular PHP API version.
For every non-core extension, record:
- The extension name and installed version
- How it was installed
- Whether the vendor or package has a PHP 8.5-compatible release
- Whether the extension supports the server’s operating system and architecture
- Which configuration file loads it
- Whether both the web and command-line runtimes load the same build
Useful checks include:
php --ri imagick
php --ri redis
php -i | grep '^extension_dir'
Use the extension names the application actually requires. Then repeat the check through the web runtime because a successful command-line load doesn’t prove that PHP-FPM or Apache is using the same extension directory or configuration files.
Install and exercise the target build in staging before switching production. Test the feature that depends on it, not only extension_loaded(). An image extension should process representative images, a cache extension should store and retrieve data and an encoded vendor package should load the application paths that depend on its loader.
Composer Install and Composer Update Aren’t the Same Operation
If the application uses Composer, avoid combining a PHP runtime upgrade with an uncontrolled dependency upgrade.
When a valid composer.lock file exists, this command installs the locked versions:
composer install
This command resolves newer versions permitted by composer.json and rewrites the lock file:
composer update
Those are different changes.
If PHP and every third-party package change during the same deployment, a failure becomes harder to trace. Was the problem caused by PHP 8.5, a new library version, a changed transitive dependency or a Composer script?
Keep the variables separate when possible.
Useful Composer checks include:
composer validate
composer diagnose
composer check-platform-reqs
composer audit
composer outdated
composer check-platform-reqs checks the real PHP version and extensions against the installed packages’ requirements. composer audit examines installed packages for known security advisories and other policy concerns.
A successful Composer check doesn’t prove that the application’s own code works, but it can prevent a deployment that’s missing a required extension or running an incompatible runtime.
Run the Critical Path Before the Full Manual Test Plan
Manual testing becomes more reliable when it follows the way value moves through the application. Begin with the shortest complete journey that proves the system can accept a user, preserve state, perform its central job and close the session cleanly.
For a typical account-based application, the first pass may be:
- Create a new account.
- Complete email verification or activation.
- Log in and confirm the session persists.
- Perform the application’s primary transaction or workflow.
- Confirm the database, filesystem and external side effects.
- Review the error logs for the exact test period.
- Log out and verify protected pages are no longer accessible.
For a store, the central workflow may be product selection, checkout, payment confirmation and order fulfillment. For a content system, it may be login, draft creation, file upload, publication and public retrieval. For an API, it may be authentication, a successful request, a rejected request, a state-changing request and webhook delivery.
Use a known test account and record the expected result for each step before running it. Note the test time, record identifiers and any external message or transaction IDs. That makes it possible to match the browser action with the database row, log entry, email, uploaded file or remote service response it produced.
Once the critical path passes, expand outward to administration, recovery tools, unusual permissions, imports, exports, cron jobs, workers and failure cases. Testing the center first doesn’t excuse the edges. It gives the audit an order that reveals whether the application’s most important promise still holds before time is spent on less common routes.
Test Behavior, Not Only Pages
A page returning HTTP 200 doesn’t prove that the feature worked. For every important operation, verify the result that matters.
Registration
- Was the account created once?
- Was the password stored with the correct hashing method?
- Was the verification message sent?
- Was private information excluded from logs?
Login
- Was the password verified?
- Was the session ID regenerated?
- Did the session persist on the next request?
- Was an inactive account rejected?
- Was the failure message generic?
File Upload
- Was the temporary upload accepted?
- Was the type detected on the server?
- Was the file renamed safely?
- Was it stored in the expected directory?
- Were rejected files actually removed?
Email
- Did the application build the message?
- Did the mail transport accept it?
- Were headers formed correctly?
- Did the message arrive?
- Did links point to the expected host?
Database Changes
- Were the correct rows inserted or updated?
- Were timestamps stored in the expected timezone?
- Were duplicate submissions prevented?
- Did a failed operation roll back?
Testing should observe the database, filesystem, logs and external effects, not only the browser. A green message on the screen may only prove that the application believed its own assumptions.
Test Old and Unusual Data
Newly created test records are often cleaner than the information found in a mature production database.
Older records may contain:
- NULL values where new forms require strings
- Empty dates
- Unexpected encodings
- Long names
- Unusual punctuation
- Emoji
- Duplicate identifiers
- Obsolete status values
- Serialized data from older class definitions
- Numbers stored as strings
An upgrade can appear successful until the first visitor opens a twelve-year-old record. Include representative old data in testing.
The uncomfortable records often teach us more about the application than the perfect ones. They show us where the system has been forgiving, where people found unexpected paths and where time stored decisions that nobody remembered documenting.
Test Failure Paths Deliberately
Applications are usually tested with valid input because successful paths are easier to demonstrate. Upgrade testing must also ask what happens when things go wrong.
Test conditions such as:
- The database connection fails
- A query returns no rows
- A required directory is missing
- A file can’t be written
- An uploaded image is corrupt
- An API request times out
- Email delivery is rejected
- A session expires
- A CSRF token is invalid
- A user submits the same form twice
- A transaction fails halfway through
A system’s reliability isn’t measured only by how it behaves when everything cooperates. It’s also measured by whether failure remains contained, understandable and recoverable.
Storms reveal which branches were already weak. Failure testing gives us a controlled storm before real users have to stand beneath the tree.
Don’t Change Everything in One Deployment
A PHP upgrade is already a meaningful change. Avoid combining it with all of the following unless there’s no practical alternative:
- Operating-system migration
- Database-engine upgrade
- Web-server replacement
- Complete dependency update
- Authentication rewrite
- Template redesign
- URL restructuring
- Hosting-provider change
- Large schema migration
When everything changes together, every failure has too many possible causes. Smaller controlled releases may feel slower, but they usually reach a dependable result sooner because each problem can be isolated.
Speed isn’t measured only by how quickly we begin. Sometimes the fastest way forward is to leave enough footprints that we can tell where we stepped wrong.
Create a Written Deployment Plan
Don’t rely on memory during the production upgrade. Write the sequence before beginning.
A practical plan may look like this:
- Confirm the latest backup completed successfully.
- Verify the restore instructions.
- Record the current PHP version and configuration.
- Place the application into maintenance mode if required.
- Stop scheduled jobs and queue workers.
- Deploy the audited application files.
- Switch the PHP runtime.
- Verify required extensions.
- Run database migrations.
- Clear only the caches that must be rebuilt.
- Run smoke tests.
- Restart scheduled jobs and workers.
- Review error logs.
- Remove maintenance mode.
- Continue monitoring.
Include the exact commands, expected output and person responsible for each step where appropriate. A written plan reduces the pressure to improvise when the clock is moving and production is quiet enough to hear every mistake.
Define the Rollback Point Before Deployment
Rollback shouldn’t be invented after production begins failing. Decide in advance which conditions will stop the deployment.
Examples include:
- Users can’t log in
- Database writes fail
- Payment processing fails
- Uploads stop working
- Error volume exceeds a defined threshold
- Response times become unacceptable
- A required scheduled task can’t run
Then define what rollback means:
- Return the application to maintenance mode.
- Stop workers and scheduled jobs.
- Restore the previous application files.
- Restore the previous runtime.
- Reverse or restore database changes when necessary.
- Restore compatible caches or rebuild them.
- Run the old-version smoke tests.
- Reopen the application only after the baseline is confirmed.
A rollback plan isn’t an admission that the upgrade will fail. It’s the guardrail that allows careful progress without turning one mistake into a prolonged outage.
Monitor the Application After It Opens
Passing a smoke test doesn’t mean the work is finished. Some code paths won’t run until particular users, records or scheduled events reach them.
After deployment, monitor:
- PHP error logs
- Web server error logs
- HTTP 500 responses
- Database errors
- Login failures
- Email failures
- Upload failures
- Queue failures
- Cron results
- Response times
- Memory exhaustion
- User reports
Compare the upgraded application against its earlier baseline. A small increase in warnings, failed logins or abandoned form submissions may reveal a problem before it becomes an obvious outage.
Monitoring isn’t distrust. It’s care that continues after the visible work is complete.
A Practical PHP 8.5 Upgrade Sequence
The following order keeps discovery, repair and deployment separate.
Stage 1: Inventory
- Record PHP versions and configuration
- List extensions
- Map routes and scripts
- Document external services
- Identify scheduled jobs
Stage 2: Preserve
- Back up files
- Back up the database
- Preserve configuration
- Record checksums where useful
- Test restoration
Stage 3: Reproduce
- Create staging
- Match the production environment
- Sanitize production-derived data
- Verify permissions and paths
Stage 4: Discover
- Enable complete error reporting on the current and target runtimes
- Tail and compare the error logs during controlled test passes
- Run syntax checks
- Run pattern scans and review dynamic properties, loose comparisons and reserved names
- Review every migration guide in the upgrade path
- Exercise every major route
Stage 5: Repair
- Replace removed functionality
- Resolve warnings and deprecations
- Make type expectations explicit
- Repair database error handling
- Correct filesystem assumptions
Stage 6: Verify
- Run the complete critical path
- Test successful behavior
- Test failure behavior
- Test old records
- Test cron and command-line scripts
- Verify Composer, compiled extensions and PECL packages
Stage 7: Deploy
- Follow the written deployment plan
- Run smoke tests
- Monitor logs
- Keep rollback available
Stage 8: Remove Temporary Compatibility Work
- Remove temporary diagnostics
- Remove obsolete fallbacks
- Update documentation
- Record the new baseline
- Schedule the next review
Final PHP 8.5 Readiness Checklist
- The current web and command-line PHP versions are documented
- Every scheduled job’s PHP binary is known
- Required extensions are listed and verified
- The complete application has been backed up
- The database restore process has been tested
- A production-like staging environment exists
- Private production data has been removed or sanitized
- Complete error reporting is enabled in staging
- The application has been exercised under E_ALL on the current runtime
- The current-runtime and PHP 8.5 error logs have been reviewed and compared
- Production errors are logged without being displayed
- Every PHP file passes a syntax check
- Every migration guide in the upgrade path has been reviewed
- Removed and deprecated functions have been searched for
- Dynamic properties have been identified and declared or deliberately justified
- Loose comparisons and numeric-string assumptions have been reviewed
- Reserved names such as match and mixed have been searched for
- Calls that may now throw TypeError, ValueError or other errors have been tested
- PHP 8.5 deprecations have been audited
- Undefined variables and array keys have been repaired
- Null values are handled intentionally
- Database failures are checked
- Database character encoding is configured explicitly
- Prepared statement bind types have been reviewed
- Transactions have been tested under failure
- Session persistence works across requests
- Authentication and logout have been tested
- File paths don’t depend on an accidental working directory
- Upload directories have correct ownership and permissions
- Server-side file type detection works
- Composer platform requirements pass
- Composer security auditing has been reviewed
- The dependency lock file is preserved unless an update is intentional
- Every compiled or PECL extension has a verified PHP 8.5-compatible build
- The critical user path has been completed from beginning to end
- Public pages and administrative pages have been tested
- API and webhook responses retain their expected structure
- Email delivery has been verified beyond the success message
- Cron jobs and workers run under PHP 8.5
- Old and unusual database records have been tested
- Failure paths have been tested deliberately
- A written deployment plan exists
- Rollback conditions are defined
- The rollback process has been rehearsed
- Post-deployment monitoring is ready
The Goal Is Understanding, Not Merely Compatibility
An application can be made to stop displaying warnings without becoming easier to understand. That isn’t enough.
The deeper value of a PHP upgrade is the opportunity to remove assumptions that have been hiding inside the system. When we repair an undefined array key, we’re deciding which data the application requires. When we handle NULL deliberately, we’re defining what absence means. When we verify an extension, we’re documenting an environmental dependency. When we test rollback, we’re admitting that dependable software includes a path back from human error.
The newer PHP runtime is useful partly because it forces these decisions into the light. A good upgrade doesn’t erase the history of the application. It carries the useful parts of that history forward with clearer boundaries, safer behavior and a stronger foundation for the person who will maintain it next.
That person may be another developer years from now. It may also be you.
Take the time to leave the system more understandable than you found it. The application has already carried its users this far, and a careful upgrade helps make certain it can continue carrying them without asking them to bear the cost of our haste.
That’s just my 2 cents.
Official References
- PHP supported versions
- PHP 8.0 backward-incompatible changes
- PHP object properties and dynamic-property guidance
- PHP 8.5 migration guide
- PHP 8.5 deprecated features
- PHP 8.5 backward-incompatible changes
- Composer basic usage and lock-file behavior
- Composer command-line documentation