Changing WooCommerce Shipping Rates After Calculation
The filter hands you every calculated rate for a package, which makes hiding and repricing trivial — and the result is cached against the cart contents, so a rule that depends on anything else looks random until you know why.
The filter runs after a package's rates are calculated and before they are cached — and the cache is the part that makes it feel broken. woocommerce_package_rates receives the array of WC_Shipping_Rate objects for one package and the package itself. You can remove rates, change their cost or label, and add rates. The result is stored in the session keyed on the package, so the filter is not called again until the package changes.
Hiding rates
add_filter( 'woocommerce_package_rates', 'ti_free_shipping_only', 10, 2 );
function ti_free_shipping_only( $rates, $package ) {
$free = array();
foreach ( $rates as $rate_id => $rate ) {
if ( 'free_shipping' === $rate->get_method_id() || 'local_pickup' === $rate->get_method_id() ) {
$free[ $rate_id ] = $rate;
}
}
return ! empty( $free ) ? $free : $rates;
}
Keys look like flat_rate:3 — method ID, colon, instance ID — and the instance ID is the one shown in the zone settings URL. Comparing get_method_id() is more robust than matching the full key, because instance IDs change when a zone is rebuilt. Returning the original array when no free rate exists is the guard that keeps a cart from having no shipping options, which the checkout reports as an unhelpful "no shipping methods available" — a symptom with several other causes covered in free shipping not working.
Changing cost and tax together
A role-based discount, the pattern behind wholesale pricing, looks like this:
add_filter( 'woocommerce_package_rates', 'ti_role_discount', 10, 2 );
function ti_role_discount( $rates, $package ) {
if ( ! current_user_can( 'wholesale_buyer' ) ) {
return $rates;
}
foreach ( $rates as $rate ) {
if ( 'flat_rate' !== $rate->get_method_id() ) {
continue;
}
$cost = $rate->get_cost() * 0.5;
$rate->set_cost( $cost );
$rate->set_taxes( WC_Tax::calc_shipping_tax( $cost, WC_Tax::get_shipping_tax_rates() ) );
}
return $rates;
}
The set_taxes() line is the one missing from most snippets. The rate object carries its taxes as an array computed from the original cost; change the cost and the old tax rides along. Recalculating with WC_Tax::calc_shipping_tax() is correct and short. Scaling the existing array by the same ratio also works and preserves per-rate class behaviour.
And this example has the cache problem built in.
The cache
WooCommerce stores calculated rates in the session under a hash of the whole package array — contents, quantities, destination, applied coupons, the user ID, the cart subtotal — plus the shipping settings' transient version. If any of those change, rates are recalculated and your filter runs; logging in and applying a coupon both change the hash. If something outside the package changes — the clock passes a cut-off, a role is granted to a shopper who is already logged in, a user-meta flag flips — the hash is unchanged and the cached rates, filtered under the old conditions, are served.
Two ways out:
- Make the condition part of the package. Filter
woocommerce_cart_shipping_packagesand add your variable to each package array, for example$package['ti_role'] = $role. Any change to it changes the hash. - Bump the transient version when the condition changes:
WC_Cache_Helper::get_transient_version( 'shipping', true ). This invalidates all cached rates for all sessions, which is right for a global change and heavy-handed for a per-user one.
The first is the correct fix for anything per-customer. The second is right after a settings change your code makes. Neither is "clear the cart", which is what support threads suggest and what shoppers should never have to do.
Adding a rate
Adding is less common and more fragile. A WC_Shipping_Rate built by hand needs an ID that does not collide, a label, a cost, taxes, and a method ID that the checkout can match to a shipping method for the order. A rate added here appears in the list but has no settings screen, no zone, and no instance to attach to the order's shipping line in the admin. If the store needs another rate, add a shipping method instance in the zone and let the filter adjust it; that keeps every rate accountable to the shipping settings.
Multiple packages
The filter runs once per package, and a cart ships in more than one package whenever a plugin splits it — per vendor, per warehouse, per shipping class. $package['contents'] lists the items in this package; a rule like "free over 100" that reads the cart total instead of the package total gives free shipping to every package of a split cart. Read from the package.
Verify
- Change the condition without changing the cart and reload the cart page: if the rates do not follow, the cache is serving the old result and the condition must go into the package. Enabling shipping debug mode under WooCommerce › Settings › Shipping bypasses the cache entirely while you test, and should be turned off afterwards.
- Change a rate's cost and compare the tax line to a fresh calculation at the standard rate.
- Return to the checkout with a destination that has no matching zone: your filter must return a usable array or the shopper sees no methods at all.
- Place an order and open it in the admin: the shipping line should reference a real method instance.
Frequently asked
- Calculated rates are cached in the session under a hash of the package contents and destination. Your filter runs when rates are calculated, not when they are displayed, so a rule depending on something outside the package — user role, time of day, a coupon — is applied once and then served from cache until the package changes.
- The rate carries taxes as a separate array calculated from the original cost. Changing cost leaves the old tax in place. Recalculate it with WC_Tax::calc_shipping_tax() on the new cost, or scale the existing taxes by the same ratio.
- Loop the rates, and if any key begins with free_shipping, return an array containing only the free rates. Keep local pickup if the store offers it; shoppers who chose pickup did not do so to save shipping.