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 "%1$s" 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' );
+ }
}