Commit b922f99f235 for woocommerce
commit b922f99f2354c29620c37ee9225c8cb743c1fc31
Author: Bogdan Ungureanu <bogdanungureanu21@gmail.com>
Date: Wed Sep 16 13:34:39 2026 +0300
Fix missing shipping rates when extensions remove free shipping (#68707)
* Fix missing shipping rates when extensions remove free shipping
The hide-when-free rule ran before woocommerce_package_rates, so paid rates
were stripped based on a free shipping option a later filter could still
remove. Running it after the filter means rates can now arrive from filter
callbacks, so the local pickup lookup needs a null guard.
* Validate the response from the hook and add tests for it.
* Add docblock for PHPStan
* Update the baseline
diff --git a/plugins/woocommerce/changelog/63300-hide-rates-when-free-shipping-filtered b/plugins/woocommerce/changelog/63300-hide-rates-when-free-shipping-filtered
new file mode 100644
index 00000000000..c9965bd0cac
--- /dev/null
+++ b/plugins/woocommerce/changelog/63300-hide-rates-when-free-shipping-filtered
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Apply "Hide shipping rates when free shipping is available" after the woocommerce_package_rates filter, so extensions that remove free shipping no longer leave the customer with no rates.
diff --git a/plugins/woocommerce/includes/class-wc-shipping-rate.php b/plugins/woocommerce/includes/class-wc-shipping-rate.php
index c49b44e27ca..1e79770d548 100644
--- a/plugins/woocommerce/includes/class-wc-shipping-rate.php
+++ b/plugins/woocommerce/includes/class-wc-shipping-rate.php
@@ -16,6 +16,9 @@ use Automattic\WooCommerce\Enums\ProductTaxStatus;
/**
* Shipping rate class.
+ *
+ * @property string $id Shipping rate ID.
+ * @property string $method_id Shipping method ID.
*/
class WC_Shipping_Rate implements JsonSerializable {
diff --git a/plugins/woocommerce/includes/class-wc-shipping.php b/plugins/woocommerce/includes/class-wc-shipping.php
index 1c9e335f90e..6bcd3fafc53 100644
--- a/plugins/woocommerce/includes/class-wc-shipping.php
+++ b/plugins/woocommerce/includes/class-wc-shipping.php
@@ -358,7 +358,27 @@ class WC_Shipping {
}
}
- // Hide shipping rates when free shipping is available.
+ /**
+ * Filter the calculated shipping rates.
+ *
+ * @see https://gist.github.com/woogists/271654709e1d27648546e83253c1a813 for cache invalidation methods.
+ * @since 2.0.0
+ * @param array $package['rates'] Package rates.
+ * @param array $package Package of cart items.
+ */
+ $package['rates'] = apply_filters( 'woocommerce_package_rates', $package['rates'], $package );
+
+ // Package rates should be an array, if it was filtered into a non-array, reset it. Don't reset to the
+ // unfiltered value, as e.g. a 3pd could have set it to "false" to remove rates.
+ if ( ! is_array( $package['rates'] ) ) {
+ $package['rates'] = array();
+ }
+
+ $package['rates'] = array_filter( $package['rates'], static fn( $rate ) => $rate instanceof WC_Shipping_Rate );
+
+ // Hide shipping rates when free shipping is available. Runs after the woocommerce_package_rates filter
+ // so that free shipping only counts as available if it survived filtering; otherwise an extension that
+ // removes free shipping would leave the customer with no rates at all.
if ( 'yes' === get_option( 'woocommerce_shipping_hide_rates_when_free', 'no' ) ) {
$free_shipping = array();
$local_pickup = array();
@@ -369,7 +389,10 @@ class WC_Shipping {
continue;
}
- if ( $this->shipping_methods[ $rate->method_id ]->supports( 'local-pickup' ) || 'local_pickup' === $rate->method_id ) {
+ // The rate may have been added by a filter callback, so its method is not necessarily registered here.
+ $rate_method = $this->shipping_methods[ $rate->method_id ] ?? null;
+
+ if ( 'local_pickup' === $rate->method_id || ( $rate_method && $rate_method->supports( 'local-pickup' ) ) ) {
$local_pickup[ $rate->id ] = $rate;
}
}
@@ -379,22 +402,6 @@ class WC_Shipping {
}
}
- /**
- * Filter the calculated shipping rates.
- *
- * @see https://gist.github.com/woogists/271654709e1d27648546e83253c1a813 for cache invalidation methods.
- * @since 2.0.0
- * @param array $package['rates'] Package rates.
- * @param array $package Package of cart items.
- */
- $package['rates'] = apply_filters( 'woocommerce_package_rates', $package['rates'], $package );
-
- // Package rates should be an array, if it was filtered into a non-array, reset it. Don't reset to the
- // unfiltered value, as e.g. a 3pd could have set it to "false" to remove rates.
- if ( ! is_array( $package['rates'] ) ) {
- $package['rates'] = array();
- }
-
// Store in session to avoid recalculation.
WC()->session->set(
$wc_session_key,
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 633efd3278d..66e9c7fbd68 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -14911,12 +14911,6 @@ parameters:
count: 1
path: includes/class-wc-shipping.php
- -
- message: '#^Offset mixed might not exist on array\|null\.$#'
- identifier: offsetAccess.notFound
- count: 1
- path: includes/class-wc-shipping.php
-
-
message: '#^One or more @param tags has an invalid name or invalid syntax\.$#'
identifier: phpDoc.parseError
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-shipping-test.php b/plugins/woocommerce/tests/php/includes/class-wc-shipping-test.php
index 535bf37d22e..afd60123b46 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-shipping-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-shipping-test.php
@@ -42,8 +42,9 @@ class WC_Shipping_Test extends WC_Unit_Test_Case {
* @param string $option_value Option value for woocommerce_shipping_hide_rates_when_free.
* @param array $shipping_methods Available shipping methods.
* @param array $expected_rates Expected rates.
+ * @param array $hidden_rates Rates expected to be hidden.
*/
- public function test_calculate_shipping_for_hide_rates_when_free( string $option_value, array $shipping_methods, array $expected_rates ) {
+ public function test_calculate_shipping_for_hide_rates_when_free( string $option_value, array $shipping_methods, array $expected_rates, array $hidden_rates ) {
update_option( 'woocommerce_shipping_hide_rates_when_free', $option_value );
$shipping_methods_hook = fn () => $shipping_methods;
@@ -66,7 +67,100 @@ class WC_Shipping_Test extends WC_Unit_Test_Case {
$this->assertArrayHasKey( $rate, $result['rates'] );
}
+ foreach ( $hidden_rates as $rate ) {
+ $this->assertArrayNotHasKey( $rate, $result['rates'] );
+ }
+
+ remove_action( 'woocommerce_shipping_methods', $shipping_methods_hook );
+ }
+
+ /**
+ * @testdox paid rates stay visible when the package rates filter removes free shipping.
+ */
+ public function test_hide_rates_when_free_respects_free_shipping_removed_by_filter() {
+ update_option( 'woocommerce_shipping_hide_rates_when_free', 'yes' );
+
+ $shipping_methods_hook = fn () => array( new WC_Shipping_Flat_Rate( 1 ), new WC_Shipping_Free_Shipping( 1 ) );
+ $remove_free_shipping = function ( $rates ) {
+ unset( $rates['free_shipping:1'] );
+ return $rates;
+ };
+
+ add_action( 'woocommerce_shipping_methods', $shipping_methods_hook );
+ add_filter( 'woocommerce_package_rates', $remove_free_shipping );
+
+ $result = $this->sut->calculate_shipping_for_package( $this->get_hide_rates_test_package() );
+
+ remove_filter( 'woocommerce_package_rates', $remove_free_shipping );
remove_action( 'woocommerce_shipping_methods', $shipping_methods_hook );
+
+ $this->assertArrayHasKey( 'flat_rate:1', $result['rates'], 'Paid rates should remain when a filter removes free shipping.' );
+ }
+
+ /**
+ * @testdox a paid rate added by the package rates filter for an unregistered method is hidden without errors.
+ */
+ public function test_hide_rates_when_free_handles_filter_added_rate_for_unregistered_method() {
+ update_option( 'woocommerce_shipping_hide_rates_when_free', 'yes' );
+
+ $shipping_methods_hook = fn () => array( new WC_Shipping_Free_Shipping( 1 ) );
+ $add_unregistered_rate = function ( $rates ) {
+ $rates['custom_carrier:1'] = new WC_Shipping_Rate( 'custom_carrier:1', 'Custom Carrier', 5, array(), 'custom_carrier' );
+ return $rates;
+ };
+
+ add_action( 'woocommerce_shipping_methods', $shipping_methods_hook );
+ add_filter( 'woocommerce_package_rates', $add_unregistered_rate );
+
+ $result = $this->sut->calculate_shipping_for_package( $this->get_hide_rates_test_package() );
+
+ remove_filter( 'woocommerce_package_rates', $add_unregistered_rate );
+ remove_action( 'woocommerce_shipping_methods', $shipping_methods_hook );
+
+ $this->assertSame(
+ array( 'free_shipping:1' ),
+ array_keys( $result['rates'] ),
+ 'Free shipping should hide a paid rate added by a filter for a method that is not registered.'
+ );
+ }
+
+ /**
+ * @testdox filtered rates that are not WC_Shipping_Rate instances are removed.
+ *
+ * @dataProvider provide_invalid_filtered_rates
+ * @param mixed $invalid_rate Invalid filtered rate.
+ */
+ public function test_calculate_shipping_rejects_invalid_filtered_rates( $invalid_rate ): void {
+ $valid_rate = new WC_Shipping_Rate( 'flat_rate:1', 'Flat rate', 5, array(), 'flat_rate' );
+
+ add_filter(
+ 'woocommerce_package_rates',
+ fn () => array(
+ 'flat_rate:1' => $valid_rate,
+ 'invalid' => $invalid_rate,
+ )
+ );
+
+ $result = $this->sut->calculate_shipping_for_package( $this->get_hide_rates_test_package() );
+
+ $this->assertSame( array( 'flat_rate:1' => $valid_rate ), $result['rates'], 'Only WC_Shipping_Rate instances should remain.' );
+ }
+
+ /**
+ * Invalid values returned by the package rates filter.
+ *
+ * @return array
+ */
+ public static function provide_invalid_filtered_rates(): array {
+ return array(
+ 'null' => array( null ),
+ 'stdClass' => array(
+ (object) array(
+ 'id' => 'local_pickup:1',
+ 'method_id' => 'local_pickup',
+ ),
+ ),
+ );
}
/**
@@ -335,6 +429,23 @@ class WC_Shipping_Test extends WC_Unit_Test_Case {
};
}
+ /**
+ * Get a package for hide-rates-when-free tests.
+ *
+ * @return array
+ */
+ private function get_hide_rates_test_package(): array {
+ return array(
+ 'contents' => array(),
+ 'contents_cost' => 10,
+ 'destination' => array(
+ 'country' => 'US',
+ 'state' => 'CA',
+ 'postcode' => '00000',
+ ),
+ );
+ }
+
/**
* Get a package for shipping hash tests.
*
@@ -392,16 +503,19 @@ class WC_Shipping_Test extends WC_Unit_Test_Case {
'no',
array( $flat_rate, $free_shipping, $local_pickup, $custom_pickup ),
array( 'flat_rate:1', 'free_shipping:1', 'local_pickup:1', 'custom_pickup:1' ),
+ array(),
),
'hide enabled - with free shipping' => array(
'yes',
array( $flat_rate, $free_shipping, $local_pickup, $custom_pickup ),
array( 'free_shipping:1', 'local_pickup:1', 'custom_pickup:1' ),
+ array( 'flat_rate:1' ),
),
'hide enabled - without free shipping' => array(
'yes',
array( $flat_rate, $local_pickup, $custom_pickup ),
array( 'flat_rate:1', 'local_pickup:1', 'custom_pickup:1' ),
+ array(),
),
);
}