Framework-Agnostic PHP Upgrade Strategies
Upgrading a legacy PHP application built without frameworks like Laravel or Symfony presents unique challenges. Without the guardrails and tested upgrade paths that frameworks provide, you must determine which coding patterns remain valid and which cause breaking changes. PHP evolves continuously—what worked reliably for years can suddenly produce errors, security vulnerabilities, or performance issues in newer versions. This guide provides a systematic approach using tools like Rector, PHPStan, and PHPUnit to navigate these upgrades safely, mapping the reliable paths while warning of the breaking changes ahead.
The core strategy we’ll outline here isn’t about massive, risky rewrites. It’s a methodical, test-driven approach that builds a safety net first, then leverages powerful automation tools like Rector, PHPStan, and PHPUnit to handle the heavy lifting. By following this process, you can confidently bring your framework-agnostic application into modern PHP versions, unlocking performance gains, security patches, and access to contemporary tooling—all while maintaining stability for your users.
Understanding the Upgrade Challenge
Before we dive into the tools and processes, let’s examine why upgrading legacy PHP applications presents unique challenges compared to framework-based projects—and why those challenges are entirely manageable with the right approach. We’ll start by understanding the framework-agnostic reality, then examine why upgrades matter beyond just security concerns.
The Framework-Agnostic Reality
Many PHP applications in production today grew organically over years or even decades. They might be custom content management systems, internal business tools, or specialized e-commerce platforms built by teams who prioritized functionality over architectural patterns. These applications often exhibit:
- Direct usage of deprecated PHP functions that frameworks would have abstracted away
- Custom autoloaders that may conflict with modern standards
- Tightly coupled business logic spread across procedural scripts
- Missing automated tests because the focus was delivery, not verification
- Dependencies on older PHP extensions that may have been replaced
A framework like Laravel or Symfony provides structure: conventions for organization, abstractions for common operations, and tested upgrade paths. Without that structure, you bear more responsibility for ensuring compatibility.
Of course, this doesn’t mean framework-agnostic applications are inferior. Many serve their purposes admirably and efficiently. But when it comes time to upgrade—whether for security compliance, performance improvements, or access to modern language features like union types or attributes—the lack of framework guidance means you need your own systematic approach.
Why This Matters: Beyond Security
It’s tempting to think “if it isn’t broken, don’t fix it.” However, running on unsupported PHP versions carries real risks:
- Security vulnerabilities: Unsupported versions no longer receive patches. According to PHP’s support policy, each major version receives active support for about two years and security fixes for an additional year. Running on PHP 7.4 or earlier means you’re exposed to known vulnerabilities with no official fixes.
- Performance improvements: PHP 8.x brought substantial performance gains through optimizations in the engine. Applications often see 30-50% speed improvements simply by upgrading, with no code changes required.
- Composer ecosystem: Modern PHP packages increasingly require PHP 8.0 or higher. Your ability to benefit from community packages diminishes as the ecosystem moves forward.
- Developer productivity: Modern language features like constructor property promotion, named arguments, and match expressions make code more concise and maintainable. Additionally, tools like PHPStan and Psalm provide static analysis that older PHP versions can’t fully leverage.
The question isn’t if you should upgrade—it’s when and how systematically.
The Core Strategy: Safety First
We mentioned earlier that this isn’t about massive rewrites. The cornerstone of our approach is this: build the safety net before you make changes. Specifically:
- Establish verification through testing—even if you have zero tests currently
- Use static analysis to find compatibility issues without running code
- Automate refactoring with tools that understand PHP’s evolution
- Iterate methodically through failures using the feedback loop between tests and fixes
Each of these steps deserves explanation, which we’ll provide in the sections ahead. The key insight is that automation handles the mechanical changes, while your team’s expertise handles the contextual ones. This division of labor makes upgrades feasible even for large, complex applications.
Prerequisites
Before beginning your upgrade journey, ensure you have the following in place:
Required Tools
- Composer (latest version): Dependency management and tool installation
- Git or similar version control: You must be able to roll back changes
- PHP version manager (mise, phpenv, or similar): To switch between PHP versions for testing
- Local development environment: Sufficient resources to run multiple PHP versions
Knowledge Assumptions
This guide assumes you’re familiar with:
- Composer workflows: Installing packages, updating dependencies, managing
composer.json - PHP basics: Understanding of classes, functions, namespaces, and error types
- Command-line operations: Running shell commands, interpreting output, using pipes and redirection
- Testing concepts: What unit tests, integration tests, and functional tests accomplish
If any of these are unfamiliar, we recommend building that foundation first. We’ll note specific resources where relevant.
Target Versions
We’ll structure this guide around upgrading from PHP 7.4 to PHP 8.0+, but the principles apply broadly:
| Source Version | Common Target Path | Notes |
|---|---|---|
| PHP 7.4 | PHP 8.0 → 8.1 → 8.2 | Incremental upgrades are safer than skipping versions |
| PHP 8.0 | PHP 8.1 → 8.2 | Direct upgrade typically feasible |
| PHP 8.1 | PHP 8.2 | Generally straightforward with Rector |
You can often upgrade directly from 7.4 to 8.2, but be prepared: the cumulative breaking changes are significant. If your application is particularly large or complex, consider incremental upgrades (7.4 → 8.0 → 8.1 → 8.2) to isolate issues.
Backup and Rollback Plan
Before touching any code:
- Commit all changes: Ensure your current working state is committed to version control
- Tag the release: Create a tag like
pre-php-upgrade-7.4so you can return precisely - Test rollback: Briefly verify you can actually restore from backup
You shouldn’t need to use it, but having this safety net reduces anxiety and allows bolder, faster progress.
With the prerequisites in place, we can begin the actual upgrade process. The first step is establishing a testing baseline—even if your application currently has zero tests.
We mentioned earlier that tests create a safety net. But what if you don’t have tests? This is common—and not a blocker. The philosophy here is: start where you are, use what you have.
Introducing PHPUnit
PHPUnit has been the standard for PHP testing for over a decade. If you don’t already use it, let’s add it now:
composer require --dev phpunit/phpunit ^10
This installs PHPUnit as a development dependency. You can verify installation:
$ ./vendor/bin/phpunit --version
PHPUnit 10.5.20 by Sebastian Bergmann and contributors.
Note: The exact version number will vary based on when you install. What matters is that it’s a PHPUnit 10.x or later version compatible with your target PHP.
Starting Without 100% Coverage
Don’t feel pressured to achieve 100% test coverage before upgrading. That’s a separate initiative. Instead, focus on critical paths:
- User authentication and authorization flows
- Core business logic (calculations, state transitions, validations)
- Data persistence operations (database writes, external API calls)
- Public-facing endpoints or high-traffic code
Here’s an example of what a functional test for a legacy authentication function might look like:
<?php
// tests/Auth/LoginTest.php
use PHPUnit\Framework\TestCase;
class LoginTest extends TestCase
{
public function testValidCredentialsAuthenticateSuccessfully(): void
{
// Arrange: set up test user in database or mock
$username = 'testuser';
$password = 'correctpassword';
// Act: attempt login
$result = login_user($username, $password);
// Assert: authentication succeeded
$this->assertTrue($result['success']);
$this->assertArrayHasKey('user_id', $result);
}
public function testInvalidPasswordReturnsError(): void
{
$username = 'testuser';
$password = 'wrongpassword';
$result = login_user($username, $password);
$this->assertFalse($result['success']);
$this->assertEquals('Invalid credentials', $result['error']);
}
}
This example assumes you have a login_user() function that your application already uses. If your authentication is spread across multiple files or mixed with presentation logic, you may need to refactor slightly to make it testable—that’s a typical part of the upgrade process. Write tests against the current behavior, not an ideal design.
Running Tests on Current PHP
Before proceeding, run your test suite on your current PHP version:
$ ./vendor/bin/phpunit
PHPUnit 10.5.20 by Sebastian Bergmann and contributors.
... 3 / 3 (100%)
Time: 00:00.125, Memory: 6.00 MB
OK (3 tests, 12 assertions)
If you have no tests yet, this will produce an empty result. That’s fine—you’ve established your baseline: zero passing tests. When you add tests later, you’ll see them pass on current PHP before upgrading.
The Iterative Test Addition Pattern
We recommend an iterative approach: write a few tests for one module, get them passing on current PHP, then move to the next module. This creates pockets of safety that expand over time. You don’t need perfect coverage upfront; you need enough coverage that when Rector makes changes, you’ll notice breakages in areas you care about.
Step 2: Static Analysis for Early Detection
Now that we have a test suite (even if partial), let’s add another safety layer: PHPStan. Unlike tests—which verify behavior by running code—PHPStan analyzes code statically, finding type errors, undefined variables, incorrect method calls, and deprecations without executing a single line. Psalm is another strong option for static analysis; both tools have similar capabilities, though PHPStan tends to have stricter defaults out of the box. The principles we cover apply equally to either tool.
Why Static Analysis Before Dynamic Changes
Consider this scenario: you have a function that calls mysql_connect()—a function removed in PHP 7.0. Running your test suite on PHP 8.0 would certainly catch this (the test would fail), but PHPStan can catch it immediately, without running tests, without even switching PHP versions. It reads the code and knows that mysql_connect() doesn’t exist in your target version.
This early detection is valuable because:
- It’s fast: Analysis often completes in seconds or minutes
- It’s comprehensive: PHPStan checks every file, every branch
- It’s precise: It tells you the exact file and line where incompatible code exists
- It finds issues tests might miss: If you don’t have tests for that code path, PHPStan still reports it
Installing and Configuring PHPStan
composer require --dev phpstan/phpstan
PHPStan’s configuration lives in phpstan.neon:
# phpstan.neon
parameters:
level: max
paths:
- src
- lib
- app
# Adjust these based on where your code lives
# If you have tests that PHPStan should also analyze:
# - tests
# Exclude specific directories:
# excludePaths:
# - src/ThirdParty
# If PHPStan reports too many errors in vendor:
ignoreErrors:
- '#Call to an undefined method [a-zA-Z0-9\\_]+::[a-zA-Z0-9\\_]+#'
# Maximum number of issues reported (0 = unlimited)
# reportUnmatchedIgnoredErrors: false
The level: max setting tells PHPStan to use the strictest rules available for your target PHP version. For PHP 8.2, level max includes checks for union types, readonly properties, and other modern features that may not exist in your legacy code.
Running Your First Analysis
$ ./vendor/bin/phpstan analyse
20:45:35 PHPStan 1.10.35 - analysing 47 files - no cache
12 errors 100%
------ -----------------------------------------------------------------------
1) App/Controller/UserController.php
Calling an undefined method App\Model\User::getFullName().
2) App/Model/Order.php
Typed property App\Model\Order::$total must be float|int, float given.
3) App/Util/DeprecatedFunctions.php
Function mysql_connect() is deprecated in PHP 7.0 and removed in PHP 8.0.
...
------ -----------------------------------------------------------------------
You also may notice that PHPStan reports issues beyond PHP version compatibility—general type safety and code quality problems appear as well. That’s intentional. Improving overall code health reduces the likelihood of unexpected issues during the upgrade. Notice how PHPStan provides the exact file and line number for each problem; this precision allows you to address issues systematically.
Addressing PHPStan Errors: Progressive Cleanup
Your initial run will likely produce many errors. Don’t panic—this is expected. Here’s a pragmatic approach:
-
Categorize errors: Identify which are:
- Critical: Code that won’t run at all on target PHP (removed functions, syntax errors)
- Important: Type mismatches that could cause runtime errors
- Informational: Style issues, missing docblocks
-
Fix the critical errors first. Use PHPStan’s output to locate the exact lines:
// Before (in App/Util/DeprecatedFunctions.php):
$link = mysql_connect($host, $user, $pass);
mysql_select_db($database, $link);
// After:
$link = new mysqli($host, $user, $pass, $database);
if ($link->connect_error) {
die('Connection failed: ' . $link->connect_error);
}
The mysql_* functions were removed entirely in PHP 7.0, so this isn’t just a deprecation warning—it’s a breaking change that will cause fatal errors.
- Iterate: Run PHPStan again after each batch of fixes. You’ll see the error count shrink.
You may encounter PHPStan errors that don’t seem to break the current code. PHPStan is conservative—it alerts you to potential issues even if they haven’t manifested yet. Addressing all type-related errors is recommended, as they often signal patterns that will cause problems when combined with Rector’s automated changes.
PHPStan Configuration for Upgrades
If you’re specifically targeting PHP version compatibility, you might create a separate PHPStan config that extends your baseline:
# phpstan.upgrade.neon
includes:
- phpstan.neon
parameters:
# This tells PHPStan to check compatibility with PHP 8.2
phpVersion: 80200
Then:
./vendor/bin/phpstan analyse -c phpstan.upgrade.neon
This focuses PHPStan’s checks specifically on version-related issues.
Step 3: Automate Refactoring with Rector
By now, you’ve established a test suite and addressed the low-hanging fruit from static analysis. It’s time for the most powerful tool in your upgrade arsenal: Rector.
Rector is an automated refactoring tool that understands the evolution of PHP. It knows that create_function() was removed, that constructor promotion exists in PHP 8.0, that $this in closures requires explicit binding in PHP 8.0, and hundreds of other changes. It can transform thousands of lines of code in seconds. While manual refactoring and IDE-based transformations are possible, Rector’s rule-based approach is both repeatable and comprehensive—something that matters especially for larger codebases.
Installing Rector
composer require --dev rector/rector
Rector Configuration: Targeting Your PHP Version
Rector’s configuration lives in rector.php:
<?php
// rector.php
use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\LevelSetList;
use Rector\Set\ValueObject\SetList;
return static function (RectorConfig $rectorConfig): void {
// Paths to process
$rectorConfig->paths([
__DIR__ . '/src',
__DIR__ . '/app',
__DIR__ . '/lib',
]);
// Define what rule sets will be applied
$rectorConfig->sets([
SetList::PHP_80, // Use this if upgrading to PHP 8.0
// SetList::PHP_81, // Use this if upgrading to PHP 8.1
// SetList::PHP_82, // Use this if upgrading to PHP 8.2
// Or use a comprehensive set:
// SetList::PHP_80,
// SetList::PHP_81,
// SetList::PHP_82,
]);
// Optional: specify the PHP version Rector should assume for your codebase
// $rectorConfig->phpVersion(70400); // If coming from PHP 7.4
};
Important: Start with the rule set for your target version. Rector will transform code to be compatible with that version. You can also apply rule sets incrementally (run PHP_80 rules, test, then PHP_81 rules).
The Dry Run: Preview Before You Commit
Never run Rector blindly on your codebase. Always start with a dry run:
$ ./vendor/bin/rector process src --dry-run
Rector will analyze your code and print a summary of changes without modifying any files. Notice the line “12/47 files changed”—this immediately tells you the scope of the transformation. If Rector reports zero changes, you may need to adjust your configuration or target PHP version.
If you want to see the specific changes, use --dry-run --verbose or even --dry-run -vvv:
$ ./vendor/bin/rector process src --dry-run --verbose
This shows ~~~ Before and +++ After for each file Rector plans to modify. Review these diffs carefully to ensure the transformations align with your expectations. Pay particular attention to any changes in business logic or edge cases where Rector might alter semantics.
Common Rector Transformations
Here are examples of what Rector might do:
Constructor property promotion (PHP 8.0):
// Before:
class User
{
private string $name;
private int $age;
public function __construct(string $name, int $age)
{
$this->name = $name;
$this->age = $age;
}
}
// After Rector with PHP_80 set:
class User
{
public function __construct(
private string $name,
private int $age,
) {}
}
Union type replacements (PHP 8.0):
// Before (PHP 7.4 docblock):
/**
* @param string|int $value
*/
function process($value) { /* ... */ }
// After (PHP 8.0 union type):
function process(string|int $value) { /* ... */ }
$this in closures (PHP 8.0):
// Before:
$callback = function() {
return $this->doSomething();
};
// After:
$callback = function() {
return $this->doSomething();
}->bindTo($this, self::class);
Executing the Refactoring
Once you’ve reviewed the dry run and understand the changes, run Rector for real:
$ ./vendor/bin/rector process src
Rector will modify files in place. Commit these changes before proceeding:
git add src/
git commit -m "Apply Rector PHP 8.0 transformations"
Limitations of Automation
Rector is remarkably powerful, but it doesn’t catch everything. It won’t:
- Fix logical errors that happen to work on old PHP but break on new PHP (e.g., relying on loose comparison quirks)
- Update your dependencies’ usage patterns unless they’re in your codebase
- Handle edge cases involving reflection or dynamic code that Rector can’t analyze
That’s where your test suite comes in. Rector handles the mechanical transformations; your tests verify that behavior remains correct.
Step 4: The Upgrade-Test-Fix Cycle
We’ve prepared the groundwork: safety net in place, static analysis cleanish, automated refactoring applied. Now we reach the critical moment—switching the PHP version itself. This is where we discover what our safety nets caught and what manual intervention remains. The test suite will reveal behavioral differences; together we’ll iterate through failures until the application runs cleanly on the target PHP version.
Switching PHP Versions
How you switch depends on your setup. If you use mise (formerly asdf):
mise use 8.2
Or if you use phpenv:
phpenv install 8.2.0 # if not already installed
phpenv global 8.2.0 # or 'local' for project-specific
Or if you use Docker, update your docker-compose.yml:
services:
php:
image: php:8.2-cli
Or if you’re on a system with multiple PHP versions installed:
sudo update-alternatives --set php /usr/bin/php8.2
Verify:
$ php -v
PHP 8.2.0 (cli) (built: Dec 8 2021 12:00:00) ...
Updating Dependencies
Now update your Composer dependencies so they’re compatible with the new PHP version:
$ composer update
Composer will read your composer.json and install versions compatible with your current PHP. You may see some packages upgrade automatically to newer versions that support PHP 8.2. Don’t be alarmed if Composer wants to make substantial changes—this is expected.
If you encounter dependency conflicts, you may need to adjust version constraints or investigate specific packages that haven’t released PHP 8.2-compatible versions yet. For critical packages without upgrades, you have options:
- Temporarily lock that package to an older version that still works on PHP 8.2 (some older code runs fine)
- Fork and patch the package yourself
- Find an alternative package
Running Tests: The Moment of Truth
Now, run your test suite:
$ ./vendor/bin/phpunit
PHPUnit 10.5.20 by Sebastian Bergmann and contributors.
F.F.F. 5 / 15 (33%)
Time: 00:00.461, Memory: 12.00 MB
There were 10 failures:
...
This is expected. Rector handled the mechanical changes, but behavioral differences remain. Let’s talk about the typical failure patterns you’ll encounter and how to fix them.
Failure Pattern 1: Parameter/Return Type Mismatches
PHP 8.x enforces type declarations more strictly. Code that previously accepted or returned incorrect types may now throw TypeError.
// Legacy code that worked on PHP 7.4 due to coercion:
function processItems(array $items) {
foreach ($items as $item) {
// $item might be string, int, object—PHP 7.4 would coerce
echo strlen($item); // Works if $item is string, coerces int to string
}
}
// On PHP 8.0 with strict types: TypeError if non-string
Fix: Add explicit type handling:
function processItems(array $items): void {
foreach ($items as $items) {
$item = (string) $item; // Explicit cast
// or:
// if (!is_string($item)) { continue; }
echo strlen($item);
}
}
Failure Pattern 2: Changed Function Signatures
Functions that changed parameter types or return types between versions.
Example: count() on non-countables in PHP 8.1+ now throws TypeError:
// Before (PHP 7.4): returns 0 for non-countables (array, Countable)
$count = count(null); // returns 0
// After (PHP 8.1+): TypeError
// TypeError: count(): Argument #1 ($value) must be of type Countable|array, null given
Fix: Defensive checking:
$count = is_countable($value) ? count($value) : 0;
Failure Pattern 3: Backward-Incompatible Changes
Some PHP changes are truly breaking and require manual intervention. For instance, the each() function was removed in PHP 7.2:
// This code would have worked on PHP 5.x:
while (list($key, $value) = each($array)) {
// ...
}
Fix: Use foreach:
foreach ($array as $key => $value) {
// ...
}
Rector should have already transformed each() calls to foreach, but if it missed them (perhaps the array was dynamic or passed through multiple layers), you’ll need to fix manually.
Failure Pattern 4: String/Array Offset Access
PHP 8.0 changed error handling for string/array offset access:
$str = "hello";
echo $str[5]; // PHP 7.4: notice (silently returns null)
// PHP 8.0+: warning and returns null
The fix is usually to add bounds checking:
$str = "hello";
$char = isset($str[5]) ? $str[5] : '';
The Iterative Cycle
Your workflow now becomes:
- Run tests:
./vendor/bin/phpunit - Identify a failing test: Read the error message carefully
- Locate and understand the code: Use stack traces and file references
- Fix the issue: Apply the minimal change that makes sense
- Run tests again: Verify the fix and ensure no regressions
- Repeat until all tests pass
This cycle is meditative. You’ll fall into a rhythm: run tests, fix one test, run tests, fix another. The feedback loop is constant and satisfying.
Of course, you may encounter issues that aren’t captured by your tests. That’s why your test suite should include manual spot-checks of critical user journeys. After your automated tests pass, go through the application manually:
- Log in as different user roles
- Perform core business operations (create orders, generate reports, etc.)
- Test edge cases you know exist
These manual checks catch what automated tests missed.
Verification and Testing
We’ve embedded testing throughout the process, but let’s formalize how you verify upgrade completeness.
PHPUnit: Behavioral Verification
Once your test suite passes consistently, you’ve achieved behavioral parity on your supported paths. This is the primary success criterion.
$ ./vendor/bin/phpunit --coverage-text
Time: 00:00.871, Memory: 14.00 MB
Code Coverage Report:
2024-03-16 20:51:32
src/Controller/UserController.php: 95.2%
src/Model/Order.php: 87.5%
src/Service/PaymentProcessor.php: 100%
Averages:
Total: 92.3% (312 / 338 lines)
If coverage is low (<60%), consider whether you have enough confidence. Coverage isn’t everything, but low coverage means large portions of your code haven’t been executed in tests—meaning you’re upgrading blindly in those areas.
PHPStan: Static Verification
Run PHPStan again on the upgraded codebase, targeting your PHP version:
$ ./vendor/bin/phpstan analyse -c phpstan.upgrade.neon
Now PHPStan should report zero errors related to version compatibility. It may still report general code quality issues (missing return types, array shape mismatches), but those are separate improvements you can make incrementally.
Manual Sanity Checks
Here’s a checklist of manual verification:
- Login/logout flows work without errors
- Core business operations (e.g., creating orders, uploading files) complete successfully
- Database queries return expected results (no silent failures)
- External API integrations function properly
- Scheduled cron jobs run without errors
- Email sending works (if applicable)
- File uploads/downloads work
- Search functionality returns results
- Admin interfaces load and function
You also may want to check performance:
# Use ab (ApacheBench) or wrk to compare response times before/after
ab -n 1000 -c 10 http://localhost:8000/endpoint
Look for significant regressions. PHP 8.x typically improves performance, but changes in your code (e.g., increased use of type checks, different algorithmic behavior) could affect it.
Regression Testing in Staging
If you have a staging environment that mirrors production:
- Deploy the upgraded code to staging
- Run integration tests (if you have them) against staging
- Load test if possible
- Do a full manual QA pass
The staging environment catches configuration issues (different PHP extensions, different database versions, different file permissions) that your local environment might not reveal.
Rollback Verification
Before declaring victory, verify that rolling back works:
# If on main branch:
git checkout main # or your deploy branch
git reset --hard pre-php-upgrade-7.4
# Revert Composer changes:
composer install # Restores previous composer.lock
# Switch PHP back:
mise use 7.4
Run a quick sanity check on the old version to ensure everything works as before. This confirms your backup strategy is sound.
Troubleshooting
Even with careful preparation, you’ll likely encounter issues. Here are common problems and solutions.
”Class not found” After Rector
Symptom: Tests or application fails with Class 'Foo\Bar' not found.
Cause: Rector may have changed namespaces or moved classes without updating all references. It’s also possible Composer’s autoloader needs regeneration.
Fix: First, clear the autoloader:
composer dump-autoload -o
If the issue persists, check if Rector moved a class to a different namespace. Use grep to find the new location:
grep -r "class Bar" src/
Then check which code is trying to use the old namespace. If Rector missed an update, you may need to fix it manually or adjust Rector rules to handle that specific pattern.
”Cannot redeclare function” Errors
Symptom: Fatal error: Cannot redeclare some_function()
Cause: Rector sometimes introduces duplicate method declarations when dealing with traits or conditional declarations. Or your codebase had conditional function definitions (e.g., if (!function_exists())) that now conflict with Rector-generated code.
Fix: Search for where the function is defined multiple times:
grep -r "function some_function" src/
Remove the duplicate or conditional wrapper. Note that truly conditional function definitions are generally an anti-pattern; use class methods instead.
Tests Fail Only on PHP 8.x But Not on 7.4
Symptom: Tests that pass on old PHP fail on new PHP, even after Rector.
Cause: Rector handles known transformations, but not every behavioral difference. Common issues:
- Removed extensions: ext/mysql, ext/ereg removed—your code may conditionally use these
- Changed error reporting: PHP 8.x converts many notices to warnings or exceptions
- Different default timezone handling
- Changed numeric string behavior
Fix: Run PHPStan on the upgraded code with phpVersion set—it may catch issues your tests missed. Also, enable detailed error reporting during testing:
php -d error_reporting=E_ALL -d display_errors=1 vendor/bin/phpunit
Look at the first failing test and examine what specifically changed. Use var_dump() or debug output if needed to understand what values are being passed.
Rector Changes Something Unexpected
Symptom: After Rector, code behaves differently even though surface syntax looks similar.
Cause: Rector can change semantics subtly. Example: list() assignments with destructuring may behave differently with non-arrays. Rector may have transformed list($a, $b) = $obj; to [$a, $b] = $obj;, which throws if $obj isn’t array/Traversable.
Fix: Review Rector changes carefully with --dry-run --verbose. If you see transformations you don’t want, you can:
- Exclude specific files or directories in
rector.php - Skip specific rule sets
- Create custom Rector rules to handle edge cases
Composer Fails with Platform Requirements
Symptom: composer update fails with Your requirements could not be resolved to an installable set of packages.
Cause: Some dependencies require PHP versions or extensions that your target PHP doesn’t have.
Fix: Read the error carefully. Composer typically lists which package caused the conflict:
Problem 1
-_root-project 1.0.0 requires php ^7.4|^8.0 -> your PHP version (8.2.0) does not satisfy that requirement.
This means the package explicitly doesn’t support PHP 8.2. Options:
- Wait for package maintainer to release update
- Use an older version of the package that still works
- Fork and patch the package yourself
- Find an alternative package
”Uncaught Error: Call to undefined function” for Built-ins
Symptom: Application crashes calling a function that definitely exists in your PHP version.
Cause: Missing PHP extension. The function is part of an extension that wasn’t installed.
Fix: Check php -m for available modules. Install the missing extension:
# Ubuntu/Debian
sudo apt install php8.2-xml # for DOMDocument, SimpleXML, etc.
sudo apt install php8.2-mbstring
sudo apt install php8.2-curl
# macOS with Homebrew
brew install php@8.2
# Extensions are typically included, but some are separate
# Verify:
php -m | grep xml
Performance Regression After Upgrade
Symptom: Application runs slower on PHP 8.x than on 7.4.
Cause: While PHP 8.x is typically faster, certain patterns can regress:
- Heavy use of JIT-unfriendly dynamic features
- Changes in how
matchor union types execute - Dependency on extensions that are slower in the new version
Fix:
- Profile with Xdebug or Blackfire to identify hot paths
- Look for excessive type checks or error suppression that PHP 8.x introduced
- Enable OPcache if not already enabled (should be)
- Consider enabling JIT in PHP 8.0+:
; php.ini
opcache.enable=1
opcache.jit=1205
opcache.jit_buffer_size=100M
JIT can provide substantial speedups for CPU-bound code, but may not help I/O-bound web apps.
Tests Fail But Application Works
Symptom: Your manual testing indicates the application works fine, but some PHPUnit tests fail.
Cause: Tests may be too tightly coupled to implementation details, or they test edge cases that don’t occur in real usage. This is actually a good outcome—the tests revealed fragility you didn’t know existed.
Fix: Evaluate whether the test is correct. If the test asserts something that changed legitimately (e.g., a deprecated function was removed and replaced), update the test to assert new behavior. If the test is checking something that shouldn’t matter, consider rewriting it to test actual outcomes rather than implementation.
Conclusion: The Path Forward
Upgrading a legacy, framework-agnostic PHP application isn’t a one-time event; it’s a process that combines tooling, testing, and iterative refinement. You’ve now seen the complete panorama: establishing a test baseline, using PHPStan to surface compatibility issues, applying Rector for automated refactoring, and navigating the upgrade-test-fix cycle.
But this conclusion marks not an ending, but a continuation. The skills and tools you’ve adopted—PHPUnit for behavioral verification, PHPStan for static analysis, Rector for safe automation—aren’t just for upgrades. They’re tools for ongoing maintenance. With this foundation, you can:
- Keep dependencies current: Run PHPStan and tests regularly as you update packages
- Refactor safely: Use Rector for other modernizations (e.g., converting to readonly properties when targeting PHP 8.2+)
- Maintain confidence: Your test suite grows with the application, protecting against regressions
The journey through PHP upgrades mirrors the elephant’s memory: each version upgrade passes accumulated knowledge from one generation of code to the next. By documenting what you learn—sharing with your team, writing internal runbooks, contributing to community resources—you contribute to that collective memory.
If you found this guide useful, explore other topics in PHP upgrade strategies: dependency management, framework migrations from Laravel or Symfony, and deployment strategies that minimize downtime. The path forward is clear, and you’re equipped to walk it.
Sponsored by Durable Programming
Need help with your PHP application? Durable Programming specializes in maintaining, upgrading, and securing PHP applications.
Hire Durable Programming