Commit 6e28918b935 for woocommerce
commit 6e28918b9359d71e431f7632aa4165dd7deb02da
Author: Miguel Gasca <miguel.gasca@automattic.com>
Date: Thu Oct 15 12:30:36 2026 +0200
Fix product.deleted webhook not firing for deleted variations (#69004)
Removing a variation in the Variations panel, the "Delete all" action,
a REST or WP-CLI delete with force, and switching a variable product to
simple all delete variations without the trash. product.deleted only
listened to wp_trash_post, so none of these sent a webhook.
The topic now also listens to before_delete_post, which runs after the
pre_delete_post veto and while the trash metadata still exists. A new
check lets through variations only, and skips those wp_trash_post()
already reported (trash status plus _wp_trash_meta_status), so each
variation is reported once. Permanently deleted simple and variable
products still send nothing; #69017 tracks that.
Bulk deletes now queue one delivery per variation. Sites that added
before_delete_post to product.deleted by filter now get only
variations from that hook.
Closes #31620.
diff --git a/plugins/woocommerce/changelog/31620-variation-product-deleted-webhook b/plugins/woocommerce/changelog/31620-variation-product-deleted-webhook
new file mode 100644
index 00000000000..ca2b739baaa
--- /dev/null
+++ b/plugins/woocommerce/changelog/31620-variation-product-deleted-webhook
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Send the product.deleted webhook when a product variation is permanently deleted.
diff --git a/plugins/woocommerce/includes/class-wc-webhook.php b/plugins/woocommerce/includes/class-wc-webhook.php
index 3015fc868a9..f7ffb1f5509 100644
--- a/plugins/woocommerce/includes/class-wc-webhook.php
+++ b/plugins/woocommerce/includes/class-wc-webhook.php
@@ -13,6 +13,7 @@
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Utilities\NumberUtil;
use Automattic\WooCommerce\Utilities\OrderUtil;
use Automattic\WooCommerce\Utilities\RestApiUtil;
@@ -193,6 +194,9 @@ class WC_Webhook extends WC_Legacy_Webhook {
case 'delete_user':
$return = $this->is_valid_user_action( $arg );
break;
+ case 'before_delete_post':
+ $return = $this->is_valid_variation_delete_action( $arg );
+ break;
}
if ( 0 === strpos( $current_action, 'woocommerce_process_shop' ) || 0 === strpos( $current_action, 'woocommerce_process_product' ) ) {
@@ -228,6 +232,34 @@ class WC_Webhook extends WC_Legacy_Webhook {
return isset( $post_type_to_resource[ $post_type ] ) && $post_type_to_resource[ $post_type ] === $this->get_resource();
}
+ /**
+ * Validates permanent deletions for product webhooks: only variations, skipping those already reported.
+ *
+ * Trashing through wp_trash_post() fires `product.deleted` and records `_wp_trash_meta_status`,
+ * so a variation trashed that way is not reported again when it is permanently deleted.
+ *
+ * @since 11.3.0
+ * @param mixed $arg First hook argument.
+ * @return bool True if validation passes.
+ */
+ private function is_valid_variation_delete_action( $arg ) {
+ if ( 'product' !== $this->get_resource() ) {
+ return true;
+ }
+
+ if ( ! $this->is_valid_post_action( $arg ) ) {
+ return false;
+ }
+
+ $variation_id = absint( $arg );
+
+ if ( 'product_variation' !== get_post_type( $variation_id ) ) {
+ return false;
+ }
+
+ return ProductStatus::TRASH !== get_post_status( $variation_id ) || ! metadata_exists( 'post', $variation_id, '_wp_trash_meta_status' );
+ }
+
/**
* Validates user actions.
*
@@ -1044,6 +1076,7 @@ class WC_Webhook extends WC_Legacy_Webhook {
),
'product.deleted' => array(
'wp_trash_post',
+ 'before_delete_post',
),
'product.restored' => array(
'untrashed_post',
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php b/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php
index 6808ef3c49d..9dbf66ddd95 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php
@@ -3,6 +3,8 @@
* Tests for WC_Webhook class.
*/
+use Automattic\WooCommerce\Enums\ProductStatus;
+
/**
* Tests for WC_Webhook class.
*/
@@ -111,6 +113,38 @@ class WC_Webhook_Test extends WC_Unit_Test_Case {
}
}
+ /**
+ * @testdox Variation-delete validation rejects a non-ID argument even when the global post is a variation.
+ *
+ * @testWith [0]
+ * ["abc"]
+ *
+ * @param mixed $arg Hook argument that is not a valid post ID.
+ */
+ public function test_is_valid_variation_delete_action_rejects_non_id_with_global_variation( $arg ): void {
+ $had_global_post = array_key_exists( 'post', $GLOBALS );
+ $original_post = $GLOBALS['post'] ?? null;
+ $product = WC_Helper_Product::create_variation_product();
+ $webhook = new WC_Webhook();
+ $webhook->set_topic( 'product.deleted' );
+ $call_is_valid_variation_delete_action = function ( $arg ) {
+ return $this->is_valid_variation_delete_action( $arg );
+ };
+
+ try {
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Test isolates arbitrary global request state.
+ $GLOBALS['post'] = get_post( $product->get_children()[0] );
+ $this->assertFalse( $call_is_valid_variation_delete_action->call( $webhook, $arg ) );
+ } finally {
+ if ( $had_global_post ) {
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the global request state changed for this test.
+ $GLOBALS['post'] = $original_post;
+ } else {
+ unset( $GLOBALS['post'] );
+ }
+ }
+ }
+
/**
* @testdox Post-action validation rejects non-ID values before coercion.
*/
@@ -212,20 +246,7 @@ class WC_Webhook_Test extends WC_Unit_Test_Case {
$product = WC_Helper_Product::create_variation_product();
$expected_ids = array_merge( array( $product->get_id() ), $product->get_children() );
$delivered_ids = array();
- $webhook = $this->create_active_webhook( 'product.deleted' );
-
- remove_action( 'woocommerce_webhook_process_delivery', 'wc_webhook_process_delivery', 10 );
- add_action(
- 'woocommerce_webhook_process_delivery',
- function ( $delivering_webhook, $arg ) use ( $webhook, &$delivered_ids ) {
- if ( $webhook === $delivering_webhook ) {
- $delivered_ids[] = $arg;
- }
- },
- 10,
- 2
- );
- $webhook->enqueue();
+ $this->record_deliveries( $this->create_active_webhook( 'product.deleted' ), $delivered_ids );
wp_trash_post( $product->get_id() );
@@ -242,8 +263,155 @@ class WC_Webhook_Test extends WC_Unit_Test_Case {
$expected_ids = array_merge( array( $product->get_id() ), $product->get_children() );
$delivered_ids = array();
wp_trash_post( $product->get_id() );
+ $this->record_deliveries( $this->create_active_webhook( 'product.restored' ), $delivered_ids );
+
+ wp_untrash_post( $product->get_id() );
+
+ sort( $expected_ids );
+ sort( $delivered_ids );
+ $this->assertSame( $expected_ids, $delivered_ids );
+ }
+
+ /**
+ * @testdox Product deletion webhooks are delivered when a variation is permanently deleted.
+ */
+ public function test_product_deletion_webhook_delivers_for_permanently_deleted_variation(): void {
+ $product = WC_Helper_Product::create_variation_product();
+ $variation_id = $product->get_children()[0];
+ $delivered_ids = array();
+ $this->record_deliveries( $this->create_active_webhook( 'product.deleted' ), $delivered_ids );
+
+ wc_get_product( $variation_id )->delete( true );
+
+ $this->assertSame( array( $variation_id ), $delivered_ids );
+ }
+
+ /**
+ * @testdox Product deletion webhooks are delivered for the variations removed when a variable product changes type.
+ */
+ public function test_product_deletion_webhook_delivers_for_variations_removed_by_type_change(): void {
+ $product = WC_Helper_Product::create_variation_product();
+ $expected_ids = $product->get_children();
+ $delivered_ids = array();
+ $this->record_deliveries( $this->create_active_webhook( 'product.deleted' ), $delivered_ids );
+
+ ( new WC_Product_Simple( $product->get_id() ) )->save();
+
+ sort( $expected_ids );
+ sort( $delivered_ids );
+ $this->assertSame( $expected_ids, $delivered_ids );
+ }
+
+ /**
+ * @testdox Product deletion webhooks are not delivered again when a trashed variable product is permanently deleted.
+ */
+ public function test_product_deletion_webhook_skips_already_trashed_variations_of_deleted_product(): void {
+ $product = WC_Helper_Product::create_variation_product();
+ $delivered_ids = array();
+ wp_trash_post( $product->get_id() );
+ $this->record_deliveries( $this->create_active_webhook( 'product.deleted' ), $delivered_ids );
+
+ wp_delete_post( $product->get_id(), true );
+
+ $this->assertSame( array(), $delivered_ids );
+ }
+
+ /**
+ * @testdox Product deletion webhooks are not delivered again when a trashed variation is permanently deleted.
+ */
+ public function test_product_deletion_webhook_skips_already_trashed_variation(): void {
+ $product = WC_Helper_Product::create_variation_product();
+ $variation_id = $product->get_children()[0];
+ $delivered_ids = array();
+ wc_get_product( $variation_id )->delete();
+ $this->record_deliveries( $this->create_active_webhook( 'product.deleted' ), $delivered_ids );
+
+ wc_get_product( $variation_id )->delete( true );
+
+ $this->assertSame( array(), $delivered_ids );
+ }
+
+ /**
+ * @testdox Product deletion webhooks are delivered for a variation moved to trash without wp_trash_post().
+ */
+ public function test_product_deletion_webhook_delivers_for_variation_trashed_without_wp_trash_post(): void {
+ $product = WC_Helper_Product::create_variation_product();
+ $variation_id = $product->get_children()[0];
+ $variation = wc_get_product( $variation_id );
+ $delivered_ids = array();
+ $variation->set_status( ProductStatus::TRASH );
+ $variation->save();
+ $this->record_deliveries( $this->create_active_webhook( 'product.deleted' ), $delivered_ids );
+
+ $variation->delete( true );
+
+ $this->assertSame( array( $variation_id ), $delivered_ids );
+ }
+
+ /**
+ * @testdox Product deletion webhooks are not delivered when a pre_delete_post filter cancels the variation deletion.
+ */
+ public function test_product_deletion_webhook_skips_variation_deletion_cancelled_by_pre_delete_post(): void {
+ $product = WC_Helper_Product::create_variation_product();
+ $variation_id = $product->get_children()[0];
+ $delivered_ids = array();
+ add_filter(
+ 'pre_delete_post',
+ function ( $check, $post ) use ( $variation_id ) {
+ return $variation_id === $post->ID ? false : $check;
+ },
+ 10,
+ 2
+ );
+ $this->record_deliveries( $this->create_active_webhook( 'product.deleted' ), $delivered_ids );
+
+ wc_get_product( $variation_id )->delete( true );
+
+ $this->assertNotNull( get_post( $variation_id ), 'The filter should have kept the variation' );
+ $this->assertSame( array(), $delivered_ids );
+ }
+
+ /**
+ * @testdox Product deletion webhooks are not delivered when a post that is not a variation is permanently deleted.
+ */
+ public function test_product_deletion_webhook_skips_permanently_deleted_non_variation_post(): void {
+ $post_id = $this->factory->post->create();
+ $delivered_ids = array();
+ $this->record_deliveries( $this->create_active_webhook( 'product.deleted' ), $delivered_ids );
+
+ wp_delete_post( $post_id, true );
+
+ $this->assertSame( array(), $delivered_ids );
+ }
- $webhook = $this->create_active_webhook( 'product.restored' );
+ /**
+ * @testdox Non-product webhooks that add before_delete_post by filter still deliver for trashed posts.
+ */
+ public function test_non_product_webhook_on_before_delete_post_delivers_for_trashed_post(): void {
+ $coupon_id = WC_Helper_Coupon::create_coupon()->get_id();
+ $delivered_ids = array();
+ wp_trash_post( $coupon_id );
+ add_filter(
+ 'woocommerce_webhook_topic_hooks',
+ function ( $topic_hooks ) {
+ $topic_hooks['coupon.deleted'][] = 'before_delete_post';
+ return $topic_hooks;
+ }
+ );
+ $this->record_deliveries( $this->create_active_webhook( 'coupon.deleted' ), $delivered_ids );
+
+ wp_delete_post( $coupon_id, true );
+
+ $this->assertSame( array( $coupon_id ), $delivered_ids, 'The variation-only check should not apply to coupon webhooks' );
+ }
+
+ /**
+ * Enqueue a webhook and record the resource IDs it would deliver, without sending them.
+ *
+ * @param WC_Webhook $webhook Webhook to enqueue.
+ * @param array<mixed> $delivered_ids Receives each delivered resource ID.
+ */
+ private function record_deliveries( WC_Webhook $webhook, array &$delivered_ids ): void {
remove_action( 'woocommerce_webhook_process_delivery', 'wc_webhook_process_delivery', 10 );
add_action(
'woocommerce_webhook_process_delivery',
@@ -256,12 +424,6 @@ class WC_Webhook_Test extends WC_Unit_Test_Case {
2
);
$webhook->enqueue();
-
- wp_untrash_post( $product->get_id() );
-
- sort( $expected_ids );
- sort( $delivered_ids );
- $this->assertSame( $expected_ids, $delivered_ids );
}
/**