Commit 469c8f0357a for woocommerce
commit 469c8f0357a13874d10b99f281e1cb77162f06a1
Author: Néstor Soriano <konamiman@konamiman.com>
Date: Tue Jun 23 17:57:54 2026 +0200
Remove remaining WooCommerce data on uninstall (WC_REMOVE_ALL_DATA) (#65793)
* Remove remaining WooCommerce data on uninstall (WC_REMOVE_ALL_DATA)
When uninstalling WooCommerce with the WC_REMOVE_ALL_DATA constant set,
several pieces of data were left behind in the database. This removes them:
- product_visibility taxonomy terms missing from the existing taxonomy cleanup loop.
- The placeholder image attachment (attachment post, its postmeta and the
file).
- User meta using the _woocommerce_, wc_ and _wc_ key prefixes (persistent
cart, tracks anon id, wc_last_active, wc_admin_*, etc.).
Action Scheduler is a shared
library that other active plugins may also bundle, so its tables are kept by
default to avoid destroying another plugin's scheduled actions. They are
dropped only when the site owner additionally defines WC_REMOVE_ACTION_SCHEDULER
in wp-config.php, mirroring the existing WC_REMOVE_ALL_DATA opt-in.
* Update user documentation
* Add changelog file
* Guard placeholder image deletion against custom merchant media
WC_Install::delete_placeholder_image() deleted whichever attachment was stored
in the woocommerce_placeholder_image option. That option is a merchant-editable
setting ("Enter attachment ID or URL to an image"), so it can point at the
merchant's own media library item, which would then be permanently deleted on
uninstall.
Only delete the attachment when it is WooCommerce's own generated placeholder
(its _wp_attached_file basename is woocommerce-placeholder.webp); a custom image
is left untouched. Add a unit test covering the custom-attachment case, and
clarify the changelog wording about the already-removed woocommerce_ user meta
prefix.
* Scope uninstall user meta deletion to WooCommerce's own keys
The WC_REMOVE_ALL_DATA cleanup deleted user meta matching the broad
wc_% and _wc_% LIKE patterns. Those two-character prefixes are not
unique to WooCommerce: they also match other plugins' per-user meta
and, critically, WordPress core's own role/capability meta
({prefix}capabilities and {prefix}user_level) on any site whose
database table prefix is "wc_", which would strip every user's roles
and could lock the site out.
Replace the blanket wc_/_wc_ wildcards with an allowlist of
WooCommerce's actual keys: the wc_admin_ legacy prefix, the _wc_egg_
easter-egg meta, the per-site customer lookup meta (wc_last_order,
wc_order_count, wc_money_spent, suffixed with the site's table
prefix), and the exact wc_last_active and
wc_marketplace_suggestions_dismissed_suggestions keys. The uniquely
namespaced woocommerce_ and _woocommerce_ wildcards are kept.
Also document that wp_usermeta is network-global while uninstall runs
per site, so the matching meta is removed network-wide.
* Assert Action Scheduler table list against the live database schema
The test for get_action_scheduler_tables() asserted the method returned
the same four table names it hardcodes, so it only caught a typo, never
drift: Action Scheduler is an independently versioned dependency, so its
table set can change without this test noticing, leaving tables behind on
uninstall.
Derive the expected set from SHOW TABLES LIKE '{prefix}actionscheduler_%'
and assert the method matches the tables actually present, plus that every
returned name is prefixed with the database table prefix. The test now
fails if Action Scheduler adds, renames or drops a table and the method
drifts out of sync.
* Adjust changelog file
diff --git a/docs/code-snippets/uninstall_remove_all_woocommerce_data.md b/docs/code-snippets/uninstall_remove_all_woocommerce_data.md
index ce0e316a754..dcf6961564c 100644
--- a/docs/code-snippets/uninstall_remove_all_woocommerce_data.md
+++ b/docs/code-snippets/uninstall_remove_all_woocommerce_data.md
@@ -24,3 +24,18 @@ define( 'WC_REMOVE_ALL_DATA', true );
Then, once the changes are saved to the file, when you deactivate and delete WooCommerce, all of its data is removed from your WordPress site database.

+
+## Removing the Action Scheduler tables
+
+WooCommerce uses [Action Scheduler](https://actionscheduler.org/) to run background tasks. Action Scheduler is a shared library that other plugins can also bundle, so its database tables (`actionscheduler_actions`, `actionscheduler_claims`, `actionscheduler_groups` and `actionscheduler_logs`) are **not** removed by `WC_REMOVE_ALL_DATA`, even when the constant is set to `true`. Keeping them avoids deleting scheduled tasks that another active plugin might still rely on.
+
+If you are certain that no other plugin on the site uses Action Scheduler, you can also remove those tables by adding the `WC_REMOVE_ACTION_SCHEDULER` constant alongside `WC_REMOVE_ALL_DATA` in `wp-config.php`:
+
+```php
+define( 'WC_REMOVE_ALL_DATA', true );
+define( 'WC_REMOVE_ACTION_SCHEDULER', true );
+
+/* That's all, stop editing! Happy publishing. */
+```
+
+Both constants are required: `WC_REMOVE_ALL_DATA` triggers the removal of WooCommerce data, and `WC_REMOVE_ACTION_SCHEDULER` additionally drops the Action Scheduler tables.
diff --git a/plugins/woocommerce/changelog/fix-wooplug-6446-remove-all-data b/plugins/woocommerce/changelog/fix-wooplug-6446-remove-all-data
new file mode 100644
index 00000000000..ef28b154c33
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wooplug-6446-remove-all-data
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Remove additional WooCommerce data when uninstalling with WC_REMOVE_ALL_DATA: the product_visibility taxonomy terms, the placeholder image attachment, and additional WooCommerce user meta. The Action Scheduler tables can also be removed by additionally defining the WC_REMOVE_ACTION_SCHEDULER constant.
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index 45ab3812aa9..e2fba02952b 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -2214,6 +2214,28 @@ $email_unsubscribes_table_schema;
return array_merge( $tables, self::get_tables() );
}
+ /**
+ * Get the list of Action Scheduler database tables.
+ *
+ * These are intentionally kept out of get_tables(): Action Scheduler is a shared library that
+ * may be bundled by other active plugins, so its tables are only dropped during a full uninstall
+ * when the site owner explicitly opts in by setting the WC_REMOVE_ACTION_SCHEDULER constant.
+ *
+ * @since 11.0.0
+ *
+ * @return string[] Action Scheduler table names.
+ */
+ public static function get_action_scheduler_tables() {
+ global $wpdb;
+
+ return array(
+ "{$wpdb->prefix}actionscheduler_actions",
+ "{$wpdb->prefix}actionscheduler_claims",
+ "{$wpdb->prefix}actionscheduler_groups",
+ "{$wpdb->prefix}actionscheduler_logs",
+ );
+ }
+
/**
* Create roles and capabilities.
*
@@ -2479,6 +2501,35 @@ $email_unsubscribes_table_schema;
wp_update_attachment_metadata( $attach_id, $attach_data );
}
+ /**
+ * Delete the placeholder image created by create_placeholder_image().
+ *
+ * Removes the attachment post, its metadata and the underlying file, but only when the stored
+ * woocommerce_placeholder_image option still points at WooCommerce's own generated placeholder.
+ * A custom image set by the merchant through the "Placeholder image" setting is left untouched to
+ * avoid deleting merchant-owned media. The option itself is removed along with the rest of the
+ * woocommerce_ options during uninstall.
+ *
+ * @since 11.0.0
+ *
+ * @return void
+ */
+ public static function delete_placeholder_image() {
+ $placeholder_image = absint( get_option( 'woocommerce_placeholder_image', 0 ) );
+
+ if ( ! $placeholder_image ) {
+ return;
+ }
+
+ // Only delete WooCommerce's own generated placeholder, never a custom image the merchant may have set.
+ $attached_file = (string) get_post_meta( $placeholder_image, '_wp_attached_file', true );
+ if ( 'woocommerce-placeholder.webp' !== wp_basename( $attached_file ) ) {
+ return;
+ }
+
+ wp_delete_attachment( $placeholder_image, true );
+ }
+
/**
* Show action links on the plugin screen.
*
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-install-test.php b/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
index 389fecc90da..b6ece8f455c 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-install-test.php
@@ -483,4 +483,92 @@ class WC_Install_Test extends \WC_Unit_Test_Case {
remove_filter( 'woocommerce_get_shop_page_id', $supply_shop_id );
remove_filter( 'pre_option_' . \Automattic\WooCommerce\Admin\API\Reports\Orders\Stats\DataStore::OPTION_ORDER_STATS_TABLE_HAS_COLUMN_ORDER_FULFILLMENT_STATUS, $supply_column_status );
}
+
+ /**
+ * @testdox Should return every actionscheduler_* table that exists in the database, each prefixed with the table prefix.
+ */
+ public function test_get_action_scheduler_tables_matches_database_tables(): void {
+ global $wpdb;
+
+ // Action Scheduler is bundled with WooCommerce, so its tables exist in the test database. Comparing
+ // against the live schema (rather than re-listing the same hardcoded names the method returns) means
+ // this test fails if Action Scheduler ever adds, renames or drops a table and the method drifts out
+ // of sync, which would otherwise leave those tables behind on uninstall.
+ $actual_tables = $wpdb->get_col(
+ "SHOW TABLES LIKE '" . $wpdb->esc_like( $wpdb->prefix . 'actionscheduler_' ) . "%'"
+ );
+
+ $this->assertNotEmpty(
+ $actual_tables,
+ 'No actionscheduler_* tables were found in the database; the test environment is not set up as expected.'
+ );
+
+ $reported_tables = WC_Install::get_action_scheduler_tables();
+
+ foreach ( $reported_tables as $table ) {
+ $this->assertStringStartsWith(
+ $wpdb->prefix,
+ $table,
+ "Action Scheduler table {$table} should be prefixed with the database table prefix."
+ );
+ }
+
+ sort( $actual_tables );
+ sort( $reported_tables );
+
+ $this->assertSame(
+ $actual_tables,
+ $reported_tables,
+ 'get_action_scheduler_tables() should match the actionscheduler_* tables present in the database.'
+ );
+ }
+
+ /**
+ * @testdox Should delete the placeholder image attachment and its meta.
+ */
+ public function test_delete_placeholder_image_removes_attachment(): void {
+ $attachment_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'woocommerce-placeholder',
+ 'post_mime_type' => 'image/webp',
+ 'post_status' => 'inherit',
+ 'post_type' => 'attachment',
+ )
+ );
+ update_post_meta( $attachment_id, '_wp_attached_file', 'woocommerce-placeholder.webp' );
+ update_option( 'woocommerce_placeholder_image', $attachment_id );
+
+ WC_Install::delete_placeholder_image();
+
+ $this->assertNull( get_post( $attachment_id ), 'The placeholder attachment post should be deleted.' );
+ $this->assertSame(
+ '',
+ get_post_meta( $attachment_id, '_wp_attached_file', true ),
+ 'The placeholder attachment meta should be deleted.'
+ );
+ }
+
+ /**
+ * @testdox Should not delete a custom image set by the merchant as the placeholder.
+ */
+ public function test_delete_placeholder_image_keeps_custom_attachment(): void {
+ $attachment_id = wp_insert_attachment(
+ array(
+ 'post_title' => 'merchant-logo',
+ 'post_mime_type' => 'image/png',
+ 'post_status' => 'inherit',
+ 'post_type' => 'attachment',
+ )
+ );
+ update_post_meta( $attachment_id, '_wp_attached_file', '2026/06/merchant-logo.png' );
+ update_option( 'woocommerce_placeholder_image', $attachment_id );
+
+ WC_Install::delete_placeholder_image();
+
+ $this->assertInstanceOf(
+ WP_Post::class,
+ get_post( $attachment_id ),
+ 'A custom merchant placeholder attachment should not be deleted.'
+ );
+ }
}
diff --git a/plugins/woocommerce/uninstall.php b/plugins/woocommerce/uninstall.php
index df6dfb9e5f9..efc13c5a532 100644
--- a/plugins/woocommerce/uninstall.php
+++ b/plugins/woocommerce/uninstall.php
@@ -86,12 +86,52 @@ if ( defined( 'WC_REMOVE_ALL_DATA' ) && true === WC_REMOVE_ALL_DATA ) {
// Tables.
WC_Install::drop_tables();
+ /*
+ * Action Scheduler is a shared library that other active plugins may also use, so its tables are
+ * kept by default. They are only dropped when the site owner additionally sets the
+ * WC_REMOVE_ACTION_SCHEDULER constant to true in wp-config.php, confirming that no other plugin
+ * relies on Action Scheduler (otherwise that plugin would lose its scheduled actions).
+ */
+ if ( defined( 'WC_REMOVE_ACTION_SCHEDULER' ) && true === WC_REMOVE_ACTION_SCHEDULER ) {
+ foreach ( WC_Install::get_action_scheduler_tables() as $as_table ) {
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $wpdb->query( "DROP TABLE IF EXISTS {$as_table}" );
+ }
+ }
+
+ // Placeholder image: delete the attachment post, its meta and the file.
+ WC_Install::delete_placeholder_image();
+
// Delete options.
$wpdb->query( "DELETE FROM $wpdb->options WHERE option_name LIKE 'woocommerce\_%';" );
$wpdb->query( "DELETE FROM $wpdb->options WHERE option_name LIKE 'widget\_woocommerce\_%';" );
- // Delete usermeta.
- $wpdb->query( "DELETE FROM $wpdb->usermeta WHERE meta_key LIKE 'woocommerce\_%';" );
+ /*
+ * Delete user meta created by WooCommerce.
+ *
+ * The woocommerce_ and _woocommerce_ prefixes are uniquely namespaced, so a LIKE wildcard is safe.
+ * The wc_ / _wc_ namespace is only two characters: a blanket wc_% / _wc_% wildcard would also delete
+ * other plugins' user meta and, critically, WordPress core's own role/capability meta (the
+ * {prefix}capabilities and {prefix}user_level keys) on any site whose database table prefix is "wc_",
+ * which would strip every user's roles and could lock the site out. We therefore match only
+ * WooCommerce's own known wc_ / _wc_ user meta keys (including the wc_admin_ legacy prefix, the
+ * _wc_egg_ easter-egg meta, and the per-site customer lookup meta, whose keys are suffixed with the
+ * site's table prefix) rather than a blanket wildcard.
+ *
+ * Note: wp_usermeta is shared across a multisite network while this uninstall runs per site, so the
+ * matching meta is removed network-wide, consistent with the woocommerce_ option/meta cleanup above.
+ */
+ $wpdb->query(
+ "DELETE FROM $wpdb->usermeta WHERE
+ meta_key LIKE 'woocommerce\_%'
+ OR meta_key LIKE '\_woocommerce\_%'
+ OR meta_key LIKE 'wc\_admin\_%'
+ OR meta_key LIKE '\_wc\_egg\_%'
+ OR meta_key LIKE 'wc\_last\_order\_%'
+ OR meta_key LIKE 'wc\_order\_count\_%'
+ OR meta_key LIKE 'wc\_money\_spent\_%'
+ OR meta_key IN ( 'wc_last_active', 'wc_marketplace_suggestions_dismissed_suggestions' );"
+ );
// Delete our data from the post and post meta tables, and remove any additional tables we created.
$wpdb->query( "DELETE FROM {$wpdb->posts} WHERE post_type IN ( 'product', 'product_variation', 'shop_coupon', 'shop_order', 'shop_order_refund' );" );
@@ -103,7 +143,7 @@ if ( defined( 'WC_REMOVE_ALL_DATA' ) && true === WC_REMOVE_ALL_DATA ) {
// Delete terms if > WP 4.2 (term splitting was added in 4.2).
if ( version_compare( $wp_version, '4.2', '>=' ) ) {
// Delete term taxonomies.
- foreach ( array( 'product_cat', 'product_tag', 'product_shipping_class', 'product_type' ) as $_taxonomy ) {
+ foreach ( array( 'product_cat', 'product_tag', 'product_shipping_class', 'product_type', 'product_visibility' ) as $_taxonomy ) {
$wpdb->delete(
$wpdb->term_taxonomy,
array(