Your Essential PHP 8.4 Migration Checklist: A Step-by-Step Guide
In the 1970s, as computing hardware became more accessible, many organizations maintained large FORTRAN and COBOL codebases that had been developed over years or decades. When new programming paradigms emerged—structured programming, then object-oriented programming—these organizations faced a persistent challenge: how to modernize without disrupting critical operations. The solution was rarely a “big bang” rewrite; instead, they adopted incremental migration strategies, running old and new systems in parallel, testing thoroughly at each step, and planning rollback procedures.
PHP applications face a similar challenge today. PHP 8.4, released on November 21, 2025, continues the language’s evolution with new features, performance improvements, and stricter type safety. For many teams, migration isn’t just about adopting new syntax; it’s about maintaining security, performance, and long-term maintainability—the same concerns that faced those early computing teams decades ago.
This guide walks you through a systematic migration process. We’ll examine the changes that most often affect existing codebases and provide concrete steps to ensure your transition goes as smoothly as possible. By the end, you should have a clear roadmap for bringing your application up to date.
Before we dive into the specifics, let’s establish what makes PHP 8.4 migration distinct from previous upgrades like PHP 7.4 to 8.0 or 8.1 to 8.2. The primary focus areas are:
- Extension reorganization (several extensions moved to PECL)
- Stricter parameter type handling
- New built-in functions that may cause name conflicts
- Enhanced password hashing defaults
Understanding these categories helps you prioritize your testing efforts. Now, let’s walk through the migration process together.
Phase 1: Preparation and Planning
A successful migration starts with a solid plan. In our experience, teams that skip these foundational steps often encounter avoidable setbacks. Let’s walk through the essential preparations together.
Tip: Before we begin—make sure you have a clear understanding of your production environment’s current PHP version and configuration. This context will help you assess migration complexity accurately.
-
[ ] Create a Full Backup: Before you begin, create a complete backup of your codebase and database. This is a critical safety net—should anything go wrong, you should be able to restore quickly. We generally recommend testing your backup restoration process to verify it works properly.
-
[ ] Review the Official Documentation: Familiarize yourself with the official PHP 8.4 release notes and upgrading guide on PHP.net. The PHP team typically maintains detailed documentation of all changes, including migration guides from previous versions. Generally, you’ll want to focus on:
- Backward incompatible changes (typically 15-20 significant changes in a major release)
- Deprecated features you might be using
- New features you may want to adopt
-
[ ] Update Your Dependencies: Ensure your
composer.jsondependencies are compatible with PHP 8.4. Runcomposer updatein a separate branch to identify and resolve any package conflicts early. Of course, not all packages support PHP 8.4 immediately after release—you may need to wait for updates or consider alternatives. Typically, packages maintained by active communities update within a few months of a PHP release.You might wonder: what if a critical dependency doesn’t support PHP 8.4 yet? In that case, you have several options: you can fork the package and add compatibility yourself (though this adds maintenance burden), you can seek alternative packages with similar functionality, or you can delay your migration until support is available. The right choice depends on your specific situation.
-
[ ] Set Up a Staging Environment: Create a staging or testing environment that mirrors your production setup but runs on PHP 8.4. This allows you to test changes thoroughly without impacting live users. Ideally, your staging environment should use production-like data volumes and configurations. Though setting up a perfect clone can be time-consuming—the investment typically pays off when you catch issues before they affect users.
-
[ ] Use Static Analysis: Run static analysis tools like PHPStan or Psalm with a PHP 8.4 ruleset. They will typically catch many potential issues and deprecations before you even run your application. Both tools are excellent choices—PHPStan is often preferred for its strictness, while Psalm offers a slightly more gradual learning curve. You could also use both if you want multiple perspectives.
# Example: Running PHPStan with a specific PHP version
vendor/bin/phpstan analyse --configuration=phpstan.neon --php-version=80400
Tip: If you’re using Psalm, you can configure it similarly with
--php-version=8.4. Consider setting up these checks in your CI pipeline early so you can monitor compatibility as you work.
Phase 2: Addressing Breaking Changes and Deprecations
This phase tackles the core technical challenges of the migration. Work through these items in your staging environment. Though the list may seem daunting, many changes are straightforward to address once you understand them.
Tip: We typically recommend addressing these items in the order presented—as some changes (like extension handling) may reveal additional issues during testing.
Handling Extensions Moved to PECL
Several extensions have been unbundled from the PHP core and moved to PECL. This change reflects PHP’s move toward a more modular core—removing rarely-used extensions reduces the default footprint and allows independent development cycles. Typically, you’ll encounter these extensions:
- IMAP: Used for email integration
- Pspell: For spell-checking functionality
- OCI8: Oracle database connectivity
- PDO-OCI: Oracle PDO driver
If your application relies on any of these, you’ll need to install them manually via PECL. Most extensions follow a similar pattern:
# Example: Installing the IMAP extension
pecl install imap
Then, add extension=imap.so to your php.ini file. You may also need to install system dependencies first—for IMAP, that typically means installing libimap or c-client libraries via your system package manager. Repeat this process for other required extensions.
One may wonder: what if I’m not sure whether my application uses any of these extensions? The answer is straightforward: check your phpinfo() output or run php -m on your current production server to see all loaded extensions. You could also search your codebase for extension-specific functions (e.g., imap_*, ocilogon, etc.).
Fixing Implicitly Nullable Parameter Deprecations
Starting in PHP 8.4, passing null to a function or method parameter that is not explicitly marked as nullable triggers a deprecation notice. This change essentially encourages explicit type declarations—something the PHP team has been gradually tightening across recent versions.
Before (PHP 8.3 and earlier):
function greet(string $name) {
echo "Hello, $name!";
}
// This worked but was problematic:
greet(null); // Would output "Hello, !" or trigger a warning in strict mode
After (PHP 8.4+):
function greet(?string $name) {
if ($name === null) {
echo "Hello!";
} else {
echo "Hello, $name!";
}
}
The fix is to explicitly mark parameters that accept null with the ? nullable type hint. Though this seems like extra work, it makes your intentions clear to both the PHP engine and other developers reading your code.
What about legacy code with many such calls? You don’t need to fix every instance immediately. These are deprecations, not fatal errors—your code will still run in PHP 8.4 but will log warnings. However, we recommend addressing them sooner rather than later, as they will likely become errors in a future PHP version (potentially PHP 9.0).
Providing the $escape Parameter for CSV Functions
Calls to fgetcsv(), fputcsv(), str_getcsv(), and str_putcsv() now require the $escape parameter. Previously, it defaulted to \\. This change was made to make escape handling explicit and avoid ambiguity.
Without the parameter in PHP 8.4, you’ll see an error like:
Warning: str_getcsv() expects at least 2 arguments, 1 given
Fix: Provide the escape character argument, even if it’s an empty string:
// Before (PHP 8.3 and earlier):
$data = str_getcsv("field1,field2,field3");
// After (PHP 8.4+):
$data = str_getcsv("field1,field2,field3", ',', '\\');
If your CSV data doesn’t include escape sequences, you can use an empty string:
$data = str_getcsv($csv_line, ',', '');
You also may notice: the function signatures changed from str_getcsv(string $input, string $delimiter = ',', string $enclosure = '"', string $escape = '\\') to str_getcsv(string $input, string $delimiter = ',', string $enclosure = '"', string $escape) where escape now has no default. Review all CSV handling in your application to ensure consistency.
Updating Password Hashing Cost
The default Bcrypt cost for password_hash() has been increased from 10 to 12 for better security. This change reflects improvements in computing power—what was considered “expensive” a decade ago is now feasible for attackers with modern hardware.
Action: No immediate code change is required if you’re using the default cost. However, be aware that:
- New password hashes will be stronger but consume approximately 40% more CPU time (cost 12 vs 10 requires about 2^2 = 4 times more iterations).
- Existing hashed passwords remain valid; PHP 8.4 will verify them correctly using the cost stored in the hash.
- If you have manually specified a cost parameter (e.g.,
password_hash($password, PASSWORD_BCRYPT, ['cost' => 10])), consider updating it to12for new hashes.
// If you specified cost explicitly, update it:
$options = ['cost' => 12];
$hash = password_hash($password, PASSWORD_BCRYPT, $options);
We recommend testing the performance impact on your production hardware—a single hash operation with cost 12 might take 50-200ms depending on your server. If this proves too expensive for your use case, you could consider using PASSWORD_ARGON2ID instead, which offers better security at similar or lower computational cost on modern PHP installations with Argon2 support.
Replacing Deprecated CURLOPT_BINARYTRANSFER
The cURL option CURLOPT_BINARYTRANSFER is deprecated in PHP 8.4. It was essentially a no-op alias for CURLOPT_RETURNTRANSFER for many years—setting it had no effect beyond setting CURLOPT_RETURNTRANSFER.
Before:
$ch = curl_init('https://api.example.com/data.json');
curl_setopt($ch, CURLOPT_BINARYTRANSFER, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
After:
$ch = curl_init('https://api.example.com/data.json');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
Simply remove any CURLOPT_BINARYTRANSFER usage—your code will continue to work unchanged. In fact, this deprecation mainly affects code that redundantly set both options; removing the deprecated one simplifies your codebase with no functional change.
Phase 3: Leveraging New Features (Recommended)
Once your application is stable on PHP 8.4, take the opportunity to improve your codebase with new features. These additions can make your code more expressive and reduce boilerplate. We’ll start with the #[Deprecated] attribute, then explore the new array functions and multibyte string utilities.
Using the #[Deprecated] Attribute
You can now officially mark functions, methods, or classes as deprecated using the #[Deprecated] attribute. This provides clearer, more consistent deprecation notices compared to triggering E_USER_DEPRECATED manually—and it’s automatically understood by static analysis tools like PHPStan and Psalm.
Basic usage:
#[Deprecated('This function will be removed in version 2.0. Use newFunction() instead.')]
function oldFunction(): string {
// ...
}
With more details:
#[Deprecated(
'Use UserService::findActiveUsers() instead.',
since: '1.5.0',
replacedBy: UserService::class
)]
function getAllUsers(): array {
// Legacy implementation
}
You might wonder: how does this differ from the older trigger_error() approach? The attribute approach has several advantages: it’s more structured, IDEs can recognize it and show warnings to developers, and it doesn’t require runtime execution to be detected—static analysis tools can spot it without running your code. That said, if you have existing deprecations using trigger_error(), they continue to work; you don’t need to change them immediately.
When to adopt: We recommend using #[Deprecated] for new deprecations going forward. For existing deprecations, migrate gradually as you touch those code areas.
Simplifying Array Logic with New Functions
PHP 8.4 introduces several helpful array functions that can make your code more readable. These functions fill common gaps that previously required custom loops or array_filter + array_slice combinations.
Let’s look at each with practical examples.
array_find() and array_find_key()
These functions help you locate elements in arrays based on a condition.
$users = [
['id' => 1, 'active' => false, 'name' => 'Alice'],
['id' => 2, 'active' => true, 'name' => 'Bob'],
['id' => 3, 'active' => true, 'name' => 'Charlie'],
];
// Find the first active user
$firstActive = array_find($users, fn($user) => $user['active']);
// Result: ['id' => 2, 'active' => true, 'name' => 'Bob']
// Find the key of that user
$firstActiveKey = array_find_key($users, fn($user) => $user['active']);
// Result: 1
Before PHP 8.4, we would typically write:
$firstActive = null;
foreach ($users as $user) {
if ($user['active']) {
$firstActive = $user;
break;
}
}
The new functions are more concise and clearly express intent.
array_any() and array_all()
These functions check whether any or all elements satisfy a condition, similar to Array.prototype.some and Array.prototype.every in JavaScript.
$numbers = [2, 4, 6, 8, 9];
// Check if any number is odd
$hasOdd = array_any($numbers, fn($n) => $n % 2 !== 0);
// Result: true (because 9 is odd)
// Check if all numbers are even
$allEven = array_all($numbers, fn($n) => $n % 2 === 0);
// Result: false (because 9 is odd)
These can be particularly useful in validation scenarios:
// Check if any item exceeds a threshold
$exceedsThreshold = array_any($items, fn($item) => $item['value'] > 100);
// Ensure all required fields are present
$valid = array_all(['name', 'email', 'phone'], fn($field) => isset($data[$field]));
Performance note: These functions iterate over the array internally. For very large arrays (tens of thousands of elements), they’re still efficient—they short-circuit when possible (array_any stops at the first match, array_all stops at the first non-match). Generally, they’re O(n) but with early exit potential.
Trimming Multibyte Strings
New functions mb_trim(), mb_ltrim(), and mb_rtrim() provide native, reliable ways to trim whitespace and other characters from multibyte (e.g., UTF-8) strings.
Before PHP 8.4, you could use trim() which technically works with UTF-8 but only recognizes ASCII whitespace characters (space, tab, newline, carriage return, NUL, vertical tab). For true multibyte awareness—where you might want to trim full-width spaces, zero-width spaces, or other Unicode whitespace—you needed custom code or the mbstring extension’s mbregex functions.
Example:
$japaneseText = " こんにちは "; // Contains full-width spaces (U+3000)
$trimmed = mb_trim($japaneseText);
// Result: "こんにちは" (full-width spaces removed)
These functions accept an optional character mask parameter just like trim():
$text = "---Hello---";
$trimmed = mb_trim($text, '-'); // Result: "Hello"
They’re particularly valuable when processing internationalized user input, where whitespace characters may span multiple Unicode categories. Of course, these functions require the mbstring extension—if you’re not already using it for multibyte string handling, you’ll need to enable it in your php.ini.
Phase 4: Testing and Deployment
The final phase ensures your migrated application is ready for production. Testing is particularly important here because PHP 8.4, while largely backward compatible, introduces subtle changes in behavior that may not be caught by static analysis alone.
Running Your Full Test Suite
Execute your entire test suite (unit, integration, and end-to-end tests) in the PHP 8.4 staging environment. If you don’t have tests covering critical paths, now is a good time to add them—though we understand test coverage varies across projects.
What to look for:
- Tests that fail outright (indicates breaking changes)
- Tests that pass but emit deprecation warnings (PHPUnit can capture these)
- Performance regressions (tests that now take significantly longer)
You can configure PHPUnit to treat deprecations as failures if you want to enforce clean runs:
<!-- In phpunit.xml -->
<php>
<ini name="error_reporting" value="-1"/>
<var name="FAIL_ON_WARNINGS" value="1"/>
</php>
Or run tests with increased verbosity to see warnings:
vendor/bin/phpunit --verbose --stderr
One may wonder: what if my test suite is minimal? Even with limited tests, run what you have and supplement with manual testing. Some teams maintain smoke tests—a small set of critical path tests (login, main CRUD operations, checkout flow, etc.)—specifically for migrations. These don’t need 100% coverage; they just exercise the most important functionality.
Conducting Thorough Manual Testing
Click through all critical user paths in your application. Pay close attention to areas related to the changes you made:
- CSV parsing/export: Upload files, export data, verify formatting isn’t broken
- User input handling: Forms with optional fields (nullable parameters)
- Authentication: Password hashing changes (though typically transparent)
- External integrations: API calls, email sending via IMAP if you use it
- File operations: Especially if your app processes uploads or generates documents
A systematic approach: Create a test matrix listing each critical path and its purpose. For example:
| Feature | Test Case | Expected Behavior |
|---|---|---|
| User registration | Submit form with all fields | Success, redirect to dashboard |
| User registration | Submit with optional phone number empty | Success, phone null stored |
| Data export | Export to CSV | File downloads, columns properly separated |
| IMAP sync | Fetch new emails | No errors, messages appear in inbox |
Going through this methodically helps ensure you don’t miss edge cases.
Tip: If your application has a staging environment with production-like data, consider running automated browser tests (with tools like Laravel Dusk, Codeception, or Selenium) to catch regressions that manual testing might miss.
Monitoring Logs
Check your PHP and application error logs for any previously unseen warnings, errors, or deprecation notices. In your staging environment, set error_reporting to include E_DEPRECATED and E_USER_DEPRECATED:
error_reporting = E_ALL
display_errors = On
log_errors = On
Then run your test suite and manual tests, and review the logs afterward. Look specifically for:
- Deprecation warnings related to nullable parameters, CSV functions, or removed extensions
- Type errors that may have been silent before
- Performance warnings (e.g., slow password_hash operations if you’re benchmarking)
You might notice: even after fixing all known issues, some deprecations may come from third-party libraries. Those are out of your direct control—you’ll need to wait for the library maintainers to update. In the meantime, you can suppress library deprecations by adjusting your error handler, but we recommend tracking them in your issue tracker and planning to update dependencies when fixes are available.
Planning Your Deployment
Plan your deployment carefully. If your application serves production traffic, consider:
- Announcing a maintenance window if the migration requires downtime (often not necessary for PHP version changes, but it’s prudent to have one scheduled just in case)
- Using a phased rollout (blue-green deployment or canary release) to gradually shift traffic to the new version and monitor for issues
- Pre-deployment checklist: confirm backups are current, monitoring is enabled, rollback plan is documented, key team members are available
The actual deployment steps depend on your infrastructure. Common approaches:
- For traditional VPS: update PHP via apt/yum, restart web server, run any database migrations
- For containers: rebuild and redeploy with PHP 8.4 base image
- For serverless: update runtime version in configuration
A word of caution: though PHP 8.4 is generally stable, you may encounter edge cases specific to your application. Having a rollback plan—such as keeping the PHP 8.3 version available and ready to switch back—is wise for mission-critical applications.
Post-Deployment Monitoring
Keep a close eye on your error logs and performance monitoring tools immediately after deployment. The first 24-48 hours are critical for catching residual issues that testing missed.
Monitor specifically for:
- Error rates (spikes in 5xx responses)
- Slow response times (especially on authentication-heavy pages if password hashing cost increases noticeably)
- Deprecation warnings (these won’t break your app but indicate areas needing future work)
- Extension-related errors (if you moved extensions to PECL, ensure they loaded correctly)
Set up alerts for anomalies, and be ready to respond quickly. Many teams find it helpful to have a developer on call or available during the first week after a major version migration.
When to consider the migration complete: once you’ve observed stable operation for at least one full business cycle (typically a week), and all critical paths have been verified working. You can then begin addressing any remaining minor deprecations or planning the adoption of new PHP 8.4 features in your regular development workflow.
Conclusion
Migrating to PHP 8.4 is more than just a version bump; it’s an investment in your application’s future, enhancing its performance, security, and maintainability. By following this checklist, you can approach the migration methodically, minimizing risks and ensuring a successful transition.
Sponsored by Durable Programming
Need help with your PHP application? Durable Programming specializes in maintaining, upgrading, and securing PHP applications.
Hire Durable Programming