Commit 02f9a16b098 for woocommerce

commit 02f9a16b098a918f7f087c7814f77bf04d7c5daf
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date:   Wed Aug 26 23:12:37 2026 +0300

    Fix invalid-product fatal errors in REST product write endpoints (#67182)

    The product REST controllers built the target product straight from request
    input and relied on the constructor succeeding. When the ID did not resolve to
    a usable product, WC_Product_Data_Store_CPT::read() threw a bare
    'Invalid product.' exception that save_object() does not catch, so the request
    fataled with an HTTP 500. Two triggers reached it: a variation ID sent with a
    type parameter, deterministically, and a product deleted between the route
    guard and preparation, intermittently. One site logged 922 in 12 hours.

    Add a shared ProductRequestPreparationTrait, used by the V2, V3 and V4 product
    controllers, that validates the target before constructing it:

    - Variation IDs return 404 woocommerce_rest_invalid_product_id with the
      existing "use the variations endpoint" message, before construction.
    - A construction failure converts to a 404 only when it is the core data
      store's own invalid-product signal for a nonzero ID whose post is gone.
      Everything else is rethrown, so typed failures from extension-backed stores
      keep their own codes and statuses.
    - A non-scalar type returns 400 woocommerce_rest_invalid_product_type instead
      of reaching explode() and raising a TypeError.
    - A type that resolves to no class falls back to the product's stored class,
      resolved through WC_Product_Factory::get_product_classname() so that
      filter-registered classes win. It previously fell back to WC_Product_Simple,
      which rewrote the stored product_type term and orphaned variations.
    - The duplicate endpoints return preparation errors rather than passing a
      WP_Error into product_duplicate(), which had produced a 200 carrying an
      empty product.

    Backward compatibility: no signature or hook changes. Typed writes invoke
    woocommerce_product_type_query once more during preparation, and the
    stored-type fallback invokes woocommerce_product_class once more on the
    requests that reach it. Both already fire on these requests via
    wc_get_product(), so neither is a new context. Previously fatal requests now
    return 404s, and the variation-duplicate 200-with-empty-product becomes a 404.

    Refs #51209

diff --git a/plugins/woocommerce/changelog/fix-51209-rest-uncaught-invalid-product b/plugins/woocommerce/changelog/fix-51209-rest-uncaught-invalid-product
new file mode 100644
index 00000000000..ba549463cb8
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-51209-rest-uncaught-invalid-product
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent REST product update and duplicate requests from fatally erroring when given a variation ID, and preserve the stored product type instead of converting the product to simple when the requested type names no known class.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
index ec68f8fb8fc..2d527762008 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
@@ -13,6 +13,7 @@ use Automattic\WooCommerce\Enums\ProductStockStatus;
 use Automattic\WooCommerce\Enums\ProductTaxStatus;
 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Enums\CatalogVisibility;
+use Automattic\WooCommerce\Internal\RestApi\ProductRequestPreparationTrait;
 use Automattic\WooCommerce\Internal\Traits\RestApiCache;
 use Automattic\WooCommerce\Utilities\I18nUtil;
 use Automattic\WooCommerce\Utilities\MetaDataUtil;
@@ -27,6 +28,7 @@ defined( 'ABSPATH' ) || exit;
  */
 class WC_REST_Products_V2_Controller extends WC_REST_CRUD_Controller {

+	use ProductRequestPreparationTrait;
 	use RestApiCache;

 	/**
@@ -1079,31 +1081,10 @@ class WC_REST_Products_V2_Controller extends WC_REST_CRUD_Controller {
 	 * @return WP_Error|WC_Data
 	 */
 	protected function prepare_object_for_database( $request, $creating = false ) {
-		$id = isset( $request['id'] ) ? absint( $request['id'] ) : 0;
+		$product = $this->get_product_for_rest_request( $request );

-		// Type is the most important part here because we need to be using the correct class and methods.
-		if ( isset( $request['type'] ) ) {
-			$classname = WC_Product_Factory::get_classname_from_product_type( $request['type'] );
-
-			if ( ! class_exists( $classname ) ) {
-				$classname = 'WC_Product_Simple';
-			}
-
-			$product = new $classname( $id );
-		} elseif ( isset( $request['id'] ) ) {
-			$product = wc_get_product( $id );
-		} else {
-			$product = new WC_Product_Simple();
-		}
-
-		if ( ProductType::VARIATION === $product->get_type() ) {
-			return new WP_Error(
-				"woocommerce_rest_invalid_{$this->post_type}_id",
-				__( 'To manipulate product variations you should use the /products/&lt;product_id&gt;/variations/&lt;id&gt; endpoint.', 'woocommerce' ),
-				array(
-					'status' => 404,
-				)
-			);
+		if ( is_wp_error( $product ) ) {
+			return $product;
 		}

 		// Post title.
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
index a133d36cbd0..98de4761783 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
@@ -14,6 +14,7 @@ use Automattic\WooCommerce\Enums\ProductTaxStatus;
 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Enums\CatalogVisibility;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareRestControllerTrait;
+use Automattic\WooCommerce\Internal\RestApi\ProductRequestPreparationTrait;
 use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
 use Automattic\WooCommerce\Utilities\I18nUtil;
 use Automattic\WooCommerce\Utilities\MetaDataUtil;
@@ -29,6 +30,7 @@ defined( 'ABSPATH' ) || exit;
 class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {

 	use CogsAwareRestControllerTrait;
+	use ProductRequestPreparationTrait;

 	/**
 	 * Endpoint namespace.
@@ -146,7 +148,12 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 		}

 		// Creating product object from request data in preparation for copying.
-		$updated_product    = $this->prepare_object_for_database( $request );
+		$updated_product = $this->prepare_product_for_duplication( $request );
+
+		if ( is_wp_error( $updated_product ) ) {
+			return $updated_product;
+		}
+
 		$duplicated_product = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $updated_product );

 		if ( is_wp_error( $duplicated_product ) ) {
@@ -740,31 +747,10 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 	 * @return WP_Error|WC_Data
 	 */
 	protected function prepare_object_for_database( $request, $creating = false ) {
-		$id = isset( $request['id'] ) ? absint( $request['id'] ) : 0;
-
-		// Type is the most important part here because we need to be using the correct class and methods.
-		if ( isset( $request['type'] ) ) {
-			$classname = WC_Product_Factory::get_classname_from_product_type( $request['type'] );
-
-			if ( ! class_exists( $classname ) ) {
-				$classname = 'WC_Product_Simple';
-			}
+		$product = $this->get_product_for_rest_request( $request );

-			$product = new $classname( $id );
-		} elseif ( isset( $request['id'] ) ) {
-			$product = wc_get_product( $id );
-		} else {
-			$product = new WC_Product_Simple();
-		}
-
-		if ( ProductType::VARIATION === $product->get_type() ) {
-			return new WP_Error(
-				"woocommerce_rest_invalid_{$this->post_type}_id",
-				__( 'To manipulate product variations you should use the /products/&lt;product_id&gt;/variations/&lt;id&gt; endpoint.', 'woocommerce' ),
-				array(
-					'status' => 404,
-				)
-			);
+		if ( is_wp_error( $product ) ) {
+			return $product;
 		}

 		// Post title.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 0fb4f327e79..7ce68d17b90 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -26760,90 +26760,6 @@ parameters:
 			count: 1
 			path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php

-		-
-			message: '#^Cannot call method get_type\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
-		-
-			message: '#^Cannot call method set_catalog_visibility\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
-		-
-			message: '#^Cannot call method set_description\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
-		-
-			message: '#^Cannot call method set_featured\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
-		-
-			message: '#^Cannot call method set_menu_order\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
-		-
-			message: '#^Cannot call method set_name\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
-		-
-			message: '#^Cannot call method set_purchase_note\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
-		-
-			message: '#^Cannot call method set_reviews_allowed\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
-		-
-			message: '#^Cannot call method set_short_description\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
-		-
-			message: '#^Cannot call method set_slug\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
-		-
-			message: '#^Cannot call method set_status\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
-		-
-			message: '#^Cannot call method set_tax_class\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
-		-
-			message: '#^Cannot call method set_tax_status\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
-		-
-			message: '#^Cannot call method set_virtual\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
 		-
 			message: '#^Method WC_REST_Products_V2_Controller\:\:batch_items\(\) has parameter \$request with generic class WP_REST_Request but does not specify its types\: T$#'
 			identifier: missingType.generics
@@ -26934,12 +26850,6 @@ parameters:
 			count: 1
 			path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php

-		-
-			message: '#^Parameter \#1 \$classname of function class_exists expects string, string\|false given\.$#'
-			identifier: argument.type
-			count: 1
-			path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
 		-
 			message: '#^Parameter \#1 \$date of function wc_rest_prepare_date_response expects string\|WC_DateTime\|null, int\<1, max\> given\.$#'
 			identifier: argument.type
@@ -26982,12 +26892,6 @@ parameters:
 			count: 1
 			path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php

-		-
-			message: '#^Parameter \#1 \$product of method WC_REST_Products_V2_Controller\:\:save_product_shipping_data\(\) expects WC_Product, object\|false\|null given\.$#'
-			identifier: argument.type
-			count: 1
-			path: includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
-
 		-
 			message: '#^Parameter \#1 \$quantity of method WC_Product\:\:set_stock_quantity\(\) expects float\|null, string given\.$#'
 			identifier: argument.type
@@ -29895,96 +29799,6 @@ parameters:
 			count: 1
 			path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php

-		-
-			message: '#^Cannot call method get_type\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
-		-
-			message: '#^Cannot call method set_catalog_visibility\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
-		-
-			message: '#^Cannot call method set_description\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
-		-
-			message: '#^Cannot call method set_featured\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
-		-
-			message: '#^Cannot call method set_menu_order\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
-		-
-			message: '#^Cannot call method set_name\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
-		-
-			message: '#^Cannot call method set_post_password\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
-		-
-			message: '#^Cannot call method set_purchase_note\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
-		-
-			message: '#^Cannot call method set_reviews_allowed\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
-		-
-			message: '#^Cannot call method set_short_description\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
-		-
-			message: '#^Cannot call method set_slug\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
-		-
-			message: '#^Cannot call method set_status\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
-		-
-			message: '#^Cannot call method set_tax_class\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
-		-
-			message: '#^Cannot call method set_tax_status\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
-		-
-			message: '#^Cannot call method set_virtual\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
 		-
 			message: '#^Method WC_Product\:\:has_options\(\) invoked with 1 parameter, 0 required\.$#'
 			identifier: arguments.count
@@ -30069,12 +29883,6 @@ parameters:
 			count: 1
 			path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php

-		-
-			message: '#^Parameter \#1 \$classname of function class_exists expects string, string\|false given\.$#'
-			identifier: argument.type
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
 		-
 			message: '#^Parameter \#1 \$date of function wc_rest_prepare_date_response expects string\|WC_DateTime\|null, int\|false given\.$#'
 			identifier: argument.type
@@ -30111,18 +29919,6 @@ parameters:
 			count: 2
 			path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php

-		-
-			message: '#^Parameter \#1 \$product of method WC_Admin_Duplicate_Product\:\:product_duplicate\(\) expects WC_Product, WC_Data\|WP_Error given\.$#'
-			identifier: argument.type
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
-		-
-			message: '#^Parameter \#1 \$product of method WC_REST_Products_V2_Controller\:\:save_product_shipping_data\(\) expects WC_Product, object\|false\|null given\.$#'
-			identifier: argument.type
-			count: 1
-			path: includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
-
 		-
 			message: '#^Parameter \#1 \$quantity of method WC_Product\:\:set_stock_quantity\(\) expects float\|null, string given\.$#'
 			identifier: argument.type
@@ -64962,96 +64758,6 @@ parameters:
 			count: 1
 			path: src/Internal/RestApi/Routes/V4/Products/Controller.php

-		-
-			message: '#^Cannot call method get_type\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
-		-
-			message: '#^Cannot call method set_catalog_visibility\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
-		-
-			message: '#^Cannot call method set_description\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
-		-
-			message: '#^Cannot call method set_featured\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
-		-
-			message: '#^Cannot call method set_menu_order\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
-		-
-			message: '#^Cannot call method set_name\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
-		-
-			message: '#^Cannot call method set_post_password\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
-		-
-			message: '#^Cannot call method set_purchase_note\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
-		-
-			message: '#^Cannot call method set_reviews_allowed\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
-		-
-			message: '#^Cannot call method set_short_description\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
-		-
-			message: '#^Cannot call method set_slug\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
-		-
-			message: '#^Cannot call method set_status\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
-		-
-			message: '#^Cannot call method set_tax_class\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
-		-
-			message: '#^Cannot call method set_tax_status\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
-		-
-			message: '#^Cannot call method set_virtual\(\) on object\|false\|null\.$#'
-			identifier: method.nonObject
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
 		-
 			message: '#^Method Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Products\\Controller\:\:create_item\(\) has parameter \$request with generic class WP_REST_Request but does not specify its types\: T$#'
 			identifier: missingType.generics
@@ -65148,12 +64854,6 @@ parameters:
 			count: 1
 			path: src/Internal/RestApi/Routes/V4/Products/Controller.php

-		-
-			message: '#^Parameter \#1 \$classname of function class_exists expects string, string\|false given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
 		-
 			message: '#^Parameter \#1 \$date of function wc_rest_prepare_date_response expects string\|WC_DateTime\|null, int\|false given\.$#'
 			identifier: argument.type
@@ -65202,18 +64902,6 @@ parameters:
 			count: 1
 			path: src/Internal/RestApi/Routes/V4/Products/Controller.php

-		-
-			message: '#^Parameter \#1 \$product of method WC_Admin_Duplicate_Product\:\:product_duplicate\(\) expects WC_Product, Automattic\\WooCommerce\\Internal\\RestApi\\Routes\\V4\\Products\\WC_Data\|WP_Error given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
-		-
-			message: '#^Parameter \#1 \$product of method WC_REST_Products_V2_Controller\:\:save_product_shipping_data\(\) expects WC_Product, object\|false\|null given\.$#'
-			identifier: argument.type
-			count: 1
-			path: src/Internal/RestApi/Routes/V4/Products/Controller.php
-
 		-
 			message: '#^Parameter \#1 \$quantity of method WC_Product\:\:set_stock_quantity\(\) expects float\|null, string given\.$#'
 			identifier: argument.type
diff --git a/plugins/woocommerce/src/Internal/RestApi/ProductRequestPreparationTrait.php b/plugins/woocommerce/src/Internal/RestApi/ProductRequestPreparationTrait.php
new file mode 100644
index 00000000000..7d8f2272c54
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/RestApi/ProductRequestPreparationTrait.php
@@ -0,0 +1,140 @@
+<?php
+/**
+ * Product REST request preparation helpers.
+ *
+ * @package WooCommerce\Internal\RestApi
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\RestApi;
+
+use Automattic\WooCommerce\Enums\ProductType;
+
+/**
+ * Shared product construction for REST write requests.
+ */
+trait ProductRequestPreparationTrait {
+
+	/**
+	 * Get the product instance targeted by a REST write request.
+	 *
+	 * @param \WP_REST_Request<array<string, mixed>> $request Request object.
+	 * @return \WC_Product|\WP_Error
+	 * @throws \Exception When construction fails for any reason other than the core data store's invalid-product failure for an ID that no longer resolves to a product post.
+	 */
+	private function get_product_for_rest_request( $request ) {
+		$id = isset( $request['id'] ) ? absint( $request['id'] ) : 0;
+
+		if ( isset( $request['type'] ) && ! is_scalar( $request['type'] ) ) {
+			// Falling back to a default class here would silently rewrite the product's type on update.
+			return new \WP_Error(
+				"woocommerce_rest_invalid_{$this->post_type}_type",
+				__( 'Invalid product type.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		$existing_product_type = isset( $request['type'] ) && $id ? \WC_Product_Factory::get_product_type( $id ) : false;
+
+		if ( ProductType::VARIATION === $existing_product_type ) {
+			return $this->get_invalid_product_id_error( true );
+		}
+
+		if ( isset( $request['type'] ) ) {
+			$classname = \WC_Product_Factory::get_classname_from_product_type( (string) $request['type'] );
+
+			if ( ! $classname || ! class_exists( $classname ) ) {
+				// Fall back to the stored type rather than silently converting the product to simple.
+				// get_product_classname() resolves it through woocommerce_product_class, so a stored
+				// class registered only by that filter still wins. The requested type is not resolved
+				// that way. The string check guards woocommerce_product_type_query, which can return
+				// any truthy value, and a non-string one would fatal while the class name is built.
+				$classname = is_string( $existing_product_type ) && '' !== $existing_product_type
+					? \WC_Product_Factory::get_product_classname( $id, $existing_product_type )
+					: 'WC_Product_Simple';
+			}
+
+			try {
+				$product = new $classname( $id );
+			} catch ( \Exception $e ) {
+				// Convert only the core data store's own invalid-product failure for a nonzero
+				// target that is no longer a product post; absence of a post proves deletion
+				// there because wp_delete_post() invalidates the posts cache (unlike
+				// WooCommerce's products cache group). The throw site stands in for that
+				// failure: a store that reads its own backend throws from its own file, so it
+				// is rethrown. A store that delegates to parent::read() throws from the core
+				// file instead and is converted, which is why the post-type check below has to
+				// carry the decision on its own. Everything else is rethrown unchanged, since
+				// typed exceptions carry their own codes and extension-backed stores may fail
+				// transiently for IDs that never had a post.
+				$is_core_invalid_product = ! ( $e instanceof \WC_Data_Exception )
+					&& ( new \ReflectionClass( \WC_Product_Data_Store_CPT::class ) )->getFileName() === $e->getFile();
+				$target_post_type        = get_post_type( $id );
+
+				if ( ! $is_core_invalid_product || ! $id || 'product' === $target_post_type ) {
+					throw $e;
+				}
+
+				return $this->get_invalid_product_id_error( 'product_variation' === $target_post_type );
+			}
+		} elseif ( isset( $request['id'] ) ) {
+			$product = wc_get_product( $id );
+		} else {
+			$product = new \WC_Product_Simple();
+		}
+
+		if ( ! $product instanceof \WC_Product ) {
+			return $this->get_invalid_product_id_error();
+		}
+
+		return ProductType::VARIATION === $product->get_type()
+			? $this->get_invalid_product_id_error( true )
+			: $product;
+	}
+
+	/**
+	 * Prepare the product targeted by a duplicate request, converting typed failures to errors.
+	 *
+	 * @param \WP_REST_Request<array<string, mixed>> $request Request object.
+	 * @return \WC_Product|\WP_Error
+	 */
+	private function prepare_product_for_duplication( $request ) {
+		try {
+			$product = $this->prepare_object_for_database( $request );
+		} catch ( \WC_Data_Exception $e ) {
+			return new \WP_Error( $e->getErrorCode(), $e->getMessage(), $e->getErrorData() );
+		}
+
+		if ( is_wp_error( $product ) ) {
+			return $product;
+		}
+
+		// The pre-insert filter runs after the trait's guarantees, so the shape must be re-checked.
+		if ( ! $product instanceof \WC_Product ) {
+			return new \WP_Error(
+				"woocommerce_rest_{$this->post_type}_not_created",
+				__( 'Invalid product.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		return $product;
+	}
+
+	/**
+	 * Build the invalid-product error used by product write endpoints.
+	 *
+	 * @param bool $is_variation Whether the target is a product variation.
+	 * @return \WP_Error
+	 */
+	private function get_invalid_product_id_error( bool $is_variation = false ): \WP_Error {
+		return new \WP_Error(
+			"woocommerce_rest_invalid_{$this->post_type}_id",
+			$is_variation
+				? __( 'To manipulate product variations you should use the /products/&lt;product_id&gt;/variations/&lt;id&gt; endpoint.', 'woocommerce' )
+				: __( 'Invalid product ID.', 'woocommerce' ),
+			array( 'status' => 404 )
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
index d4bcdeb2f88..cabee67c3ce 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
@@ -20,6 +20,7 @@ use Automattic\WooCommerce\Enums\ProductTaxStatus;
 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Enums\WeightUnit;
 use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareRestControllerTrait;
+use Automattic\WooCommerce\Internal\RestApi\ProductRequestPreparationTrait;
 use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
 use Automattic\WooCommerce\Utilities\I18nUtil;
 use Automattic\WooCommerce\Utilities\MetaDataUtil;
@@ -32,8 +33,6 @@ use WC_Admin_Duplicate_Product;
 use WC_REST_CRUD_Controller;
 use WC_Data_Store;
 use WC_Product_Attribute;
-use WC_Product_Factory;
-use WC_Product_Simple;
 use WC_REST_Exception;


@@ -47,6 +46,7 @@ defined( 'ABSPATH' ) || exit;
 class Controller extends WC_REST_Products_V2_Controller {

 	use CogsAwareRestControllerTrait;
+	use ProductRequestPreparationTrait;

 	/**
 	 * Fields stripped from the response for users without product management capabilities
@@ -239,7 +239,12 @@ class Controller extends WC_REST_Products_V2_Controller {
 		}

 		// Creating product object from request data in preparation for copying.
-		$updated_product    = $this->prepare_object_for_database( $request );
+		$updated_product = $this->prepare_product_for_duplication( $request );
+
+		if ( is_wp_error( $updated_product ) ) {
+			return $updated_product;
+		}
+
 		$duplicated_product = ( new WC_Admin_Duplicate_Product() )->product_duplicate( $updated_product );

 		if ( is_wp_error( $duplicated_product ) ) {
@@ -1012,31 +1017,10 @@ class Controller extends WC_REST_Products_V2_Controller {
 	 * @return WP_Error|WC_Data
 	 */
 	protected function prepare_object_for_database( $request, $creating = false ) {
-		$id = isset( $request['id'] ) ? absint( $request['id'] ) : 0;
-
-		// Type is the most important part here because we need to be using the correct class and methods.
-		if ( isset( $request['type'] ) ) {
-			$classname = WC_Product_Factory::get_classname_from_product_type( $request['type'] );
-
-			if ( ! class_exists( $classname ) ) {
-				$classname = 'WC_Product_Simple';
-			}
+		$product = $this->get_product_for_rest_request( $request );

-			$product = new $classname( $id );
-		} elseif ( isset( $request['id'] ) ) {
-			$product = wc_get_product( $id );
-		} else {
-			$product = new WC_Product_Simple();
-		}
-
-		if ( ProductType::VARIATION === $product->get_type() ) {
-			return new WP_Error(
-				"woocommerce_rest_invalid_{$this->post_type}_id",
-				__( 'To manipulate product variations you should use the /products/&lt;product_id&gt;/variations/&lt;id&gt; endpoint.', 'woocommerce' ),
-				array(
-					'status' => 404,
-				)
-			);
+		if ( is_wp_error( $product ) ) {
+			return $product;
 		}

 		// Post title.
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php
index 823574633c4..71f15444f97 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-products-controller-tests.php
@@ -450,4 +450,20 @@ class WC_REST_Products_V2_Controller_Test extends WC_REST_Unit_Test_Case {

 		$this->assert_incomplete_meta_data_handled_correctly( wc_get_product( $product->get_id() ) );
 	}
+
+	/**
+	 * @testdox Updating a variation through the products endpoint returns the existing variation endpoint error.
+	 */
+	public function test_update_with_variation_id_and_type_returns_error_response(): void {
+		$variable_product = WC_Helper_Product::create_variation_product();
+		$variation_id     = $variable_product->get_children()[0];
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v2/products/' . $variation_id );
+		$request->set_body_params( array( 'type' => 'simple' ) );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 404, $response->get_status(), 'Variations should be handled by the variations endpoint.' );
+		$this->assertSame( 'woocommerce_rest_invalid_product_id', $response->get_data()['code'] );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
index e56ed02e009..8a62dc18e66 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller-tests.php
@@ -2208,4 +2208,474 @@ class WC_REST_Products_Controller_Tests extends WC_Unit_Test_Case {

 		$this->assert_incomplete_meta_data_handled_correctly( wc_get_product( $product->get_id() ) );
 	}
+
+	/**
+	 * @testdox Updating a product using a variation ID and a type param returns an error response instead of a fatal error.
+	 */
+	public function test_update_with_variation_id_and_type_returns_error_response(): void {
+		$variable_product = WC_Helper_Product::create_variation_product();
+		$variation_id     = $variable_product->get_children()[0];
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/products/' . $variation_id );
+		$request->set_body_params( array( 'type' => 'simple' ) );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 404, $response->get_status(), 'Variations should be handled by the variations endpoint.' );
+		$this->assertSame( 'woocommerce_rest_invalid_product_id', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox Updating a product can still change its product type.
+	 */
+	public function test_update_can_change_product_type(): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/products/' . $product->get_id() );
+		$request->set_body_params( array( 'type' => 'variable' ) );
+
+		$response        = $this->server->dispatch( $request );
+		$updated_product = wc_get_product( $product->get_id() );
+
+		$this->assertSame( 200, $response->get_status(), 'Valid product type changes should continue to succeed.' );
+		$this->assertInstanceOf( WC_Product_Variable::class, $updated_product );
+	}
+
+	/**
+	 * @testdox Product preparation allows an extension-backed product whose ID collides with a variation post.
+	 */
+	public function test_prepare_object_allows_extension_product_when_id_collides_with_variation_post(): void {
+		$variable_product = WC_Helper_Product::create_variation_product();
+		$product_id       = $variable_product->get_children()[0];
+
+		$custom_data_store           = new class() extends WC_Product_Data_Store_CPT {
+			/**
+			 * Number of products read through the extension data store.
+			 *
+			 * @var int
+			 */
+			public $read_count = 0;
+
+			/**
+			 * Mark the extension-backed product as read without loading the colliding WordPress post.
+			 *
+			 * @param WC_Product $product Product being read.
+			 */
+			public function read( &$product ) {
+				++$this->read_count;
+				$product->set_object_read( true );
+			}
+		};
+		$register_custom_data_store  = static function ( $data_stores ) use ( $custom_data_store ) {
+			$data_stores['product-simple'] = $custom_data_store;
+			return $data_stores;
+		};
+		$resolve_custom_product_type = static function ( $product_type, $queried_product_id ) use ( $product_id ) {
+			return $product_id === $queried_product_id ? ProductType::SIMPLE : $product_type;
+		};
+		add_filter( 'woocommerce_data_stores', $register_custom_data_store );
+		add_filter( 'woocommerce_product_type_query', $resolve_custom_product_type, 10, 2 );
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/products/' . $product_id );
+		$request->set_url_params( array( 'id' => $product_id ) );
+		$request->set_body_params( array( 'type' => 'simple' ) );
+
+		$result = $this->invoke_prepare( $request, false );
+
+		$this->assertInstanceOf( WC_Product_Simple::class, $result );
+		$this->assertSame( $product_id, $result->get_id() );
+		$this->assertSame( 'product_variation', get_post_type( $product_id ) );
+		$this->assertSame( 1, $custom_data_store->read_count, 'Product type detection should not construct the product twice.' );
+	}
+
+	/**
+	 * Invoke the protected prepare_object_for_database() on the endpoint under test.
+	 *
+	 * @param WP_REST_Request $request  Request object.
+	 * @param bool            $creating Whether the request creates a new product.
+	 * @return mixed
+	 */
+	private function invoke_prepare( WP_REST_Request $request, bool $creating ) {
+		$prepare_method = new ReflectionMethod( $this->endpoint, 'prepare_object_for_database' );
+		$prepare_method->setAccessible( true );
+
+		return $prepare_method->invoke( $this->endpoint, $request, $creating );
+	}
+
+	/**
+	 * @testdox Updating a product that no longer exists with a type param returns an error response instead of a fatal error.
+	 */
+	public function test_prepare_object_returns_error_when_product_deleted_with_type(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/products/' . $product->get_id() );
+		$request->set_url_params( array( 'id' => $product->get_id() ) );
+		$request->set_body_params( array( 'type' => 'simple' ) );
+
+		// Simulate a concurrent deletion between the update_item() guard and preparation.
+		wp_delete_post( $product->get_id(), true );
+
+		$result = $this->invoke_prepare( $request, false );
+
+		$this->assertWPError( $result );
+		$this->assertEquals( 'woocommerce_rest_invalid_product_id', $result->get_error_code() );
+	}
+
+	/**
+	 * @testdox Construction failures on the create path are rethrown instead of misreported as an invalid product ID.
+	 */
+	public function test_prepare_object_rethrows_create_path_store_failure(): void {
+		$request = new WP_REST_Request( 'POST', '/wc/v3/products' );
+		$request->set_body_params( array( 'type' => 'simple' ) );
+
+		$break_store = function ( $stores ) {
+			$stores['product'] = 'WC_Nonexistent_Data_Store';
+			return $stores;
+		};
+		add_filter( 'woocommerce_data_stores', $break_store );
+
+		$this->expectException( Exception::class );
+		$this->expectExceptionMessage( 'Invalid data store.' );
+
+		$this->invoke_prepare( $request, true );
+	}
+
+	/**
+	 * @testdox Duplicating a product preserves the error code of a typed data exception thrown during preparation.
+	 */
+	public function test_duplicate_preserves_data_exception_error_code(): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		// The route guard is served from the warm product instance cache, so the only
+		// read on this route happens inside the preparation construction.
+		$throw_data_exception = function ( $product_id ) use ( $product ) {
+			if ( $product->get_id() === $product_id ) {
+				throw new WC_Data_Exception( 'custom_duplicate_block', 'Simulated typed failure.', 409 );
+			}
+		};
+		add_action( 'woocommerce_product_read', $throw_data_exception );
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/products/' . $product->get_id() . '/duplicate' );
+		$request->set_body_params( array( 'type' => 'simple' ) );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 409, $response->get_status(), 'A typed exception should keep its own HTTP status on the duplicate route' );
+		$this->assertEquals( 'custom_duplicate_block', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox A non-scalar type value in a batch item is rejected instead of silently rewriting the product type.
+	 */
+	public function test_batch_update_with_array_type_is_rejected(): void {
+		$variable_product = WC_Helper_Product::create_variation_product();
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/products/batch' );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					'update' => array(
+						array(
+							'id'   => $variable_product->get_id(),
+							'type' => array( 'simple' ),
+							'name' => 'Renamed via array-type batch',
+						),
+					),
+				)
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 200, $response->get_status() );
+		$data = $response->get_data();
+		$this->assertArrayHasKey( 'error', $data['update'][0], 'A non-scalar type must be rejected, not coerced' );
+		$this->assertEquals( 'woocommerce_rest_invalid_product_type', $data['update'][0]['error']['code'] );
+		$this->assertInstanceOf( WC_Product_Variable::class, wc_get_product( $variable_product->get_id() ), 'The product type must not be rewritten' );
+	}
+
+	/**
+	 * @testdox Typed exceptions from extension-backed product stores are rethrown instead of becoming a 404.
+	 */
+	public function test_prepare_object_rethrows_typed_exception_for_extension_backed_product(): void {
+		$typed_store = new class() extends WC_Product_Data_Store_CPT {
+			/**
+			 * Simulate an extension backend that is temporarily unavailable.
+			 *
+			 * @param WC_Product $product Product being read.
+			 * @throws WC_Data_Exception Always, with a typed error code and status.
+			 */
+			public function read( &$product ) {
+				throw new WC_Data_Exception( 'ext_backend_unavailable', 'Backend unavailable.', 503 );
+			}
+		};
+		$register    = static function ( $data_stores ) use ( $typed_store ) {
+			$data_stores['product-simple'] = $typed_store;
+			return $data_stores;
+		};
+		add_filter( 'woocommerce_data_stores', $register );
+
+		// Extension-backed product: the ID does not correspond to any WordPress post.
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/products/999999991' );
+		$request->set_url_params( array( 'id' => 999999991 ) );
+		$request->set_body_params( array( 'type' => 'simple' ) );
+
+		$this->expectException( WC_Data_Exception::class );
+		$this->expectExceptionMessage( 'Backend unavailable.' );
+
+		$this->invoke_prepare( $request, false );
+	}
+
+	/**
+	 * @testdox Generic exceptions from extension-backed product stores are rethrown instead of becoming a 404.
+	 */
+	public function test_prepare_object_rethrows_generic_exception_for_extension_backed_product(): void {
+		$failing_store = new class() extends WC_Product_Data_Store_CPT {
+			/**
+			 * Simulate a transient failure in an extension backend that stores products outside wp_posts.
+			 *
+			 * @param WC_Product $product Product being read.
+			 * @throws RuntimeException Always, simulating a temporary outage.
+			 */
+			public function read( &$product ) {
+				throw new RuntimeException( 'Remote backend timeout.' );
+			}
+		};
+		$register      = static function ( $data_stores ) use ( $failing_store ) {
+			$data_stores['product-simple'] = $failing_store;
+			return $data_stores;
+		};
+		add_filter( 'woocommerce_data_stores', $register );
+
+		// Extension-backed product: the ID has no corresponding WordPress post.
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/products/999999992' );
+		$request->set_url_params( array( 'id' => 999999992 ) );
+		$request->set_body_params( array( 'type' => 'simple' ) );
+
+		$this->expectException( RuntimeException::class );
+		$this->expectExceptionMessage( 'Remote backend timeout.' );
+
+		$this->invoke_prepare( $request, false );
+	}
+
+	/**
+	 * @testdox An extension store reusing the core invalid-product message is rethrown instead of becoming a 404.
+	 */
+	public function test_prepare_object_rethrows_extension_exception_reusing_core_message(): void {
+		$mimicking_store = new class() extends WC_Product_Data_Store_CPT {
+			/**
+			 * Simulate an extension store that reuses the core failure message for its own failures.
+			 *
+			 * @param WC_Product $product Product being read.
+			 * @throws RuntimeException Always, with the core store's message text.
+			 */
+			public function read( &$product ) {
+				throw new RuntimeException( 'Invalid product.' );
+			}
+		};
+		$register        = static function ( $data_stores ) use ( $mimicking_store ) {
+			$data_stores['product-simple'] = $mimicking_store;
+			return $data_stores;
+		};
+		add_filter( 'woocommerce_data_stores', $register );
+
+		// Extension-backed product: the ID has no corresponding WordPress post.
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/products/999999993' );
+		$request->set_url_params( array( 'id' => 999999993 ) );
+		$request->set_body_params( array( 'type' => 'simple' ) );
+
+		$this->expectException( RuntimeException::class );
+
+		$this->invoke_prepare( $request, false );
+	}
+
+	/**
+	 * @testdox The invalid-product error keeps one code but distinguishes variation targets in its message.
+	 */
+	public function test_invalid_product_id_error_distinguishes_variations_in_message(): void {
+		$error_method = new ReflectionMethod( $this->endpoint, 'get_invalid_product_id_error' );
+		$error_method->setAccessible( true );
+
+		$variation_error = $error_method->invoke( $this->endpoint, true );
+		$generic_error   = $error_method->invoke( $this->endpoint, false );
+
+		$this->assertEquals( 'woocommerce_rest_invalid_product_id', $variation_error->get_error_code() );
+		$this->assertEquals( 'woocommerce_rest_invalid_product_id', $generic_error->get_error_code() );
+		$this->assertStringContainsString( 'variations', $variation_error->get_error_message() );
+		$this->assertStringNotContainsString( 'variations', $generic_error->get_error_message() );
+	}
+
+	/**
+	 * @testdox A falsy type value in a batch item preserves the stored product type instead of causing a fatal error.
+	 */
+	public function test_batch_update_with_falsy_type_preserves_stored_type(): void {
+		$variable_product = WC_Helper_Product::create_variation_product();
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/products/batch' );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body(
+			wp_json_encode(
+				array(
+					'update' => array(
+						array(
+							'id'   => $variable_product->get_id(),
+							'type' => '',
+							'name' => 'Renamed via falsy-type batch',
+						),
+					),
+				)
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 200, $response->get_status() );
+		$data = $response->get_data();
+		$this->assertArrayNotHasKey( 'error', $data['update'][0], 'A falsy type should not fail the item' );
+		$this->assertEquals( 'Renamed via falsy-type batch', $data['update'][0]['name'] );
+		$this->assertInstanceOf( WC_Product_Variable::class, wc_get_product( $variable_product->get_id() ), 'The stored product type must be preserved' );
+	}
+
+	/**
+	 * @testdox A stored type whose class is registered only through woocommerce_product_class keeps that class.
+	 */
+	public function test_falsy_type_preserves_filter_registered_product_class(): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		add_filter(
+			'woocommerce_product_type_query',
+			static function ( $override, $product_id ) use ( $product ) {
+				return $product->get_id() === $product_id ? 'acme-widget' : $override;
+			},
+			10,
+			2
+		);
+		add_filter(
+			'woocommerce_product_class',
+			static function ( $classname, $product_type ) {
+				return 'acme-widget' === $product_type ? WC_Product_Grouped::class : $classname;
+			},
+			10,
+			2
+		);
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/products/' . $product->get_id() );
+		$request->set_url_params( array( 'id' => $product->get_id() ) );
+		$request->set_body_params( array( 'type' => '' ) );
+
+		$result = $this->invoke_prepare( $request, false );
+
+		$this->assertInstanceOf( WC_Product_Grouped::class, $result, 'The filter-registered class must win over the WC_Product_Simple fallback' );
+	}
+
+	/**
+	 * @testdox A non-string stored product type falls back to a simple product instead of causing a fatal error.
+	 */
+	public function test_non_string_stored_product_type_does_not_fatal(): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		add_filter(
+			'woocommerce_product_type_query',
+			static function ( $override, $product_id ) use ( $product ) {
+				return $product->get_id() === $product_id ? array( ProductType::SIMPLE ) : $override;
+			},
+			10,
+			2
+		);
+
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/products/' . $product->get_id() );
+		$request->set_url_params( array( 'id' => $product->get_id() ) );
+		$request->set_body_params( array( 'type' => '' ) );
+
+		$result = $this->invoke_prepare( $request, false );
+
+		$this->assertInstanceOf( WC_Product_Simple::class, $result );
+		$this->assertSame( $product->get_id(), $result->get_id() );
+	}
+
+	/**
+	 * @testdox Product constructor exceptions are rethrown when the product still exists.
+	 */
+	public function test_prepare_object_rethrows_unexpected_constructor_exception(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$request = new WP_REST_Request( 'PUT', '/wc/v3/products/' . $product->get_id() );
+		$request->set_url_params( array( 'id' => $product->get_id() ) );
+		$request->set_body_params( array( 'type' => 'simple' ) );
+
+		$throw_exception = function ( $product_id ) use ( $product ) {
+			if ( $product->get_id() === $product_id ) {
+				throw new Exception( 'Simulated unexpected read failure.' );
+			}
+		};
+		add_action( 'woocommerce_product_read', $throw_exception );
+
+		$this->expectException( Exception::class );
+		$this->expectExceptionMessage( 'Simulated unexpected read failure.' );
+
+		$this->invoke_prepare( $request, false );
+	}
+
+	/**
+	 * @testdox Duplicating a product using a variation ID and a type param returns an error response instead of a fatal error.
+	 */
+	public function test_duplicate_with_variation_id_and_type_returns_error_response(): void {
+		$variable_product = WC_Helper_Product::create_variation_product();
+		$variation_id     = $variable_product->get_children()[0];
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/products/' . $variation_id . '/duplicate' );
+		$request->set_body_params( array( 'type' => 'simple' ) );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 404, $response->get_status(), 'Variations should be handled by the variations endpoint.' );
+		$this->assertSame( 'woocommerce_rest_invalid_product_id', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox Duplicating a product using a variation ID returns the variation endpoint error instead of an empty product.
+	 */
+	public function test_duplicate_with_variation_id_returns_error_response(): void {
+		$variable_product = WC_Helper_Product::create_variation_product();
+		$variation_id     = $variable_product->get_children()[0];
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/products/' . $variation_id . '/duplicate' );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 404, $response->get_status(), 'Duplicating a variation should return the variations endpoint error.' );
+		$this->assertSame( 'woocommerce_rest_invalid_product_id', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox Batch updates continue processing valid products when a variation is rejected.
+	 */
+	public function test_batch_update_handles_variation_error_per_item(): void {
+		$product          = WC_Helper_Product::create_simple_product();
+		$variable_product = WC_Helper_Product::create_variation_product();
+		$variation_id     = $variable_product->get_children()[0];
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/products/batch' );
+		$request->set_body_params(
+			array(
+				'update' => array(
+					array(
+						'id'   => $variation_id,
+						'type' => 'simple',
+					),
+					array(
+						'id'   => $product->get_id(),
+						'name' => 'Updated in batch',
+					),
+				),
+			)
+		);
+
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 'woocommerce_rest_invalid_product_id', $data['update'][0]['error']['code'] );
+		$this->assertSame( 'Updated in batch', $data['update'][1]['name'] );
+		$this->assertSame( 'Updated in batch', wc_get_product( $product->get_id() )->get_name() );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
index 3412f18e071..39283df1719 100644
--- a/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/RestApi/Routes/V4/Products/ProductsControllerTest.php
@@ -2037,6 +2037,22 @@ class ProductsControllerTest extends WC_Unit_Test_Case {
 		$this->assertEquals( ProductStatus::DRAFT, $duplicated_product->get_status() );
 	}

+	/**
+	 * @testdox Duplicating a product using a variation ID and a type param returns an error response instead of a fatal error.
+	 */
+	public function test_duplicate_with_variation_id_and_type_returns_error_response(): void {
+		$variable_product = WC_Helper_Product::create_variation_product();
+		$variation_id     = $variable_product->get_children()[0];
+
+		$request = new WP_REST_Request( 'POST', '/wc/v4/products/' . $variation_id . '/duplicate' );
+		$request->set_body_params( array( 'type' => 'simple' ) );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 404, $response->get_status(), 'Variations should be handled by the variations endpoint.' );
+		$this->assertSame( 'woocommerce_rest_invalid_product_id', $response->get_data()['code'] );
+	}
+
 	/**
 	 * Test the duplicate product endpoint with variable products.
 	 */