Commit 7bb51d1fad2 for woocommerce

commit 7bb51d1fad28b10cb7e7cf46acf40116fb14208e
Author: Albert Juhé Lluveras <contact@albertjuhe.com>
Date:   Tue Oct 27 08:28:38 2026 +0100

    Product Button block: call woocommerce_add_to_cart_quantity when adding products to cart instead of on render (#70139)

    * Add changelog

    * Update docs

    * Product Button block: call woocommerce_add_to_cart_quantity when adding products to cart instead of on render

    * Add tests

    * Remove unnecessary docblock

    * Harden test

    * Remove unnecessary tests

    * Update woocommerce_loop_add_to_cart_args docblocks

    * Add missing since in docblock

    * Add back tests util

    * PHPStan

    * Fix tests

    * Remove leftover after merge

diff --git a/plugins/woocommerce/changelog/fix-56454-iapi-product-button-woocommerce_add_to_cart_quantity-on-add b/plugins/woocommerce/changelog/fix-56454-iapi-product-button-woocommerce_add_to_cart_quantity-on-add
new file mode 100644
index 00000000000..d4c6596993f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-56454-iapi-product-button-woocommerce_add_to_cart_quantity-on-add
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Product Button block: call woocommerce_add_to_cart_quantity when adding products to cart instead of on render
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 6e576978659..66485a369f1 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
@@ -372,23 +372,15 @@ apply_filters( 'woocommerce_add_to_cart_form_action', string $action_url )
 ## woocommerce_add_to_cart_quantity


-Filters the change the quantity to add to cart.
+This filter is documented in includes/class-wc-cart.php.

 ```php
-apply_filters( 'woocommerce_add_to_cart_quantity', int|float $default_quantity, int $product_id, int $variation_id )
+apply_filters( 'woocommerce_add_to_cart_quantity' )
 ```

-### Parameters
-
-| Argument | Type | Description |
-| -------- | ---- | ----------- |
-| $default_quantity | int, float | The default quantity. |
-| $product_id | int | The product id. |
-| $variation_id | int | The variation ID. Always 0 in this context. |
-
 ### Source

-- [Blocks/BlockTypes/ProductButton.php](../../../../../../src/Blocks/BlockTypes/ProductButton.php)
+- [StoreApi/Utilities/CartController.php](../../../../../../src/StoreApi/Utilities/CartController.php)

 ---

@@ -1219,19 +1211,12 @@ Allows backward compatibility with the `rest_request_after_callbacks` filter by
 ## woocommerce_loop_add_to_cart_args


-Allow filtering of the add to cart button arguments.
+This filter is documented in includes/wc-template-functions.php.

 ```php
-apply_filters( 'woocommerce_loop_add_to_cart_args', array $args, \WC_Product $product )
+apply_filters( 'woocommerce_loop_add_to_cart_args' )
 ```

-### Parameters
-
-| Argument | Type | Description |
-| -------- | ---- | ----------- |
-| $args | array | Button arguments, with a `class` string and an `attributes` array. |
-| $product | \WC_Product | Product the button is rendered for. |
-
 ### Source

 - [Blocks/BlockTypes/ProductButton.php](../../../../../../src/Blocks/BlockTypes/ProductButton.php)
diff --git a/plugins/woocommerce/includes/wc-template-functions.php b/plugins/woocommerce/includes/wc-template-functions.php
index 26ba5c94168..d8c7f934003 100644
--- a/plugins/woocommerce/includes/wc-template-functions.php
+++ b/plugins/woocommerce/includes/wc-template-functions.php
@@ -1491,10 +1491,10 @@ if ( ! function_exists( 'woocommerce_template_loop_add_to_cart' ) ) {
 		}

 		/**
-		 * Filter to customize the arguments for the add to cart template for the loop.
-		 *
-		 * @param array $args Arguments.
+		 * Filter to customize the arguments for the add to cart button in the loop.
 		 *
+		 * @param array       $args    Arguments.
+		 * @param \WC_Product $product Product object.
 		 * @since 2.4.11
 		 */
 		$args = apply_filters( 'woocommerce_loop_add_to_cart_args', wp_parse_args( $args, $defaults ), $product );
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index c6cc362c421..5cf3e639980 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -34770,7 +34770,7 @@ parameters:
 		-
 			message: '#^Expected 2 @param tags, found 1\.$#'
 			identifier: paramTag.count
-			count: 2
+			count: 1
 			path: includes/wc-template-functions.php

 		-
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
index d0159273158..580f70a04b0 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
@@ -118,41 +118,12 @@ class ProductButton extends AbstractBlock {
 			)
 		);

-		$default_quantity = 1;
-
-		if ( ! $is_descendant_of_add_to_cart_form ) {
-			/**
-			 * Filters the change the quantity to add to cart.
-			 *
-			 * @since 8.5.0
-			 * @since 11.0.0 Added the `$variation_id` parameter.
-			 * @param int|float $default_quantity The default quantity.
-			 * @param int       $product_id The product id.
-			 * @param int       $variation_id The variation ID. Always 0 in this context.
-			 */
-			$default_quantity = apply_filters( 'woocommerce_add_to_cart_quantity', $default_quantity, $product->get_id(), 0 );
-		}
-
 		$add_to_cart_text = null !== $product->add_to_cart_text() ? $product->add_to_cart_text() : __( 'Add to cart', 'woocommerce' );

 		if ( $is_descendant_of_add_to_cart_form && null !== $product->single_add_to_cart_text() ) {
 			$add_to_cart_text = $product->single_add_to_cart_text();
 		}

-		$context = array(
-			'quantityToAdd'    => $default_quantity,
-			'addToCartText'    => $add_to_cart_text,
-			'tempQuantity'     => $number_of_items_in_cart,
-			'animationStatus'  => 'IDLE',
-			'inTheCartText'    => $this->get_in_the_cart_text( $product ),
-			'noticeId'         => '',
-			'hasPressedButton' => false,
-		);
-
-		if ( $product->is_type( ProductType::GROUPED ) ) {
-			$context['groupedProductIds'] = $product->get_children();
-		}
-
 		$attributes = array(
 			'type' => $is_descendant_of_add_to_cart_form ? 'submit' : 'button',
 		);
@@ -174,6 +145,48 @@ class ProductButton extends AbstractBlock {
 			}
 		}

+		/**
+		 * This filter is documented in includes/wc-template-functions.php.
+		 *
+		 * @since 2.4.11
+		 */
+		$args = apply_filters(
+			'woocommerce_loop_add_to_cart_args',
+			array(
+				'quantity'   => 1,
+				'class'      => $html_classes,
+				'attributes' => array_merge(
+					$attributes,
+					array(
+						'data-product_id'  => $product->get_id(),
+						'data-product_sku' => $product->get_sku(),
+						'aria-label'       => ! $is_descendant_of_add_to_cart_form || 'simple' === $product->get_type() ? $product->add_to_cart_description() : null,
+					),
+				),
+			),
+			$product
+		);
+
+		// Single-product forms take their quantity from the quantity input.
+		$default_quantity = 1;
+		if ( ! $is_descendant_of_add_to_cart_form && isset( $args['quantity'] ) && is_numeric( $args['quantity'] ) ) {
+			$default_quantity = wc_stock_amount( (float) $args['quantity'] );
+		}
+
+		$context = array(
+			'quantityToAdd'    => $default_quantity,
+			'addToCartText'    => $add_to_cart_text,
+			'tempQuantity'     => $number_of_items_in_cart,
+			'animationStatus'  => 'IDLE',
+			'inTheCartText'    => $this->get_in_the_cart_text( $product ),
+			'noticeId'         => '',
+			'hasPressedButton' => false,
+		);
+
+		if ( $product->is_type( ProductType::GROUPED ) ) {
+			$context['groupedProductIds'] = $product->get_children();
+		}
+
 		wp_interactivity_config(
 			'woocommerce',
 			array(
@@ -216,30 +229,6 @@ class ProductButton extends AbstractBlock {
 			)
 		);

-		/**
-		 * Allow filtering of the add to cart button arguments.
-		 *
-		 * @since 9.7.0
-		 *
-		 * @param array       $args    Button arguments, with a `class` string and an `attributes` array.
-		 * @param \WC_Product $product Product the button is rendered for.
-		 */
-		$args = apply_filters(
-			'woocommerce_loop_add_to_cart_args',
-			array(
-				'class'      => $html_classes,
-				'attributes' => array_merge(
-					$attributes,
-					array(
-						'data-product_id'  => $product->get_id(),
-						'data-product_sku' => $product->get_sku(),
-						'aria-label'       => ! $is_descendant_of_add_to_cart_form || 'simple' === $product->get_type() ? $product->add_to_cart_description() : null,
-					),
-				),
-			),
-			$product
-		);
-
 		// The accessible name must keep containing the visible text once it changes to "N in cart" (WCAG 2.5.3).
 		if ( isset( $args['attributes']['aria-label'] ) ) {
 			$args['attributes']['aria-label'] = wp_strip_all_tags( $args['attributes']['aria-label'] );
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/CartController.php b/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
index cf449024d4e..e70c77a0cf9 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
@@ -108,15 +108,8 @@ class CartController {
 			]
 		);

-		$request = $this->filter_request_data( $this->parse_variation_data( $request ) );
-		$product = $this->get_product_for_cart( $request );
-		$cart_id = $cart->generate_cart_id(
-			$this->get_product_id( $product ),
-			$this->get_variation_id( $product ),
-			$request['variation'],
-			$request['cart_item_data']
-		);
-
+		$request         = $this->parse_variation_data( $request );
+		$product         = $this->get_product_for_cart( $request );
 		$quantity_limits = new QuantityLimits();

 		// If quantity was not passed, it should default to the minimum allowed quantity.
@@ -124,6 +117,29 @@ class CartController {
 			$request['quantity'] = $quantity_limits->get_add_to_cart_limits( $product )['minimum'];
 		}

+		$product_id   = $this->get_product_id( $product );
+		$variation_id = $this->get_variation_id( $product );
+
+		/**
+		 * This filter is documented in includes/class-wc-cart.php.
+		 *
+		 * @since 3.1.0
+		 */
+		$request['quantity'] = apply_filters(
+			'woocommerce_add_to_cart_quantity',
+			$request['quantity'],
+			$product_id,
+			$variation_id
+		);
+
+		$request = $this->filter_request_data( $request );
+		$cart_id = $cart->generate_cart_id(
+			$product_id,
+			$variation_id,
+			$request['variation'],
+			$request['cart_item_data']
+		);
+
 		$this->validate_add_to_cart( $product, $request );
 		$existing_cart_id = $cart->find_product_in_cart( $cart_id );
 		$request_quantity = wc_stock_amount( $request['quantity'] );
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php
index 67255b8b034..9b12dab0a9a 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php
@@ -62,7 +62,7 @@ class ProductButton extends \WP_UnitTestCase {
 	 */
 	private function render_product_button( \WC_Product $product ): string {
 		return do_blocks(
-			'<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><div class="wp-block-woocommerce-single-product"><!-- wp:woocommerce/product-button /--></div><!-- /wp:woocommerce/single-product -->'
+			'<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><div class="product-button-test-wrapper"><!-- wp:woocommerce/product-button /--></div><!-- /wp:woocommerce/single-product -->'
 		);
 	}

@@ -210,4 +210,53 @@ class ProductButton extends \WP_UnitTestCase {

 		return $processor->get_attribute( 'aria-label' );
 	}
+
+	/**
+	 * @testdox Product Button starts with a quantity of 1.
+	 */
+	public function test_product_button_defaults_quantity_to_one(): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		$this->assertSame( 1, $this->get_quantity_to_add( $this->render_product_button( $product ) ) );
+	}
+
+	/**
+	 * @testdox woocommerce_loop_add_to_cart_args sets the Product Button quantity.
+	 */
+	public function test_loop_add_to_cart_args_sets_initial_quantity(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$filter  = static function ( array $args ): array {
+			$args['quantity'] = 4;
+			return $args;
+		};
+
+		add_filter( 'woocommerce_loop_add_to_cart_args', $filter );
+		try {
+			$markup = $this->render_product_button( $product );
+		} finally {
+			remove_filter( 'woocommerce_loop_add_to_cart_args', $filter );
+		}
+
+		$this->assertSame( 4, $this->get_quantity_to_add( $markup ) );
+	}
+
+	/**
+	 * Read quantityToAdd from the rendered Product Button context.
+	 *
+	 * @param string $markup Rendered block markup.
+	 * @return int
+	 */
+	private function get_quantity_to_add( string $markup ) {
+		$processor = new \WP_HTML_Tag_Processor( $markup );
+		$this->assertTrue(
+			$processor->next_tag( array( 'class_name' => 'wc-block-components-product-button' ) ),
+			'Product Button wrapper was not rendered.'
+		);
+
+		$context = json_decode( (string) $processor->get_attribute( 'data-wp-context' ), true );
+		$this->assertIsArray( $context );
+		$this->assertArrayHasKey( 'quantityToAdd', $context );
+
+		return $context['quantityToAdd'];
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/CartControllerTests.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/CartControllerTests.php
index ba8a04cef5e..475c4302f30 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/CartControllerTests.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/CartControllerTests.php
@@ -285,4 +285,87 @@ class CartControllerTests extends \WC_Unit_Test_Case {

 		remove_all_filters( 'woocommerce_cart_shipping_packages' );
 	}
+
+	/**
+	 * @testdox Add to cart applies woocommerce_add_to_cart_quantity to the requested quantity.
+	 */
+	public function test_add_to_cart_applies_quantity_filter(): void {
+		$fixtures = new FixtureData();
+		$product  = $fixtures->get_simple_product(
+			array(
+				'name'          => 'Quantity Filter Product',
+				'regular_price' => 10,
+			)
+		);
+
+		$captured = array();
+		$filter   = static function ( $quantity, $product_id, $variation_id ) use ( &$captured ) {
+			$captured = array( $quantity, $product_id, $variation_id );
+			return 4;
+		};
+
+		add_filter( 'woocommerce_add_to_cart_quantity', $filter, 10, 3 );
+		try {
+			$cart_id = ( new CartController() )->add_to_cart(
+				array(
+					'id'       => $product->get_id(),
+					'quantity' => 1,
+				)
+			);
+		} finally {
+			remove_filter( 'woocommerce_add_to_cart_quantity', $filter, 10 );
+		}
+
+		$this->assertSame( array( 1, $product->get_id(), 0 ), $captured );
+		$this->assertSame( 4, wc()->cart->get_cart_item( $cart_id )['quantity'] );
+	}
+
+	/**
+	 * @testdox Add to cart passes the variation ID to woocommerce_add_to_cart_quantity.
+	 */
+	public function test_add_to_cart_quantity_filter_receives_variation_id(): void {
+		$product   = \WC_Helper_Product::create_variation_product();
+		$variation = null;
+		foreach ( $product->get_available_variations() as $available_variation ) {
+			if ( ! in_array( '', $available_variation['attributes'], true ) ) {
+				$variation = $available_variation;
+				break;
+			}
+		}
+		$this->assertIsArray( $variation, 'Expected a variation with every attribute set.' );
+
+		$variation_request = array();
+		foreach ( $variation['attributes'] as $name => $value ) {
+			$attribute = $name;
+			if ( str_starts_with( $attribute, 'attribute_' ) ) {
+				$attribute = substr( $attribute, strlen( 'attribute_' ) );
+			}
+			$variation_request[] = array(
+				'attribute' => $attribute,
+				'value'     => $value,
+			);
+		}
+
+		$captured = array();
+		$filter   = static function ( $quantity, $product_id, $variation_id ) use ( &$captured ) {
+			$captured = array( $quantity, $product_id, $variation_id );
+			return 4;
+		};
+
+		add_filter( 'woocommerce_add_to_cart_quantity', $filter, 10, 3 );
+		try {
+			$cart_id = ( new CartController() )->add_to_cart(
+				array(
+					'id'        => $variation['variation_id'],
+					'quantity'  => 1,
+					'variation' => $variation_request,
+				)
+			);
+		} finally {
+			remove_filter( 'woocommerce_add_to_cart_quantity', $filter, 10 );
+		}
+
+		$this->assertSame( array( 1, $product->get_id(), (int) $variation['variation_id'] ), $captured );
+		$this->assertSame( 4, wc()->cart->get_cart_item( $cart_id )['quantity'] );
+	}
 }