| name | drupal-commerce-9-to-10 |
| description | Drupal Commerce 9-to-10 upgrade expertise. Use when upgrading Drupal Commerce sites from Drupal 9 to Drupal 10, resolving Commerce-specific update hooks, or recovering from failed database updates. |
Drupal Commerce 9→10 Upgrade Expert
You are an expert in upgrading Drupal Commerce sites from Drupal 9 to Drupal 10, including diagnosing and recovering from Commerce-specific update hook failures.
When This Activates
- Upgrading a Drupal 9 Commerce site to Drupal 10
- Resolving failed
drush updatedb runs involving Commerce modules
- Debugging
commerce_stripe, dblog, or system.schema update failures
- Planning a major-version Drupal upgrade on a site with Commerce, Stripe, or payment modules
Upgrade Phases
The upgrade follows four phases: Inventory → Composer Upgrade → Database Updates & Recovery → Cutover. Expect blockers in Phase 3 — the recipes below are first-class recovery procedures, not edge cases.
Phase 1: Inventory
Before touching Composer, audit the current state.
Check Drupal and PHP Versions
ddev drush status --fields=drupal-version,php-version,db-driver
List Installed Modules and Versions
ddev drush pm:list --status=enabled --format=table
ddev composer show drupal/* --format=table
Identify Deprecated or Incompatible Modules
ddev exec vendor/bin/drupal-check -ad modules/contrib/
ddev exec vendor/bin/drupal-check -ad modules/custom/
If drupal-check is not installed:
ddev composer require --dev mglaman/drupal-check
Export Current Configuration
ddev drush config:export -y
Commit the config export — this is your rollback baseline.
Audit Migration Modules and Groups
Commerce sites frequently have active migration modules — from initial data imports, ongoing feeds, or a prior D7→D9 migration. These must be inventoried before upgrading.
ddev drush pm:list --status=enabled --type=module | grep -i migrat
ddev drush migrate:status --group=all 2>/dev/null || ddev drush migrate:status
If migrate_tools is installed, list every migration group and its state:
ddev drush sqlq "SELECT id, label FROM config WHERE name LIKE 'migrate_plus.migration_group.%';" | cat
Check for Commerce-specific migration modules:
| Module | Purpose | D10 Action |
|---|
commerce_migrate | Ubercart/Commerce 1.x → Commerce 2.x | Check for D10-compatible release; remove if migration is complete |
migrate_drupal_commerce | D7 Commerce → D9/D10 Commerce | Verify compatibility; may need patch for D10 |
commerce_feeds | Product feed imports | Check D10 release; consider replacement with migrate_plus |
migrate_plus | Config-based migrations, groups | Update to D10-compatible version (^6) |
migrate_tools | Drush commands for migrations | Update to D10-compatible version (^6) |
migrate_file | File migration handling | Update to D10-compatible version |
Check for In-Progress or Stuck Migrations
Migrations left in an "Importing" state will block updatedb:
ddev drush migrate:status 2>/dev/null
ddev drush migrate:reset-status <migration_id>
If you have many stuck migrations:
SELECT m.name, m.value FROM key_value m
WHERE m.collection = 'migrate_status'
AND m.value != 'i:0;';
UPDATE key_value
SET value = 'i:0;'
WHERE collection = 'migrate_status';
Decide: Keep or Remove Migration Modules
If the original migration is complete (all data is in D9 and verified):
ddev drush pm:uninstall commerce_migrate migrate_drupal migrate_drupal_ui -y
ddev drush cr
Uninstalling before the D10 upgrade avoids update hook failures from migration modules that lack D10 releases.
If migrations are still active (ongoing feeds, periodic imports):
Keep the modules but ensure every one has a D10-compatible release. Check each on drupal.org:
migrate_plus ≥ 6.0 for D10
migrate_tools ≥ 6.0 for D10
commerce_migrate — check issue queue for D10 status
Export Migration Group Configuration
If you are keeping migration modules, export their config so you can restore after upgrade:
ddev drush config:export --destination=/tmp/migration-config -y
ls /tmp/migration-config/migrate_plus.migration_group.* 2>/dev/null
ls /tmp/migration-config/migrate_plus.migration.* 2>/dev/null
Back these up separately — updatedb can alter or delete migration configuration.
Record Schema Versions
ddev drush php:eval '$schemas = \Drupal::keyValue("system.schema")->getAll(); ksort($schemas); foreach ($schemas as $name => $version) { echo $name . "\t" . $version . PHP_EOL; }' | cat
Save this output. You will need it to diagnose orphaned schema entries in Phase 3.
Phase 2: Composer Upgrade
Create a Database Snapshot Before Starting
ddev snapshot --name=pre-d10-upgrade
Update composer.json Constraints
The core constraint change:
ddev composer require drupal/core-recommended:^10 drupal/core-composer-scaffold:^10 drupal/core-project-message:^10 --no-update
Update Commerce and Payment Modules
ddev composer require drupal/commerce:^2 drupal/commerce_stripe:^1 --no-update
Adjust module constraints based on your composer show output from Phase 1. Every contrib module needs a D10-compatible release.
Run the Full Update
ddev composer update -W
The -W (or --with-all-dependencies) flag is essential — it allows Composer to resolve the full dependency tree including transitive dependencies.
If Composer fails, resolve one constraint at a time:
- Remove modules that have no D10 release (
ddev composer remove drupal/module_name)
- Check drupal.org for D10-compatible releases or patches
- For custom modules, update
core_version_requirement in .info.yml files to ^9 || ^10
Verify Codebase State
ddev drush cr
ddev drush status
The site may return errors at this point — that is expected. The database schema has not been updated yet.
Phase 3: Database Updates & Recovery
This is where Commerce sites diverge from simple Drupal upgrades. Run updatedb and expect failures:
ddev drush updatedb -y
If it completes cleanly, skip to Phase 4. If it fails, identify the failing update hook from the error output and apply the matching recipe below.
Blocker 1: __PHP_Incomplete_Class in DefaultTableMapping
Symptom:
PHP Fatal error: Uncaught Error: Cannot use __PHP_Incomplete_Class as array
... in Drupal\Core\Entity\Sql\DefaultTableMapping
Often surfaces during commerce_stripe_update_8102 or admin_toolbar_update_8003, but the underlying corruption is independent of either hook and will keep breaking later updates until it is cleared.
Cause: The key_value row under entity.definitions.installed / commerce_payment_method.field_storage_definitions holds a serialized entity definition snapshot from the pre-upgrade codebase. During the D10 update run Drupal tries to unserialize it, but the referenced class has been removed or renamed, producing __PHP_Incomplete_Class objects that DefaultTableMapping then tries to dereference as an array. The site looks broken at the Commerce layer, but the root cause is stale cached entity metadata.
Recovery:
ddev mysql -e "
CREATE TABLE IF NOT EXISTS key_value_backup LIKE key_value;
INSERT INTO key_value_backup
SELECT * FROM key_value
WHERE collection='entity.definitions.installed'
AND name='commerce_payment_method.field_storage_definitions';
DELETE FROM key_value
WHERE collection='entity.definitions.installed'
AND name='commerce_payment_method.field_storage_definitions';
"
ddev drush updb -y
Verification:
ddev drush updatedb-status
ddev drush php:eval "var_export(\Drupal::keyValue('entity.definitions.installed')->get('commerce_payment_method.field_storage_definitions') !== NULL);"
If __PHP_Incomplete_Class errors persist, check for additional entity types with corrupt field_storage_definitions rows. The same pattern can affect commerce_order, commerce_product_variation, and other Commerce entities. Widen the query and repeat the backup-and-delete for each affected row.
Blocker 2: commerce_stripe_update_8102 — Column Already Exists
Symptom:
[error] SQLSTATE[42S21]: Column already exists: 1060 Duplicate column name 'stripe_customer_id'
The update hook tries to add a stripe_customer_id column to commerce_payment_method, but the column was already created by a previous partial run or a schema mismatch.
Cause: The schema update ran partially (column was created) but the schema version was not recorded as complete. Re-running updatedb tries to add the column again.
Recovery:
Verify the column is already in place:
SHOW COLUMNS FROM commerce_payment_method LIKE 'stripe_customer_id';
If it exists, advance the schema version past 8102 via the keyValue service. This serializes the value correctly as an integer (i:8102;) and avoids the brittle hand-written PHP string format:
ddev drush php:eval "\Drupal::keyValue('system.schema')->set('commerce_stripe', 8102);"
Raw SQL alternative if drush is unavailable:
UPDATE key_value
SET value = 'i:8102;'
WHERE collection = 'system.schema'
AND name = 'commerce_stripe';
Verification:
ddev drush updatedb-status
ddev drush php:eval "echo \Drupal::keyValue('system.schema')->get('commerce_stripe'), PHP_EOL;"
Blocker 3: commerce_stripe_update_8104 — Hook-Level Error on Missing Payment Method Metadata
Symptom:
[error] Undefined array key "method_id"
[error] Call to a member function getType() on null
Hit during ddev drush updb -y while the site is running commerce_stripe_update_8104. The hook dereferences payment-method metadata that is missing or shaped differently than the hook expects.
Cause: A PHP-level bug inside the commerce_stripe update hook when it encounters payment methods whose stored metadata does not match the hook's expected structure. This is not a missing-table or missing-column issue; the hook itself raises a fatal error mid-run.
Recovery (last-resort workaround, requires follow-up, see below):
Back up the current system.schema row for commerce_stripe, then advance the schema version past 8104 so the remaining update hooks and post_update steps can run. This skips the failed hook rather than repairing it.
ddev mysql -e "
CREATE TABLE IF NOT EXISTS key_value_backup LIKE key_value;
INSERT INTO key_value_backup
SELECT * FROM key_value
WHERE collection='system.schema'
AND name='commerce_stripe';
"
ddev drush php:eval "\Drupal::keyValue('system.schema')->set('commerce_stripe', 8106);"
ddev drush updb -y
Verification:
ddev drush updatedb-status
ddev drush php:eval "echo \Drupal::keyValue('system.schema')->get('commerce_stripe'), PHP_EOL;"
Required follow-up before production rollout:
Skipping 8104 means the data shape the hook was meant to update has not actually been migrated. Before the next production deploy, do the following on a restored pre-skip backup of the database:
- Read the hook source. Look up
commerce_stripe_update_8104 in modules/contrib/commerce_stripe/commerce_stripe.install or on drupal.org. Understand what data it reads and writes.
- Decide whether the target data shape is in use on this site. If the site does not use the Stripe payment methods or features the hook touches, the skip is safe. Document this in writing.
- If the data is in use, repair the inputs and re-run the hook rather than keeping the skip. Reset
system.schema back to the pre-skip value from key_value_backup and let updatedb execute 8104 against cleaned inputs.
- Re-verify payments. Run a live Stripe transaction in test mode, confirm
commerce_payment_method rows look correct, and check that saved customer payment methods still authorise.
Do not ship the skip to production unsupervised.
Blocker 4: dblog_update_10100 — ALTER TABLE watchdog Exceeds MySQL Timeout
Symptom:
[error] SQLSTATE[HY000]: General error: 2006 MySQL server has gone away
Hit during ddev drush updb -y while dblog_update_10100 runs ALTER TABLE watchdog ... BIGINT. Less commonly, on sites whose wid values have exceeded the old INT range, the alteration instead fails with SQLSTATE[22003]: Numeric value out of range.
Cause: D10 widens the wid column in the watchdog table to BIGINT. On sites with a large watchdog table the in-place ALTER TABLE runs longer than MySQL's connection-idle limit (wait_timeout, or the local DDEV max_allowed_packet), and MySQL drops the connection mid-run. The numeric-overflow variant has the same root shape (the old column can't hold values during the conversion) and clears with the same recovery.
Recovery (fastest, what we ran in production):
Uninstall dblog before running updates so the alteration is never attempted, then re-enable it afterwards. This is safe: watchdog holds operational logging only.
ddev drush pm:uninstall dblog -y
ddev drush updb -y
ddev drush en dblog -y
ddev drush cr
Alternative (keep dblog installed, shrink the table first):
If you need to preserve recent watchdog entries, prune the table so the ALTER TABLE stays under the MySQL timeout window.
SELECT COUNT(*) FROM watchdog;
CREATE TABLE watchdog_backup AS SELECT * FROM watchdog ORDER BY wid DESC LIMIT 100000;
TRUNCATE TABLE watchdog;
INSERT INTO watchdog SELECT * FROM watchdog_backup;
DROP TABLE watchdog_backup;
Then re-run:
ddev drush updb -y
Verification:
ddev drush updatedb-status
ddev drush watchdog:show --count=5
Blocker 5: Orphaned system.schema Entries
Symptom:
[error] Module 'some_removed_module' not found.
Or updatedb crashes because it tries to run update hooks for a module that no longer exists in the codebase.
Cause: A module was removed via Composer (or deleted from modules/) without first being uninstalled through Drupal. The key_value table still has a system.schema entry for it, so Drupal thinks the module is installed and tries to update it.
Recovery:
SELECT name FROM key_value
WHERE collection = 'system.schema'
AND name NOT IN ('system')
ORDER BY name;
DELETE FROM key_value
WHERE collection = 'system.schema'
AND name = 'some_removed_module';
Also clean up core.extension config so Drupal does not try to load the module:
ddev drush config:get core.extension module.some_removed_module 2>/dev/null
ddev drush php:eval "\$config = \Drupal::configFactory()->getEditable('core.extension'); \$modules = \$config->get('module'); unset(\$modules['some_removed_module']); \$config->set('module', \$modules)->save();"
ddev drush cr
Verification:
ddev drush updatedb-status
ddev drush cr
Post-Update: Verify Migration Module Group Integrity
After all update hooks pass, verify that migration modules and their groups survived the upgrade intact.
ddev drush pm:list --status=enabled | grep -i migrat
ddev drush migrate:status 2>/dev/null
If migration config was deleted by updatedb:
Re-import the migration group configuration you backed up in Phase 1:
ddev drush config:import --partial --source=/tmp/migration-config -y
ddev drush cr
ddev drush migrate:status
If migrate_plus or migrate_tools update hooks changed group structure:
ddev drush config:list | grep migrate_plus.migration_group
ddev drush migrate:status --group=<group_name>
Common issue — migration map tables renamed or dropped:
D10 schema changes can affect migrate_map_* and migrate_message_* tables. Check that map tables still exist for active migrations:
SHOW TABLES LIKE 'migrate_map_%';
SHOW TABLES LIKE 'migrate_message_%';
If a map table is missing, the migration will behave as though it has never been run. For ongoing imports this means duplicate content on next run — re-create the map table from a backup or run a rollback-and-reimport.
Phase 4: Cutover
After all update hooks pass:
Re-export Configuration
ddev drush config:export -y
Review the diff — every change should be explainable as a D9→D10 schema or default update.
Run Full Status Check
ddev drush status
ddev drush core:requirements --severity=2
Verify Commerce Functionality
ddev drush entity:updates
ddev drush sqlq "SELECT COUNT(*) FROM commerce_order;" | cat
ddev drush sqlq "SELECT COUNT(*) FROM commerce_product;" | cat
ddev drush sqlq "SELECT COUNT(*) FROM commerce_payment_method;" | cat
Clear All Caches and Test
ddev drush cr
ddev launch
Walk through critical Commerce paths manually:
- Product display pages
- Add to cart → checkout flow
- Payment processing (use Stripe test mode)
- Order admin pages (
/admin/commerce/orders)
Commit the Final State
ddev drush config:export -y
git add -A
git commit -m "Drupal 10 upgrade complete"
Quick Reference: Schema Version Overrides
When skipping a failed update hook, prefer the keyValue service. It serializes the integer value correctly and avoids hand-written serialization bugs:
ddev drush php:eval "\Drupal::keyValue('system.schema')->set('commerce_stripe', 8106);"
ddev drush php:eval "\Drupal::keyValue('system.schema')->set('dblog', 10101);"
Raw SQL alternative. Schema versions are stored as serialized integers (i:<version>;), not strings. Writing s:<length>:"<version>"; is brittle, because a miscounted length prefix silently corrupts system.schema:
UPDATE key_value SET value = 'i:8102;' WHERE collection = 'system.schema' AND name = 'commerce_stripe';
UPDATE key_value SET value = 'i:8106;' WHERE collection = 'system.schema' AND name = 'commerce_stripe';
UPDATE key_value SET value = 'i:10101;' WHERE collection = 'system.schema' AND name = 'dblog';
Warning: Only skip an update hook when you have confirmed the schema change it applies either already exists or is not needed by your site. Blindly skipping updates will cause data loss or runtime errors. For hooks you have not diagnosed, treat the skip as a last-resort workaround and plan a follow-up repair before production rollout (see Blocker 3 for a worked example).
Pre-Upgrade Checklist
Sources