Changing Cart Item Prices with woocommerce_before_calculate_totals
The hook runs every time WooCommerce recalculates the cart, which can be several times in one request and not at all in another. Price changes made here have to be idempotent, and the mini cart needs its own answer.
woocommerce_before_calculate_totals hands you the cart just before WooCommerce adds it up, and it is the place to change a line item's price — as long as the change gives the same answer however many times it runs. It fires inside WC_Cart::calculate_totals(), after reset_totals() and before WC_Cart_Totals reads each item's price, with the cart object as its only argument. Nothing you do here is saved to the product: you are editing the product object held in $cart_item['data'], which is rebuilt from the database on the next request.
The working shape
add_action( 'woocommerce_before_calculate_totals', 'ti_bulk_unit_price', 20, 1 );
function ti_bulk_unit_price( WC_Cart $cart ) {
if ( is_admin() && ! wp_doing_ajax() ) {
return;
}
foreach ( $cart->get_cart() as $cart_item ) {
$product = $cart_item['data'];
// Always start from a value set_price() never changes.
$base = (float) $product->get_regular_price();
if ( $cart_item['quantity'] >= 10 && $base > 0 ) {
$product->set_price( round( $base * 0.9, wc_get_price_decimals() ) );
}
}
}
The priority of 20 matters only if another plugin also edits prices here; run after it if your rule should win, before it if its rule should. The single argument is the cart, so the default accepted_args of 1 is already right.
The trap: totals run more than once
calculate_totals() is called from many places — adding to cart, updating quantities, applying a coupon, the shipping calculator, the checkout refresh, the Store API — and several of them can fire in the same request. Add to cart is a good example: WC_Cart hooks its own calculate_totals() to woocommerce_add_to_cart, and the page that follows may calculate again.
That breaks the most common snippet on the internet:
// Wrong: compounds on every run.
$product->set_price( $product->get_price() * 0.9 );
The first run sets 90 % of the price, the second sets 90 % of that, and the customer sees 81 % or 72.9 % depending on the route they took. The popular fix — return early when did_action( 'woocommerce_before_calculate_totals' ) >= 2 — stops the compounding by skipping every later run, including the one after a quantity change that should have moved the item above or below your threshold.
The real fix is to make the callback idempotent. Read the starting price from something set_price() does not touch: get_regular_price(), get_sale_price(), a fresh wc_get_product() call, or a value you stored on the cart item when it was added. Then a second run sets the same number the first one did, and the guard becomes unnecessary.
The mini cart can disagree with the totals
The hook only fires when totals are calculated, and WooCommerce avoids that work when it can. When the cart is loaded from the session, it restores the previous totals from the session and only recalculates if they are missing or an item had to be removed. The product objects in the cart are fresh copies at their catalogue price.
The classic mini cart is rendered by the get_refreshed_fragments AJAX endpoint, which prints woocommerce_mini_cart() without recalculating. Each line's price there comes from WC()->cart->get_product_price( $product ), which reads the product object — the untouched catalogue price. The subtotal comes from the restored session totals, which include your change. The result is a mini cart showing "10 × £20.00" next to a subtotal of £180.00.
Because the callback is idempotent, the cure is simply to run it when the cart is loaded too:
add_action( 'woocommerce_cart_loaded_from_session', 'ti_bulk_unit_price', 20, 1 );
woocommerce_cart_loaded_from_session also passes the cart object, so the same function works unchanged. With the compounding version, adding this second hook makes the problem worse, which is one more reason to fix the price source first.
What else belongs here, and what does not
The hook is right for anything that changes the inputs to the calculation: a unit price, a product's tax class through set_tax_class(), or removing an item that no longer qualifies. It is the wrong place for:
- Extra charges. A surcharge or handling fee is a fee, added in woocommerce_cart_calculate_fees, which runs inside the calculation and is cleared and rebuilt each time.
- Shipping changes. Rates belong in woocommerce_package_rates.
- Display-only prices. If the price should look different in the catalogue but charge the same, that is woocommerce_get_price_html, not a cart hook.
- Discounts customers should see as discounts. A lower unit price never appears on a coupon report; a coupon does. The trade-off is covered in coupon not working.
Role or customer-group pricing is a legitimate use, but a store with more than a couple of rules is usually better served by the patterns in B2B and wholesale than by a growing callback.
Block cart and checkout
The Store API uses the same cart. Before building a response it checks whether woocommerce_after_calculate_totals has already fired in the request and calls calculate_totals() if not, so your callback runs for the block cart and checkout as it does for the classic pages. The mini cart problem above is specific to the classic fragments; the block mini cart reads its line prices from the Store API response, which is built after totals.
Common mistakes
- Multiplying
get_price()instead of starting from a fixed base. - Leaving out
wc_get_price_decimals()rounding, so the line total is off by a cent against the displayed unit price. - Checking
is_admin()alone, which returns true onadmin-ajax.phpand skips cart requests that themes and plugins send there.wp_doing_ajax()in the guard keeps those working; WooCommerce's own?wc-ajax=requests are not admin requests at all. - Expecting the change on existing orders. Orders are recalculated from order items, not from the cart; this hook never touches them.
Verify
- Add nine units, then ten, and confirm the unit price changes at the threshold on the cart page.
- Apply and remove a coupon; the unit price must stay at 90 %, not drift lower.
- Reload a product page and open the mini cart: the line price and subtotal must agree.
- Repeat in the block cart if the store uses it, and place one test order to check the order line price.
Frequently asked
- Because calculate_totals() can run more than once in a request and your callback multiplies the current price. Each run takes the already-reduced price and reduces it again. Compute the new price from a value set_price() never touches, such as the regular price or a value stored in the cart item.
- It hides the symptom by skipping every run after the first, which also skips legitimate recalculations after a quantity change in the same request. An idempotent callback needs no guard at all, because running it twice gives the same result as running it once.
- Yes. The Store API builds its responses from the same WC_Cart object and calls calculate_totals() when totals have not yet been calculated in that request, so the hook fires before every block cart response that needs fresh totals.