Commit 827767a8f31 for woocommerce

commit 827767a8f3167a57fffa492275607aa16f9e5435
Author: Raluca Stan <ralucastn@gmail.com>
Date:   Wed Sep 9 13:52:28 2026 +0200

    Add a cart item quantity validation filter to the Store API (#67928)

    * Update QuantityLimits.php - Filters the cart item quantity validation

    Filters the quantity validation for a cart item being updated via the Store API.

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Change return type in filter documentation

    * Change param type in filter documentation

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Fix linting

    * Update changelog

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Update docs with new filter

    * Add changefile(s) from automation for the following project(s): woocommerce-blocks, woocommerce

    * Update 45489-yoancutillas-patch-1

    * Add changefile(s) from automation for the following project(s): woocommerce-blocks, woocommerce

    * Add changefile(s) from automation for the following project(s): woocommerce-blocks, woocommerce

    * Update since tag

    * Fix hook docs formatting regressions in filters.md

    * Fix quantity filter docblock types and since version

    * Update changelog entry significance and message

    * Update plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md

    Co-authored-by: Raluca Stan <ralucastn@gmail.com>

    * Update QuantityLimits.php

    Validate the new woocommerce_store_api_validate_cart_item_quantity filter to make sure it is either true or WP_Error

    * Remove trailing whitespace from blank lines in validate_cart_item_quantity

    * Validate the shape of WP_Error returned by the quantity validation filter

    * Update quantity validation filter docs to match the update-only scope

    * Add unit tests for the cart item quantity validation filter

    * Rename quantity validation filter and rework return handling

    * Pass the product object to the quantity validation filter

    * Fix filter docs return type and document the non-product bypass

    * Rename changelog entry to match the new branch

    * Preserve filter errors that carry an integer code

    WP_Error accepts string or integer codes, but the shape check only let string
    codes through, so an extension returning new WP_Error( 123, ... ) had its error
    swapped for the generic invalid_quantity one. Accept integers too, and keep
    treating an empty code the way WP_Error itself does.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

    * Regenerate the Blocks hook docs

    The docs pipeline was repaired in #67839, so the filters.md entry for this hook
    now comes from build:docs instead of being hand-written.

    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

    * Pass quantity filter errors through untouched and ignore false

    * Regenerate the Blocks hook docs

    ---------

    Co-authored-by: yoancutillas <yoan.cutillas@gmail.com>
    Co-authored-by: github-actions <github-actions@github.com>
    Co-authored-by: Alex Florisca <alex.florisca@automattic.com>
    Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
    Co-authored-by: Seghir Nadir <nadir.seghir@gmail.com>

diff --git a/plugins/woocommerce/changelog/add-store-api-cart-item-quantity-validation-filter b/plugins/woocommerce/changelog/add-store-api-cart-item-quantity-validation-filter
new file mode 100644
index 00000000000..edb2fb5c708
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-store-api-cart-item-quantity-validation-filter
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a woocommerce_store_api_cart_item_quantity_validation filter so extensions can validate cart item quantity changes made via the Store API.
diff --git a/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md b/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
index a9e2833cdd7..2b5c2d9ea91 100644
--- a/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
+++ b/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
@@ -70,6 +70,7 @@
 - [woocommerce_sortable_taxonomies](#woocommerce_sortable_taxonomies)
 - [woocommerce_store_api_add_to_cart_data](#woocommerce_store_api_add_to_cart_data)
 - [woocommerce_store_api_cart_item_images](#woocommerce_store_api_cart_item_images)
+- [woocommerce_store_api_cart_item_quantity_validation](#woocommerce_store_api_cart_item_quantity_validation)
 - [woocommerce_store_api_disable_nonce_check](#woocommerce_store_api_disable_nonce_check)
 - [`woocommerce_store_api_product_quantity_{$value_type}`](#woocommerce_store_api_product_quantity_value_type)
 - [woocommerce_store_api_rate_limit_id](#woocommerce_store_api_rate_limit_id)
@@ -1732,6 +1733,41 @@ This hook allows the cart item images to be changed. This is specific to the car

 ---

+## woocommerce_store_api_cart_item_quantity_validation
+
+
+Filters the validation result for a cart item quantity being updated via the Store API.
+
+```php
+apply_filters( 'woocommerce_store_api_cart_item_quantity_validation', true $valid, int|float $quantity, \WC_Product $product, array $cart_item )
+```
+
+### Description
+
+Return a \WP_Error to reject the new quantity; the Store API sends its code and message in a 400 response. Throwing a RouteException works too. Any other return value, including false, is ignored and the quantity is accepted. Notices added with wc_add_notice() are not read here. Core validation failures (min, max, multiple_of, read-only), and cart items whose data key is not a WC_Product, return early and never reach this filter.
+
+This does not run when a product is first added to the cart; use the woocommerce_store_api_validate_add_to_cart action for that. When an already-in-cart item is topped up, $quantity is the new total while $cart_item['quantity'] is still the pre-existing quantity.
+
+### Parameters
+
+| Argument | Type | Description |
+| -------- | ---- | ----------- |
+| $valid | true | Always true; core validation failures bypass this filter. |
+| $quantity | int, float | The new quantity, already normalized through wc_stock_amount(). |
+| $product | \WC_Product | The product object. |
+| $cart_item | array | Cart item. |
+
+### Returns
+
+
+`\WP_Error, true`
+
+### Source
+
+- [StoreApi/Utilities/QuantityLimits.php](../../../../../../src/StoreApi/Utilities/QuantityLimits.php)
+
+---
+
 ## woocommerce_store_api_disable_nonce_check


diff --git a/plugins/woocommerce/src/StoreApi/Utilities/QuantityLimits.php b/plugins/woocommerce/src/StoreApi/Utilities/QuantityLimits.php
index ff57de9a9f7..3029fe0d4ce 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/QuantityLimits.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/QuantityLimits.php
@@ -186,7 +186,32 @@ final class QuantityLimits {
 			return new \WP_Error( 'invalid_quantity', sprintf( __( 'The quantity of &quot;%1$s&quot; must be a multiple of %2$s', 'woocommerce' ), $product->get_name(), $limits['multiple_of'] ) );
 		}

-		return true;
+		/**
+		 * Filters the validation result for a cart item quantity being updated via the Store API.
+		 *
+		 * Return a \WP_Error to reject the new quantity; the Store API sends its code and message in a 400
+		 * response. Throwing a RouteException works too. Any other return value, including false, is
+		 * ignored and the quantity is accepted. Notices added with wc_add_notice() are not read here.
+		 * Core validation failures (min, max, multiple_of, read-only), and cart items whose data key is
+		 * not a WC_Product, return early and never reach this filter.
+		 *
+		 * This does not run when a product is first added to the cart; use the
+		 * woocommerce_store_api_validate_add_to_cart action for that. When an already-in-cart item is
+		 * topped up, $quantity is the new total while $cart_item['quantity'] is still the pre-existing
+		 * quantity.
+		 *
+		 * @since 11.2.0
+		 *
+		 * @param true        $valid     Always true; core validation failures bypass this filter.
+		 * @param int|float   $quantity  The new quantity, already normalized through wc_stock_amount().
+		 * @param \WC_Product $product   The product object.
+		 * @param array       $cart_item Cart item.
+		 * @return \WP_Error|true
+		 */
+		$valid = apply_filters( 'woocommerce_store_api_cart_item_quantity_validation', true, $quantity, $product, $cart_item );
+
+		// Like the other filters in this class, unexpected return values fall back to the original value.
+		return is_wp_error( $valid ) ? $valid : true;
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/QuantityLimitsTests.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/QuantityLimitsTests.php
index 7eeebc5b9b1..fea004d5063 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/QuantityLimitsTests.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/QuantityLimitsTests.php
@@ -10,6 +10,14 @@ use Automattic\WooCommerce\StoreApi\Utilities\QuantityLimits;
  * QuantityLimitsTests class.
  */
 class QuantityLimitsTests extends \WC_Unit_Test_Case {
+	/**
+	 * Clean up filters registered by tests, even when a test fails mid-way.
+	 */
+	public function tearDown(): void {
+		remove_all_filters( 'woocommerce_store_api_cart_item_quantity_validation' );
+		parent::tearDown();
+	}
+
 	/**
 	 * Enable float support for tests.
 	 */
@@ -668,4 +676,133 @@ class QuantityLimitsTests extends \WC_Unit_Test_Case {
 		// Test with invalid rounding function (should default to 'round').
 		$this->assertEquals( 6.0, $quantity_limits->limit_to_multiple( 5.3, 1.5, 'invalid' ), 'Invalid rounding function should default to round' );
 	}
+
+	/**
+	 * Create a simple product wrapped in a cart item array.
+	 *
+	 * @return array Cart item with the product under the data key.
+	 */
+	private function get_validation_cart_item() {
+		$fixtures = new FixtureData();
+		$product  = $fixtures->get_simple_product(
+			array(
+				'name'          => 'Test Product',
+				'regular_price' => 10,
+			)
+		);
+		$product->save();
+
+		return array(
+			'data' => $product,
+		);
+	}
+
+	/**
+	 * @testdox Should return the WP_Error produced by a woocommerce_store_api_cart_item_quantity_validation callback.
+	 */
+	public function test_validate_cart_item_quantity_filter_returns_wp_error(): void {
+		$cart_item = $this->get_validation_cart_item();
+		$sut       = new QuantityLimits();
+
+		add_filter(
+			'woocommerce_store_api_cart_item_quantity_validation',
+			function () {
+				return new \WP_Error( 'custom_rejection', 'Rejected by extension' );
+			}
+		);
+
+		$result = $sut->validate_cart_item_quantity( 3, $cart_item );
+
+		$this->assertInstanceOf( 'WP_Error', $result, 'A WP_Error returned by the filter should be passed through' );
+		$this->assertSame( 'custom_rejection', $result->get_error_code(), 'The filter callback error code should be preserved' );
+		$this->assertSame( 'Rejected by extension', $result->get_error_message(), 'The filter callback error message should be preserved' );
+	}
+
+	/**
+	 * @testdox Should return true when a woocommerce_store_api_cart_item_quantity_validation callback returns true.
+	 */
+	public function test_validate_cart_item_quantity_filter_returns_true(): void {
+		$cart_item = $this->get_validation_cart_item();
+		$sut       = new QuantityLimits();
+
+		$received_args = array();
+		add_filter(
+			'woocommerce_store_api_cart_item_quantity_validation',
+			function ( $valid, $quantity, $product, $filtered_cart_item ) use ( &$received_args ) {
+				$received_args = array( $quantity, $product, $filtered_cart_item );
+				return $valid;
+			},
+			10,
+			4
+		);
+
+		$this->assertTrue( $sut->validate_cart_item_quantity( 3, $cart_item ), 'A true filter return should be accepted' );
+		$this->assertSame( 3, $received_args[0], 'The callback should receive the new quantity' );
+		$this->assertSame( $cart_item['data']->get_id(), $received_args[1]->get_id(), 'The callback should receive the product object' );
+		$this->assertSame( $cart_item, $received_args[2], 'The callback should receive the cart item' );
+	}
+
+	/**
+	 * Data provider of unexpected filter return values.
+	 *
+	 * @return array
+	 */
+	public function unexpected_filter_return_values() {
+		return array(
+			'false'       => array( false ),
+			'null'        => array( null ),
+			'string'      => array( 'nope' ),
+			'truthy int'  => array( 1 ),
+			'empty array' => array( array() ),
+		);
+	}
+
+	/**
+	 * @testdox Should ignore unexpected filter return values and accept the quantity.
+	 * @dataProvider unexpected_filter_return_values
+	 *
+	 * @param mixed $filter_return_value Value returned by the filter callback.
+	 */
+	public function test_validate_cart_item_quantity_filter_unexpected_return_is_ignored( $filter_return_value ): void {
+		$cart_item = $this->get_validation_cart_item();
+		$sut       = new QuantityLimits();
+
+		add_filter(
+			'woocommerce_store_api_cart_item_quantity_validation',
+			function () use ( $filter_return_value ) {
+				return $filter_return_value;
+			}
+		);
+
+		$this->assertTrue( $sut->validate_cart_item_quantity( 3, $cart_item ), 'Unexpected filter return values should fall back to the original valid result' );
+	}
+
+	/**
+	 * @testdox Should return the core WP_Error even when a permissive filter callback is attached.
+	 */
+	public function test_validate_cart_item_quantity_core_error_bypasses_filter(): void {
+		$cart_item = $this->get_validation_cart_item();
+		$product   = $cart_item['data'];
+		$product->set_manage_stock( true );
+		$product->set_stock_quantity( 10 );
+		$product->set_backorders( 'no' );
+		$product->save();
+
+		$sut = new QuantityLimits();
+
+		$filter_ran = false;
+		add_filter(
+			'woocommerce_store_api_cart_item_quantity_validation',
+			function () use ( &$filter_ran ) {
+				$filter_ran = true;
+				return true;
+			}
+		);
+
+		$result = $sut->validate_cart_item_quantity( 11, $cart_item );
+
+		$this->assertInstanceOf( 'WP_Error', $result, 'Core limit violations should return WP_Error regardless of filter callbacks' );
+		$this->assertStringContainsString( 'maximum', $result->get_error_message(), 'The core maximum-quantity error should win' );
+		$this->assertFalse( $filter_ran, 'The filter should not run when core validation fails' );
+	}
 }