Drupal 9 to Drupal 10 with PHP 8.2+
In the vast African savanna, elephant herds traverse well-worn migration paths — routes remembered across generations, leading reliably to water and grazing even through drought. These paths evolve gradually, but when seasonal shifts demand a new route, the herd adapts carefully, testing each step before committing the group.
Drupal’s upgrade history shows a similar evolution. The leap from Drupal 7 to 8 was a fundamental rewrite — so substantial that many sites chose fresh builds over in-place upgrades. Drupal 9, however, was designed differently: it was essentially Drupal 8 with deprecated code removed, creating a deliberate compatibility bridge to Drupal 10. This means the path from Drupal 9 to 10 follows much more familiar terrain. The new challenge comes not from Drupal itself, but from PHP 8.2’s stricter rules, which demand we test our footing carefully.
We’ll walk this migration path together, noting where the ground is solid and where PHP 8.2 requires careful navigation.
Why Upgrade to Drupal 10 with PHP 8.2
Before we get into the technical steps, though, let’s consider why this upgrade path merits our attention.
You might have a Drupal 9 site working well enough today. Of course, Drupal 9’s support ended in November 2023, so security updates are no longer available. Beyond that, though, Drupal 10 offers concrete improvements we’ll use:
- Modern frontend tools: Olivero theme, Claro admin theme, CKEditor 5 — typically better for content editors than Drupal 9 defaults.
- Symfony 6 backend: Generally improved performance; long-term support aligns with Drupal’s timeline.
- PHP 8.2 readiness: Requires PHP 8.1+, recommends 8.2+. You gain JIT performance typically 20-30% better on typical workloads, though results vary.
- Streamlined APIs: Typically, fewer deprecations mean less custom code maintenance over time.
That said, no upgrade is without trade-offs. Contributed modules may lag, and PHP 8.2’s strictness catches issues earlier — but requires fixes.
Prerequisites
Before we examine the upgrade commands, let’s ensure your site is positioned for success. You might wonder: why so much preparation? Because Drupal upgrades typically fail on unprepared sites — often due to outdated modules or unaddressed deprecations.
We need to verify both Drupal 10 compatibility — which we’ll check via Upgrade Status — and PHP 8.2 compatibility — which requires separate attention since Drupal 10 supports PHP 8.1+ but PHP 8.2 adds stricter deprecations.
Understanding the Upgrade Context
One may wonder: how does the Drupal 9 to 10 upgrade compare to previous major version jumps, particularly Drupal 7 to 8? The difference is substantial. The Drupal 7 to 8 upgrade was a nearly complete rewrite — custom modules, themes, and even database schemas often required extensive rework. Many sites opted for fresh builds rather than in-place upgrades.
The Drupal 9 to 10 path, by contrast, was designed from the start as a “minor version” upgrade. Drupal 9 was essentially Drupal 8 with deprecated code removed. This means your Drupal 9 codebase is already largely Drupal 10-compatible; we’re mostly dealing with the small percentage of code that relied on deprecated APIs. Additionally, Drupal 10 requires PHP 8.1+ and supports PHP 8.2’s stricter standards, which is typically the bigger challenge than core compatibility itself.
Bottom line: If you maintained your Drupal 9 site following best practices — regular updates, avoiding deprecated APIs — the actual core upgrade is often a matter of hours rather than weeks. The PHP 8.2 compatibility work on custom code, though, can vary significantly based on code quality.
-
Environment: PHP 8.1+ (8.2 recommended for production). Composer 2.7+ is typically required. Verify with
$ php -vand$ composer --version. -
Update Drupal 9 fully: Latest 9.5.x core, all contrib modules/themes. Run:
$ composer update --with-all-dependencies $ drush updb -y $ drush cr -
Upgrade Status module: Scans compatibility. Install and run:
$ composer require drupal/upgrade_status $ drush en upgrade_status -yVisit
/admin/reports/upgrade-status. Note any incompatible modules — we’ll address them next.
The Upgrade Process: A Step-by-Step Guide
Once you’ve prepared your site, you can begin the upgrade process.
Step 1: Checking Deprecated Code
Upgrade Status covers much, but for custom code, drupal-check provides deeper analysis. It flags D10/D9 incompatibilities and PHP 8.2 issues.
One may wonder: why use drupal-check if we already have the Upgrade Status module? The answer is straightforward. Upgrade Status primarily scans contributed modules for compatibility — it’s excellent for determining whether your site’s modules are ready. drupal-check, though, examines your actual codebase — custom modules, themes, and any bespoke PHP code — for API deprecations and PHP 8.2 compatibility issues that automated tools might miss.
Tip: When running
drupal-check, focus on “Drupal 10 incompatible” issues first, then address deprecations. Some deprecations are cosmetic, but others indicate code that will break entirely. Also, check drupal.org project pages for contributed modules — often there are specific upgrade instructions or known issues thatdrupal-checkwon’t capture.
Usage
drupal-check [options] [<path>]
Installation
First, add drupal-check to your project:
$ composer require --dev mglaman/drupal-check
Basic usage
Run drupal-check on your custom code directory:
$ ./vendor/bin/drupal-check web/modules/custom
You can also scan specific paths or your entire codebase. The tool recursively scans all PHP files in the provided directory. Expected output example:
$ ./vendor/bin/drupal-check web/modules/custom
web/modules/custom/my_module/src/MyService.php:12:10:
Drupal\Core\Session\AccountInterface->isAnonymous() is deprecated in drupal:10.0.0
web/modules/custom/my_module/src/Plugin/views/field/MyField.php:45:18:
Call to deprecated method getEntity() of class Drupal\views\ResultRow
1 Drupal 10 incompatible
2 deprecations
--------------------------------------------------------------------
Overall: 3 issues (2 deprecations, 1 Drupal 10 incompatible)
--------------------------------------------------------------------
Review and fix these before proceeding. You also may notice PHP 8.2-specific warnings here.
drupal-check vs Upgrade Status: These tools serve complementary purposes. Upgrade Status primarily scans contributed modules for Drupal 10 compatibility — it’s excellent for determining whether your site’s module ecosystem is ready before you begin. drupal-check, though, examines your actual codebase — custom modules, themes, and any bespoke PHP code — for API deprecations and PHP 8.2 compatibility issues that automated tools might miss. In practice, I recommend starting with Upgrade Status to ensure your contrib modules are D10-ready, then using drupal-check to audit your custom code thoroughly.
Safety first: Before running automated refactoring tools, ensure your code is committed to version control and you have a recent backup. Rector will modify your files directly — though it’s generally reliable, you’ll want the ability to revert if anything unexpected occurs.
Step 2: Automate Fixes with Rector
Rector handles many mechanical D9→D10 changes automatically. Of course, it doesn’t fix everything — we’ll still need to review changes carefully, especially around custom business logic.
Usage
rector process [<path>] [options]
Installation
Add the Drupal Rector configuration to your project:
$ composer require --dev drupal/rector
Dry run (recommended first step)
Always start with a dry run to preview changes:
$ ./vendor/bin/rector process web/modules/custom --dry-run --config vendor/drupal/rector/config/rector.php
The --dry-run flag shows what would change without modifying any files. Review the colored diff output carefully: yellow indicates modified lines, red indicates removed code.
Apply changes
Once you’re satisfied with the preview, run without --dry-run:
$ ./vendor/bin/rector process web/modules/custom --config vendor/drupal/rector/config/rector.php
What you’ll see: Rector shows a color-coded diff of proposed changes. Yellow indicates modified lines; red indicates removed code. You’ll want to review these carefully — Rector is good, but it doesn’t understand your specific use cases.
If the changes look reasonable, we can apply them:
$ ./vendor/bin/rector process web/modules/custom --config vendor/drupal/rector/config/rector.php
Note: The config file is auto-loaded by Rector if it’s in the expected location. After running, you’ll see a summary of modified files. We’ll test thoroughly in the next step — never trust automated refactoring blindly.
Step 3: Update Composer Dependencies
Usage
composer update [options]
Update composer.json
First, update your composer.json file. Replace "drupal/core": "^9" with:
"drupal/core-recommended": "^10",
"drupal/core-composer-scaffold": "^10"
Remove the old drupal/core entry if present.
Alternative: You can also use Rector’s composer rule or run composer why-not drupal/core-recommended 10 to diagnose conflicts before updating.
Tip: Before making any changes to
composer.json, ensure you have it committed to version control. Also back up your currentcomposer.lockfile. These files represent your site’s dependency state; having a known-good baseline makes rollback straightforward if needed.
Run the update
After saving your composer.json, run:
$ composer update --with-all-dependencies
The --with-all-dependencies flag allows Composer to update development dependencies (like drupal-check and rector) as well as production dependencies. Expect some module constraint relaxations. Review the composer.lock changes carefully before proceeding.
Critical: Before running database updates, ensure you have a complete backup of your database and files. Database updates are potentially destructive and cannot be easily undone. If you’re unsure, test the process on a staging copy first.
Step 4: Running Database Updates
The final step is to run the database updates. You can do this with Drush:
Usage
drush updb [options]
Run the updates
$ drush updb
This command executes any pending database update hooks introduced by Drupal core or contributed modules as part of the Drupal 10 upgrade. Typical output looks like:
[notice] Starting Drupal database update:
[notice] Reverted ckeditor5.settings.yml configuration.
[notice] Updated {key} settings with default configuration.
[notice] Updated URL aliases with new pattern.
[notice] Updated menu_link_content data.
[success] All listed database operations were completed successfully.
Important: If drush updb reports failures or hangs on certain updates, investigate before clearing the cache. Some updates require manual intervention or additional steps documented in the update hook comments. Don’t proceed until all database updates complete successfully.
Clear the cache
After successful database updates, clear all caches:
$ drush cr
or
$ drush cache-rebuild
You should see output confirming cache clearing:
[success] Cache rebuild complete.
The drush cr command (short for cache-rebuild) ensures Drupal picks up the new schema and configuration changes.
PHP 8.2 Specific Gotchas
PHP 8.2 enforces rules that catch sloppy code earlier. Drupal 10 works with it, but custom code often needs tweaks.
One may wonder: if Drupal 10 supports PHP 8.2, why does custom code frequently require changes? The answer is that while Drupal core has been fully updated for PHP 8.2 compatibility, your custom code — and many contributed modules — may still contain patterns that were acceptable in PHP 8.1 but are now deprecated or forbidden in 8.2. These changes are mostly about catching potential bugs earlier rather than breaking functionality outright.
Dynamic properties deprecated:
// Old (triggers warning in 8.2)
class MyClass {
public function setFoo($foo) { $this->foo = $foo; }
}
// Fix: declare explicit properties or use #[AllowDynamicProperties]
class MyClass {
public ?string $foo = null;
// Explicitly declare all properties
public function setFoo(string $foo): void {
$this->foo = $foo;
}
}
Stricter null handling: Functions like strlen(null), count(null), and array_sum(null) now throw a TypeError. The fix is to add null checks:
// Instead of: $length = strlen($value);
$length = strlen($value ?? '');
Readonly classes: New in PHP 8.2; these are particularly useful for Drupal service definitions where immutability prevents accidental state changes:
#[Readonly]
class MyCacheService {
public function __construct(
private CacheBackendInterface $cache
) {}
}
String interpolation edge cases: PHP 8.2 is stricter about complex expressions in double-quoted strings. What worked in 8.1 may now produce warnings:
// Problematic:
echo "Value: {$object->method()}";
// Safer:
$value = $object->method();
echo "Value: {$value}";
Tip: Run
php -l(lint) on your entire codebase before upgrading:
find web/modules/custom web/themes/custom -name "*.php" -exec php -l {} \; | grep -v "No syntax errors"
This catches syntax issues that PHP 8.2 will reject. For deeper analysis, use Psalm or PHPStan with a Drupal plugin—they’ll identify type issues that lint misses.
After making changes, run your test suite thoroughly. PHP 8.2’s stricter checks often uncover latent bugs that previous versions silently tolerated.
Common Pitfalls and How to Avoid Them
Based on real-world upgrade experiences, here are the most common issues we’ve seen and how to avoid them:
-
Not updating contributed modules first: This is the most frequent cause of upgrade failures. Run
composer update --with-all-dependencieson your Drupal 9 site before touching core. Verify all modules are at their latest stable releases compatible with D9. -
Skipping the Upgrade Status report: The Upgrade Status module often reveals incompatible modules that aren’t obvious from composer constraints. Always run this check and address all critical issues before proceeding.
-
Neglecting PHP 8.2 compatibility checks: Drupal 10 runs on PHP 8.1+, but PHP 8.2 introduces stricter standards. Run
drupal-checkon all custom code and review warnings carefully — what passes in 8.1 may fail in 8.2. -
Upgrading directly on production: Always test upgrades on a staging environment that mirrors production. A local development environment often misses caching, database, or integration issues that appear in real deployments.
-
Not backing up database and files: Before any upgrade, ensure you have verified backups. Use
drush sql-dumpand copy thesites/default/filesdirectory. Test restore procedures so you can recover quickly if needed. -
Assuming Rector fixes everything automatically: Rector handles many mechanical changes, but it doesn’t understand your business logic. Review all diffs carefully — especially custom entity definitions, form alterations, and plugin systems.
-
Rushing through database updates: After running
drush updb, review the update hook output. Some updates may take longer on large sites or require manual intervention. Don’t immediately clear cache if updates fail; investigate first. -
Forgetting to test contributed module functionality: Even if modules are D10-compatible, their behavior may change. Test all critical workflows involving contributed modules — especially views, webforms, and complex field configurations.
-
Not verifying theme compatibility: Olivero and Claro are Drupal 10’s defaults, but custom themes based on Drupal 9 may need adjustments. Check for deprecated theme functions, library definitions, and breakpoint changes.
-
Ignoring deprecation warnings during development: Enable full error reporting during the upgrade process. Deprecated code often works in D10 but will break in D11. Address deprecations now to avoid future upgrade headaches.
Conclusion
Upgrading from Drupal 9 to Drupal 10 is generally straightforward if you are prepared. By following the steps in this guide, you can help ensure a smooth transition to a more modern, secure, and performant version of Drupal.
The benefits of Drupal 10 and PHP 8.2+ typically outweigh the effort involved. Consider planning your upgrade to keep your site secure and well-maintained.
Sponsored by Durable Programming
Need help with your PHP application? Durable Programming specializes in maintaining, upgrading, and securing PHP applications.
Hire Durable Programming