Commit d8d0a13f3dd for woocommerce

commit d8d0a13f3ddd4dd61737657c840a374c70adbd9b
Author: Tom Cafferkey <tjcafferkey@gmail.com>
Date:   Sun Nov 15 12:35:03 2026 +0000

    Fix catalog add-to-cart minimum quantities (#70915)

    * Fix catalog add-to-cart minimum quantities

    * Add changelog entry for catalog minimum quantities

    * Fix catalog quantity consistency across buttons and links

    * Refactor catalog button data into ProductUtil

    * Preserve filtered add-to-cart URL quantities

    * Consolidate catalog quantity override tests

    * Respect sold-individually limits before URL quantities

    * Update plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php

    Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

    * Resolve quantities when rendering loop buttons directly

    * Refactor loop button preparation outside the template

    * Simplify loop button template arguments

    ---------

    Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/fix-46326-loop-minimum-quantity b/plugins/woocommerce/changelog/fix-46326-loop-minimum-quantity
new file mode 100644
index 00000000000..757b91486cc
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-46326-loop-minimum-quantity
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Make catalog add-to-cart buttons add the minimum quantity and show how many items will be added.
diff --git a/plugins/woocommerce/includes/class-wc-product-simple.php b/plugins/woocommerce/includes/class-wc-product-simple.php
index fb858443e71..26601a4d490 100644
--- a/plugins/woocommerce/includes/class-wc-product-simple.php
+++ b/plugins/woocommerce/includes/class-wc-product-simple.php
@@ -41,12 +41,16 @@ class WC_Product_Simple extends WC_Product {
 	 * @return string
 	 */
 	public function add_to_cart_url() {
+		$quantity   = $this->is_sold_individually() ? 1 : $this->get_min_purchase_quantity();
+		$query_args = array(
+			'add-to-cart' => $this->get_id(),
+			'quantity'    => $quantity > 1 ? $quantity : false,
+		);
+
 		$url = $this->is_purchasable() && $this->is_in_stock() ? remove_query_arg(
 			'added-to-cart',
 			add_query_arg(
-				array(
-					'add-to-cart' => $this->get_id(),
-				),
+				$query_args,
 				( function_exists( 'is_feed' ) && is_feed() ) || ( function_exists( 'is_404' ) && is_404() ) ? $this->get_permalink() : false
 			)
 		) : $this->get_permalink();
@@ -73,8 +77,16 @@ class WC_Product_Simple extends WC_Product {
 	public function add_to_cart_description() {
 		/* translators: %s: Product title */
 		$text = $this->is_purchasable() && $this->is_in_stock() ? __( 'Add to cart: &ldquo;%s&rdquo;', 'woocommerce' ) : __( 'Read more about &ldquo;%s&rdquo;', 'woocommerce' );
+		$text = sprintf( $text, $this->get_name() );

-		return apply_filters( 'woocommerce_product_add_to_cart_description', sprintf( $text, $this->get_name() ), $this );
+		/**
+		 * Filters the add to cart button description used in accessible labels.
+		 *
+		 * @param string            $text The button description.
+		 * @param WC_Product_Simple $product The product object.
+		 * @since 3.3.0
+		 */
+		return apply_filters( 'woocommerce_product_add_to_cart_description', $text, $this );
 	}

 	/**
diff --git a/plugins/woocommerce/includes/wc-template-functions.php b/plugins/woocommerce/includes/wc-template-functions.php
index a4bd5bcc307..37dd2c98868 100644
--- a/plugins/woocommerce/includes/wc-template-functions.php
+++ b/plugins/woocommerce/includes/wc-template-functions.php
@@ -1470,7 +1470,7 @@ if ( ! function_exists( 'woocommerce_template_loop_add_to_cart' ) ) {
 		}

 		$defaults = array(
-			'quantity'              => 1,
+			'quantity'              => wc_get_container()->get( \Automattic\WooCommerce\Internal\Utilities\ProductUtil::class )->get_catalog_add_to_cart_quantity( $product ),
 			'class'                 => implode(
 				' ',
 				array_filter(
@@ -1508,6 +1508,11 @@ if ( ! function_exists( 'woocommerce_template_loop_add_to_cart' ) ) {
 			$args['attributes']['aria-label'] = wp_strip_all_tags( $args['attributes']['aria-label'] );
 		}

+		$button_data = wc_get_container()->get( \Automattic\WooCommerce\Internal\Utilities\ProductUtil::class )->get_catalog_add_to_cart_button_data( $product, $args['quantity'] ?? null );
+		if ( ( $args['attributes']['aria-label'] ?? null ) === wp_strip_all_tags( $button_data['original_description'] ) ) {
+			$args['attributes']['aria-label'] = wp_strip_all_tags( $button_data['description'] );
+		}
+
 		$cart_redirect_after_add  = get_option( 'woocommerce_cart_redirect_after_add' ) === 'yes';
 		$ajax_add_to_cart_enabled = get_option( 'woocommerce_enable_ajax_add_to_cart' ) === 'yes';

@@ -1524,6 +1529,7 @@ if ( ! function_exists( 'woocommerce_template_loop_add_to_cart' ) ) {
 			$args['attributes']['role'] = 'button';
 		}

+		$args['button_data'] = $button_data;
 		wc_get_template( 'loop/add-to-cart.php', $args );
 	}
 }
diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/AbstractProductGrid.php b/plugins/woocommerce/src/Blocks/BlockTypes/AbstractProductGrid.php
index 80ef051ba68..09f6a87fec1 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/AbstractProductGrid.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/AbstractProductGrid.php
@@ -5,6 +5,7 @@ use Automattic\WooCommerce\Blocks\Utils\BlocksWpQuery;
 use Automattic\WooCommerce\StoreApi\SchemaController;
 use Automattic\WooCommerce\StoreApi\StoreApi;
 use Automattic\WooCommerce\Enums\ProductStockStatus;
+use Automattic\WooCommerce\Internal\Utilities\ProductUtil;

 /**
  * AbstractProductGrid class.
@@ -654,7 +655,7 @@ abstract class AbstractProductGrid extends AbstractDynamicBlock {
 	protected function get_add_to_cart( $product ) {
 		$attributes = array(
 			'aria-label'       => $product->add_to_cart_description(),
-			'data-quantity'    => '1',
+			'data-quantity'    => wc_get_container()->get( ProductUtil::class )->get_catalog_add_to_cart_quantity( $product ),
 			'data-product_id'  => $product->get_id(),
 			'data-product_sku' => $product->get_sku(),
 			'data-price'       => wc_get_price_to_display( $product ),
@@ -680,13 +681,17 @@ abstract class AbstractProductGrid extends AbstractDynamicBlock {
 		 *
 		 * @return array Returns an associative array derived from the default array passed as an argument and added the extra HTML attributes.
 		 */
-		$attributes = apply_filters( 'woocommerce_blocks_product_grid_add_to_cart_attributes', $attributes, $product );
+		$attributes  = apply_filters( 'woocommerce_blocks_product_grid_add_to_cart_attributes', $attributes, $product );
+		$button_data = wc_get_container()->get( ProductUtil::class )->get_catalog_add_to_cart_button_data( $product, $attributes['data-quantity'] ?? 1 );
+		if ( ( $attributes['aria-label'] ?? null ) === $button_data['original_description'] ) {
+			$attributes['aria-label'] = $button_data['description'];
+		}

 		return sprintf(
 			'<a href="%s" %s>%s</a>',
-			esc_url( $product->add_to_cart_url() ),
+			esc_url( $button_data['url'] ),
 			wc_implode_html_attributes( $attributes ),
-			esc_html( $product->add_to_cart_text() )
+			esc_html( $button_data['text'] )
 		);
 	}

diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
index 580f70a04b0..de4edc6ee77 100644
--- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
+++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductButton.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\Blocks\BlockTypes\AddToCartWithOptions\Utils;
 use Automattic\WooCommerce\Blocks\Utils\BlocksSharedState;
 use Automattic\WooCommerce\Enums\ProductType;
 use Automattic\WooCommerce\Internal\Utilities\InCartQuantity;
+use Automattic\WooCommerce\Internal\Utilities\ProductUtil;

 /**
  * ProductButton class.
@@ -145,6 +146,8 @@ class ProductButton extends AbstractBlock {
 			}
 		}

+		$initial_quantity = $is_descendant_of_add_to_cart_form ? 1 : wc_get_container()->get( ProductUtil::class )->get_catalog_add_to_cart_quantity( $product );
+
 		/**
 		 * This filter is documented in includes/wc-template-functions.php.
 		 *
@@ -153,7 +156,7 @@ class ProductButton extends AbstractBlock {
 		$args = apply_filters(
 			'woocommerce_loop_add_to_cart_args',
 			array(
-				'quantity'   => 1,
+				'quantity'   => $initial_quantity,
 				'class'      => $html_classes,
 				'attributes' => array_merge(
 					$attributes,
@@ -173,6 +176,17 @@ class ProductButton extends AbstractBlock {
 			$default_quantity = wc_stock_amount( (float) $args['quantity'] );
 		}

+		if ( ! $is_descendant_of_add_to_cart_form ) {
+			$button_data      = wc_get_container()->get( ProductUtil::class )->get_catalog_add_to_cart_button_data( $product, $default_quantity );
+			$add_to_cart_text = $button_data['text'] ?? $add_to_cart_text;
+			if ( isset( $add_to_cart_url, $args['attributes']['href'] ) && esc_url( $add_to_cart_url ) === $args['attributes']['href'] ) {
+				$args['attributes']['href'] = esc_url( $button_data['url'] );
+			}
+			if ( ( $args['attributes']['aria-label'] ?? null ) === $button_data['original_description'] ) {
+				$args['attributes']['aria-label'] = $button_data['description'];
+			}
+		}
+
 		$context = array(
 			'quantityToAdd'    => $default_quantity,
 			'addToCartText'    => $add_to_cart_text,
diff --git a/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php b/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
index 365890855d9..6673e0cef69 100644
--- a/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
+++ b/plugins/woocommerce/src/Internal/Utilities/ProductUtil.php
@@ -10,6 +10,80 @@ use Automattic\WooCommerce\Caches\ProductCountCache;
  * Class with general utility methods related to products.
  */
 class ProductUtil {
+	/**
+	 * Get the default catalog quantity without changing a filtered add-to-cart URL.
+	 *
+	 * @since 11.4.0
+	 * @param \WC_Product $product Product to render.
+	 * @param string|null $url Filtered URL, if already available.
+	 * @return int|float
+	 */
+	public function get_catalog_add_to_cart_quantity( \WC_Product $product, ?string $url = null ) {
+		if ( $product->is_sold_individually() ) {
+			return 1;
+		}
+
+		$url = $url ?? $product->add_to_cart_url();
+		parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $query );
+		if ( ( $query['add-to-cart'] ?? null ) === (string) $product->get_id() && is_numeric( $query['quantity'] ?? null ) ) {
+			$quantity = wc_stock_amount( (float) $query['quantity'] );
+			if ( $quantity > 0 ) {
+				return $quantity;
+			}
+		}
+
+		return max( 1, $product->get_min_purchase_quantity() );
+	}
+
+	/**
+	 * Get catalog button data, using the filtered URL quantity unless explicitly overridden.
+	 *
+	 * @since 11.4.0
+	 * @param \WC_Product $product Product to render.
+	 * @param mixed       $quantity Explicit button quantity, or null to resolve the default from the URL and product minimum.
+	 * @return array{url: string, text: string, description: string, original_description: string, quantity: mixed}
+	 */
+	public function get_catalog_add_to_cart_button_data( \WC_Product $product, $quantity = null ): array {
+		$description = $product->add_to_cart_description();
+		$data        = array(
+			'url'                  => $product->add_to_cart_url(),
+			'text'                 => $product->add_to_cart_text(),
+			'description'          => $description,
+			'original_description' => $description,
+		);
+
+		$quantity         = $quantity ?? $this->get_catalog_add_to_cart_quantity( $product, $data['url'] );
+		$data['quantity'] = $quantity;
+
+		if ( ! $product instanceof \WC_Product_Simple || ! $product->is_purchasable() || ! $product->is_in_stock() || ! is_numeric( $quantity ) || $quantity <= 0 ) {
+			return $data;
+		}
+
+		$quantity = wc_stock_amount( (float) $quantity );
+		parse_str( (string) wp_parse_url( $data['url'], PHP_URL_QUERY ), $query );
+		if ( ( $query['add-to-cart'] ?? null ) === (string) $product->get_id() ) {
+			$url_quantity = $query['quantity'] ?? null;
+			if ( ! is_numeric( $url_quantity ) || wc_stock_amount( (float) $url_quantity ) !== $quantity ) {
+				$data['url'] = add_query_arg( 'quantity', $quantity, $data['url'] );
+			}
+		}
+		$data['quantity'] = $quantity;
+
+		if ( $quantity > 1 ) {
+			if ( __( 'Add to cart', 'woocommerce' ) === $data['text'] ) {
+				/* translators: %s: Quantity of the product to add to the cart. */
+				$data['text'] = sprintf( __( 'Add %s to cart', 'woocommerce' ), $quantity );
+			}
+			/* translators: %s: Product title. */
+			if ( sprintf( __( 'Add to cart: &ldquo;%s&rdquo;', 'woocommerce' ), $product->get_name() ) === $description ) {
+				/* translators: 1: Quantity of the product to add to the cart, 2: Product title. */
+				$data['description'] = sprintf( __( 'Add %1$s to cart: &ldquo;%2$s&rdquo;', 'woocommerce' ), $quantity, $product->get_name() );
+			}
+		}
+
+		return $data;
+	}
+
 	/**
 	 * Transient name for the cached dashboard "out of stock" product count.
 	 *
diff --git a/plugins/woocommerce/templates/loop/add-to-cart.php b/plugins/woocommerce/templates/loop/add-to-cart.php
index b06497c6fbb..97a535c5cf0 100644
--- a/plugins/woocommerce/templates/loop/add-to-cart.php
+++ b/plugins/woocommerce/templates/loop/add-to-cart.php
@@ -12,7 +12,7 @@
  *
  * @see         https://woocommerce.com/document/template-structure/
  * @package     WooCommerce\Templates
- * @version     9.2.0
+ * @version     11.4.0
  */

 if ( ! defined( 'ABSPATH' ) ) {
@@ -21,6 +21,16 @@ if ( ! defined( 'ABSPATH' ) ) {

 global $product;

+$args = $args ?? array();
+
+// Direct template callers may not supply the data prepared by woocommerce_template_loop_add_to_cart().
+$button_data = $button_data ?? array(
+	'url'      => $product->add_to_cart_url(),
+	'text'     => $product->add_to_cart_text(),
+	'quantity' => $args['quantity'] ?? 1,
+);
+unset( $args['button_data'] );
+
 $aria_describedby = isset( $args['aria-describedby_text'] ) ? sprintf( 'aria-describedby="woocommerce_loop_add_to_cart_link_describedby_%s"', esc_attr( $product->get_id() ) ) : '';

 /**
@@ -36,12 +46,12 @@ echo apply_filters( // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEsc
 	'woocommerce_loop_add_to_cart_link',
 	sprintf(
 		'<a href="%s" %s data-quantity="%s" class="%s" %s>%s</a>',
-		esc_url( $product->add_to_cart_url() ),
+		esc_url( $button_data['url'] ),
 		$aria_describedby,
-		esc_attr( isset( $args['quantity'] ) ? $args['quantity'] : 1 ),
+		esc_attr( $button_data['quantity'] ),
 		esc_attr( isset( $args['class'] ) ? $args['class'] : 'button' ),
 		isset( $args['attributes'] ) ? wc_implode_html_attributes( $args['attributes'] ) : '',
-		esc_html( $product->add_to_cart_text() )
+		esc_html( $button_data['text'] )
 	),
 	$product,
 	$args
diff --git a/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php
index bf17e936906..bb1d069c4ca 100644
--- a/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php
@@ -52,9 +52,10 @@ class WC_Template_Functions_Tests extends \WC_Unit_Test_Case {
 	 * Render the loop add-to-cart template for a product.
 	 *
 	 * @param WC_Product $test_product Product to render.
+	 * @param array      $args Arguments for the loop button.
 	 * @return string Rendered template markup.
 	 */
-	private function render_loop_add_to_cart( WC_Product $test_product ): string {
+	private function render_loop_add_to_cart( WC_Product $test_product, array $args = array() ): string {
 		global $product;

 		$previous_product = $product;
@@ -63,7 +64,7 @@ class WC_Template_Functions_Tests extends \WC_Unit_Test_Case {

 		ob_start();
 		try {
-			woocommerce_template_loop_add_to_cart();
+			woocommerce_template_loop_add_to_cart( $args );

 			return (string) ob_get_clean();
 		} finally {
@@ -293,6 +294,205 @@ class WC_Template_Functions_Tests extends \WC_Unit_Test_Case {
 		$this->assertStringContainsString( 'rel="nofollow"', $markup );
 	}

+	/**
+	 * @testdox Loop buttons use and announce the minimum quantity without changing sold-individually products.
+	 * @testWith [1, false, 1]
+	 *           [10, false, 10]
+	 *           [0, false, 1]
+	 *           [10, true, 1]
+	 *
+	 * @param int  $minimum Minimum purchase quantity.
+	 * @param bool $sold_individually Whether the product is sold individually.
+	 * @param int  $expected_quantity Expected quantity added by the button.
+	 */
+	public function test_loop_button_uses_minimum_purchase_quantity( int $minimum, bool $sold_individually, int $expected_quantity ): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_sold_individually( $sold_individually );
+		$filter = static function () use ( $minimum ) {
+			return $minimum;
+		};
+
+		add_filter( 'woocommerce_quantity_input_min', $filter );
+		try {
+			$markup = $this->render_loop_add_to_cart( $product );
+		} finally {
+			remove_filter( 'woocommerce_quantity_input_min', $filter );
+		}
+
+		$processor = new WP_HTML_Tag_Processor( $markup );
+		$this->assertTrue( $processor->next_tag( 'a' ), 'The loop button should be rendered.' );
+		$this->assertSame( (string) $expected_quantity, $processor->get_attribute( 'data-quantity' ), 'AJAX should add the minimum quantity.' );
+		parse_str( wp_parse_url( $processor->get_attribute( 'href' ), PHP_URL_QUERY ), $query );
+		$this->assertSame( (string) $product->get_id(), $query['add-to-cart'], 'The link should add the correct product.' );
+		$this->assertSame( (string) $expected_quantity, $query['quantity'] ?? '1', 'Non-AJAX requests should add the same quantity as AJAX.' );
+
+		$expected_text = $expected_quantity > 1 ? 'Add ' . $expected_quantity . ' to cart' : 'Add to cart';
+		$this->assertStringContainsString( '>' . $expected_text . '</a>', $markup, 'The button should disclose how many items will be added.' );
+		$this->assertStringContainsString( $expected_text, $processor->get_attribute( 'aria-label' ), 'The accessible name should include the visible button text.' );
+	}
+
+	/**
+	 * @testdox Classic buttons preserve URL-filter quantities unless arguments or argument filters explicitly override them.
+	 */
+	public function test_loop_button_retains_filtered_url_quantity(): void {
+		$product     = WC_Helper_Product::create_simple_product();
+		$minimum     = static fn() => 10;
+		$url_filter  = static fn( $url ) => add_query_arg( 'quantity', 30, $url );
+		$args_filter = static function ( $args ) {
+			$args['quantity'] = 10;
+			return $args;
+		};
+		add_filter( 'woocommerce_quantity_input_min', $minimum );
+		add_filter( 'woocommerce_product_add_to_cart_url', $url_filter );
+		try {
+			$markup = $this->render_loop_add_to_cart( $product );
+			$this->assertStringContainsString( 'data-quantity="30"', $markup );
+			$this->assertStringContainsString( '>Add 30 to cart</a>', $markup );
+			$this->assertStringContainsString( 'aria-label="Add 30 to cart:', $markup );
+			$this->assertStringContainsString( 'quantity=30', $markup );
+
+			$markup = $this->render_loop_add_to_cart( $product, array( 'quantity' => 10 ) );
+			$this->assertStringContainsString( 'data-quantity="10"', $markup );
+			$this->assertStringContainsString( '>Add 10 to cart</a>', $markup );
+			$this->assertStringContainsString( 'aria-label="Add 10 to cart:', $markup );
+			$this->assertStringContainsString( 'quantity=10', $markup );
+
+			add_filter( 'woocommerce_loop_add_to_cart_args', $args_filter );
+			$markup = $this->render_loop_add_to_cart( $product );
+			$this->assertStringContainsString( 'data-quantity="10"', $markup );
+			$this->assertStringContainsString( '>Add 10 to cart</a>', $markup );
+			$this->assertStringContainsString( 'aria-label="Add 10 to cart:', $markup );
+			$this->assertStringContainsString( 'quantity=10', $markup );
+		} finally {
+			remove_filter( 'woocommerce_quantity_input_min', $minimum );
+			remove_filter( 'woocommerce_product_add_to_cart_url', $url_filter );
+			remove_filter( 'woocommerce_loop_add_to_cart_args', $args_filter );
+		}
+	}
+
+	/**
+	 * @testdox Direct template rendering retains legacy defaults without requiring prepared button data.
+	 */
+	public function test_loop_template_resolves_quantity_when_rendered_directly(): void {
+		global $product;
+
+		$previous_product = $product;
+		$product          = WC_Helper_Product::create_simple_product();
+		$product_id       = $product->get_id();
+		$url_filter       = static fn( $url ) => add_query_arg( 'quantity', 12, $url );
+		add_filter( 'woocommerce_product_add_to_cart_url', $url_filter );
+		try {
+			$markup          = wc_get_template_html( 'loop/add-to-cart.php' );
+			$explicit_markup = wc_get_template_html( 'loop/add-to-cart.php', array( 'quantity' => 1 ) );
+		} finally {
+			remove_filter( 'woocommerce_product_add_to_cart_url', $url_filter );
+			$product = $previous_product;
+		}
+
+		$processor = new WP_HTML_Tag_Processor( $markup );
+		$this->assertTrue( $processor->next_tag( 'a' ), 'The template should render a link without quantity arguments.' );
+		$this->assertSame( '1', $processor->get_attribute( 'data-quantity' ), 'Direct callers should retain the legacy default quantity.' );
+		parse_str( wp_parse_url( $processor->get_attribute( 'href' ), PHP_URL_QUERY ), $query );
+		$this->assertSame( (string) $product_id, $query['add-to-cart'] );
+		$this->assertSame( '12', $query['quantity'], 'The filtered URL quantity should be retained.' );
+		$this->assertStringContainsString( '>Add to cart</a>', $markup );
+		$processor = new WP_HTML_Tag_Processor( $explicit_markup );
+		$this->assertTrue( $processor->next_tag( 'a' ) );
+		$this->assertSame( '1', $processor->get_attribute( 'data-quantity' ), 'Direct callers should retain their explicit quantity.' );
+		parse_str( wp_parse_url( $processor->get_attribute( 'href' ), PHP_URL_QUERY ), $query );
+		$this->assertSame( '12', $query['quantity'], 'Direct callers should retain the filtered product URL.' );
+		$this->assertStringContainsString( '>Add to cart</a>', $explicit_markup );
+	}
+
+	/**
+	 * @testdox Loop rendering passes prepared button data alongside the existing template arguments.
+	 */
+	public function test_loop_button_passes_prepared_data_without_replacing_template_args(): void {
+		$product         = WC_Helper_Product::create_simple_product();
+		$template_args   = null;
+		$link_args       = null;
+		$template_filter = static function ( $template, $template_name, $args ) use ( &$template_args ) {
+			if ( 'loop/add-to-cart.php' === $template_name ) {
+				$template_args = $args;
+			}
+			return $template;
+		};
+		$link_filter     = static function ( $html, $product, $args ) use ( &$link_args ) {
+			$link_args = $args;
+			return $html;
+		};
+
+		add_filter( 'wc_get_template', $template_filter, 10, 3 );
+		add_filter( 'woocommerce_loop_add_to_cart_link', $link_filter, 10, 3 );
+		try {
+			$this->render_loop_add_to_cart(
+				$product,
+				array(
+					'quantity'   => '3',
+					'class'      => 'custom-button',
+					'custom_arg' => 'extension-value',
+					'attributes' => array( 'aria-label' => 'Custom accessible label' ),
+				)
+			);
+		} finally {
+			remove_filter( 'wc_get_template', $template_filter );
+			remove_filter( 'woocommerce_loop_add_to_cart_link', $link_filter );
+		}
+
+		$this->assertSame( '3', $template_args['quantity'], 'Existing overrides must retain the original quantity value and type.' );
+		$this->assertSame( 'custom-button', $template_args['class'] );
+		$this->assertSame( 'extension-value', $template_args['custom_arg'] );
+		$this->assertSame( 'Custom accessible label', $template_args['attributes']['aria-label'] );
+		$this->assertSame( 3, $template_args['button_data']['quantity'] );
+		$this->assertSame( 'Add 3 to cart', $template_args['button_data']['text'] );
+		$this->assertArrayNotHasKey( 'button_data', $link_args, 'Prepared template data must not change the link filter arguments.' );
+		unset( $template_args['button_data'] );
+		$this->assertSame( $template_args, $link_args, 'The template must retain all existing loop arguments.' );
+	}
+
+	/**
+	 * @testdox Add-to-cart URLs do not inherit another product's minimum quantity from the current request.
+	 */
+	public function test_loop_button_does_not_inherit_request_quantity(): void {
+		$product      = WC_Helper_Product::create_simple_product();
+		$previous_uri = $_SERVER['REQUEST_URI'] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Preserve the original request URI for restoration.
+
+		$_SERVER['REQUEST_URI'] = '/shop/?quantity=10';
+		try {
+			$url = $product->add_to_cart_url();
+		} finally {
+			if ( null === $previous_uri ) {
+				unset( $_SERVER['REQUEST_URI'] );
+			} else {
+				$_SERVER['REQUEST_URI'] = $previous_uri;
+			}
+		}
+
+		parse_str( wp_parse_url( $url, PHP_URL_QUERY ), $query );
+		$this->assertArrayNotHasKey( 'quantity', $query, 'An ordinary product should not inherit a previous bulk quantity.' );
+	}
+
+	/**
+	 * @testdox Minimum quantities do not change links or labels for unavailable products.
+	 */
+	public function test_loop_button_minimum_does_not_change_unavailable_products(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_price( '' );
+		$filter = static function () {
+			return 10;
+		};
+
+		add_filter( 'woocommerce_quantity_input_min', $filter );
+		try {
+			$markup = $this->render_loop_add_to_cart( $product );
+		} finally {
+			remove_filter( 'woocommerce_quantity_input_min', $filter );
+		}
+
+		$this->assertStringContainsString( 'href="' . esc_url( $product->get_permalink() ) . '"', $markup, 'Unavailable products should link to their product page.' );
+		$this->assertStringContainsString( '>Read more</a>', $markup, 'Unavailable products should retain their label.' );
+	}
+
 	/**
 	 * @testdox Loop buttons retain nofollow on external product links.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php
index 9b12dab0a9a..37f75c65a1d 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php
@@ -221,23 +221,89 @@ class ProductButton extends \WP_UnitTestCase {
 	}

 	/**
-	 * @testdox woocommerce_loop_add_to_cart_args sets the Product Button quantity.
+	 * @testdox Product Button adds and announces the minimum quantity with or without AJAX.
+	 * @testWith [true]
+	 *           [false]
+	 *
+	 * @param bool $ajax_enabled Whether AJAX add to cart is enabled.
 	 */
-	public function test_loop_add_to_cart_args_sets_initial_quantity(): void {
+	public function test_product_button_uses_minimum_purchase_quantity( bool $ajax_enabled ): void {
+		update_option( 'woocommerce_enable_ajax_add_to_cart', $ajax_enabled ? 'yes' : 'no' );
 		$product = WC_Helper_Product::create_simple_product();
-		$filter  = static function ( array $args ): array {
-			$args['quantity'] = 4;
+		$filter  = static function () {
+			return 10;
+		};
+
+		add_filter( 'woocommerce_quantity_input_min', $filter );
+		try {
+			$markup = $this->render_product_button( $product );
+		} finally {
+			remove_filter( 'woocommerce_quantity_input_min', $filter );
+		}
+
+		$this->assertSame( 10, $this->get_quantity_to_add( $markup ), 'The button context should use the minimum purchase quantity.' );
+		$this->assertStringContainsString( '>Add 10 to cart</span>', $markup, 'The button should disclose the quantity being added.' );
+		$processor = new \WP_HTML_Tag_Processor( $markup );
+		$this->assertTrue( $processor->next_tag( $ajax_enabled ? 'button' : 'a' ), 'The product button should be rendered.' );
+		$this->assertStringContainsString( 'Add 10 to cart', $processor->get_attribute( 'aria-label' ), 'The accessible name should include the visible button text.' );
+		if ( ! $ajax_enabled ) {
+			parse_str( wp_parse_url( $processor->get_attribute( 'href' ), PHP_URL_QUERY ), $query );
+			$this->assertSame( '10', $query['quantity'] ?? null, 'The non-AJAX link should add the minimum quantity.' );
+		}
+	}
+
+	/**
+	 * @testdox Product Button still adds one item for sold-individually products when a global minimum is filtered.
+	 */
+	public function test_product_button_minimum_respects_sold_individually(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_sold_individually( true );
+		$product->save();
+		$filter = static function () {
+			return 10;
+		};
+
+		add_filter( 'woocommerce_quantity_input_min', $filter );
+		try {
+			$markup = $this->render_product_button( $product );
+		} finally {
+			remove_filter( 'woocommerce_quantity_input_min', $filter );
+		}
+
+		$this->assertSame( 1, $this->get_quantity_to_add( $markup ), 'Sold-individually products should still add one item.' );
+		$this->assertStringContainsString( '>Add to cart</span>', $markup, 'The button should not promise a bulk quantity for sold-individually products.' );
+	}
+
+	/**
+	 * @testdox Quantity filters keep Product Button quantities, text, accessible labels, and links consistent.
+	 * @testWith [true]
+	 *           [false]
+	 * @param bool $ajax_enabled Whether AJAX is enabled.
+	 */
+	public function test_loop_add_to_cart_args_sets_initial_quantity( bool $ajax_enabled ): void {
+		update_option( 'woocommerce_enable_ajax_add_to_cart', $ajax_enabled ? 'yes' : 'no' );
+		$product = WC_Helper_Product::create_simple_product();
+		$minimum = static fn() => 10;
+		$filter  = static function ( $args ) {
+			$args['quantity'] = 30;
 			return $args;
 		};

+		add_filter( 'woocommerce_quantity_input_min', $minimum );
 		add_filter( 'woocommerce_loop_add_to_cart_args', $filter );
 		try {
 			$markup = $this->render_product_button( $product );
 		} finally {
+			remove_filter( 'woocommerce_quantity_input_min', $minimum );
 			remove_filter( 'woocommerce_loop_add_to_cart_args', $filter );
 		}

-		$this->assertSame( 4, $this->get_quantity_to_add( $markup ) );
+		$this->assertSame( 30, $this->get_quantity_to_add( $markup ) );
+		$this->assertStringContainsString( '>Add 30 to cart</span>', $markup );
+		$this->assertStringContainsString( 'aria-label="Add 30 to cart:', $markup );
+		if ( ! $ajax_enabled ) {
+			$this->assertStringContainsString( 'quantity=30', $markup );
+		}
 	}

 	/**
@@ -259,4 +325,97 @@ class ProductButton extends \WP_UnitTestCase {

 		return $context['quantityToAdd'];
 	}
+
+	/**
+	 * @testdox Catalog blocks retain URL-filter quantities unless button arguments override them.
+	 */
+	public function test_catalog_blocks_retain_filtered_url_quantity(): void {
+		$product     = WC_Helper_Product::create_simple_product();
+		$minimum     = static fn() => 10;
+		$url_filter  = static fn( $url ) => add_query_arg( 'quantity', 30, $url );
+		$args_filter = static function ( $args ) {
+			$args['quantity'] = 10;
+			return $args;
+		};
+		add_filter( 'woocommerce_quantity_input_min', $minimum );
+		add_filter( 'woocommerce_product_add_to_cart_url', $url_filter );
+		try {
+			$markup = $this->render_product_button( $product );
+			$this->assertSame( 30, $this->get_quantity_to_add( $markup ) );
+			$this->assertStringContainsString( '>Add 30 to cart</span>', $markup );
+			$this->assertStringContainsString( 'aria-label="Add 30 to cart:', $markup );
+			$this->assertStringContainsString( 'quantity=30', $markup );
+
+			$grid = do_blocks( '<!-- wp:woocommerce/handpicked-products {"products":[' . $product->get_id() . ']} /-->' );
+			$this->assertStringContainsString( 'data-quantity="30"', $grid );
+			$this->assertStringContainsString( '>Add 30 to cart</a>', $grid );
+			$this->assertStringContainsString( 'quantity=30', $grid );
+
+			add_filter( 'woocommerce_loop_add_to_cart_args', $args_filter );
+			$markup = $this->render_product_button( $product );
+			$this->assertSame( 10, $this->get_quantity_to_add( $markup ) );
+			$this->assertStringContainsString( '>Add 10 to cart</span>', $markup );
+			$this->assertStringContainsString( 'quantity=10', $markup );
+		} finally {
+			remove_filter( 'woocommerce_quantity_input_min', $minimum );
+			remove_filter( 'woocommerce_product_add_to_cart_url', $url_filter );
+			remove_filter( 'woocommerce_loop_add_to_cart_args', $args_filter );
+		}
+	}
+
+	/**
+	 * @testdox Buttons inside quantity forms retain their quantity-independent accessible name.
+	 */
+	public function test_form_button_retains_description(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$minimum = static fn() => 10;
+		add_filter( 'woocommerce_quantity_input_min', $minimum );
+		try {
+			$block  = new \WP_Block(
+				array(
+					'blockName' => 'woocommerce/product-button',
+					'attrs'     => array(),
+				),
+				array(
+					'postId' => $product->get_id(),
+					'woocommerce/isDescendantOfAddToCartWithOptions' => true,
+				)
+			);
+			$markup = $block->render();
+		} finally {
+			remove_filter( 'woocommerce_quantity_input_min', $minimum );
+		}
+		$this->assertStringContainsString( '>Add to cart</span>', $markup );
+		$this->assertStringContainsString( 'Add to cart:', $this->get_button_aria_label( $markup ) );
+		$this->assertStringNotContainsString( 'Add 10 to cart', $markup );
+	}
+
+	/**
+	 * @testdox Legacy grids use their final quantity in both AJAX data and the button label and link.
+	 * @testWith [10]
+	 *           [30]
+	 * @param int $quantity Final grid quantity.
+	 */
+	public function test_legacy_grid_quantity( int $quantity ): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$minimum = static fn() => 10;
+		$filter  = static function ( $attributes ) use ( $quantity ) {
+			$attributes['data-quantity'] = $quantity;
+			return $attributes;
+		};
+		add_filter( 'woocommerce_quantity_input_min', $minimum );
+		if ( 30 === $quantity ) {
+			add_filter( 'woocommerce_blocks_product_grid_add_to_cart_attributes', $filter );
+		}
+		try {
+			$markup = do_blocks( '<!-- wp:woocommerce/handpicked-products {"products":[' . $product->get_id() . ']} /-->' );
+		} finally {
+			remove_filter( 'woocommerce_quantity_input_min', $minimum );
+			remove_filter( 'woocommerce_blocks_product_grid_add_to_cart_attributes', $filter );
+		}
+		$this->assertStringContainsString( 'data-quantity="' . $quantity . '"', $markup );
+		$this->assertStringContainsString( '>Add ' . $quantity . ' to cart</a>', $markup );
+		$this->assertStringContainsString( 'quantity=' . $quantity, $markup );
+		$this->assertStringContainsString( 'aria-label="Add ' . $quantity . ' to cart:', $markup );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Utilities/ProductUtilTest.php b/plugins/woocommerce/tests/php/src/Internal/Utilities/ProductUtilTest.php
index 887befc08b0..4d097feb01a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Utilities/ProductUtilTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Utilities/ProductUtilTest.php
@@ -17,6 +17,50 @@ use Automattic\WooCommerce\StoreApi\Utilities\ProductQuery;
  * Tests for the internal ProductUtil class.
  */
 class ProductUtilTest extends \WC_Unit_Test_Case {
+	/**
+	 * @testdox Filtered URL quantities respect explicit button quantities and sold-individually limits.
+	 * @testWith [null, 30]
+	 *           [10, 10]
+	 *           [30, 30]
+	 *           [null, 1, true]
+	 * @param int|null $quantity Explicit button quantity.
+	 * @param int      $expected Expected quantity.
+	 * @param bool     $sold_individually Whether the product is sold individually.
+	 */
+	public function test_catalog_button_preserves_filtered_url_quantity( ?int $quantity, int $expected, bool $sold_individually = false ): void {
+		$product = \WC_Helper_Product::create_simple_product();
+		$product->set_sold_individually( $sold_individually );
+		$sut        = wc_get_container()->get( ProductUtil::class );
+		$minimum    = static fn() => 10;
+		$url_filter = static fn( $url ) => add_query_arg(
+			array(
+				'quantity' => '030',
+				'custom'   => 'a b',
+			),
+			$url
+		);
+		add_filter( 'woocommerce_quantity_input_min', $minimum );
+		add_filter( 'woocommerce_product_add_to_cart_url', $url_filter );
+		try {
+			$url  = $product->add_to_cart_url();
+			$data = $sut->get_catalog_add_to_cart_button_data( $product, $quantity );
+		} finally {
+			remove_filter( 'woocommerce_quantity_input_min', $minimum );
+			remove_filter( 'woocommerce_product_add_to_cart_url', $url_filter );
+		}
+		$this->assertSame( $expected, $data['quantity'] );
+		$expected_text = $expected > 1 ? 'Add ' . $expected . ' to cart' : 'Add to cart';
+		$this->assertSame( $expected_text, $data['text'] );
+		$this->assertStringContainsString( $expected_text . ':', $data['description'] );
+		if ( 30 === $expected ) {
+			$this->assertSame( $url, $data['url'], 'An unchanged quantity must preserve the filtered URL exactly.' );
+		} else {
+			parse_str( wp_parse_url( $data['url'], PHP_URL_QUERY ), $query );
+			$this->assertSame( (string) $expected, $query['quantity'] );
+			$this->assertSame( 'a b', $query['custom'], 'Other URL customizations must remain intact.' );
+		}
+	}
+
 	/**
 	 * @testdox Product cache cleanup can be scheduled when the loaded Action Scheduler lacks async actions.
 	 */