Changing Product Prices with woocommerce_product_get_price
The filter changes the price the cart charges, not just the one shown, but not for variations. Variations have their own filter, the variable price range has a third with a cache, and catalogue sorting ignores all of them.
woocommerce_product_get_price filters the value $product->get_price() returns, so it changes the price WooCommerce charges, not only the one it displays. It receives two arguments, the price as stored and the product object, and it runs every time code asks for the price in the default view context. The cart builds its totals from get_price() on each line's product, so a filtered price ends up on the order. That is the main difference from woocommerce_get_price_html, which only changes the markup.
The name is built, not declared. Product getters call get_prop(), which applies woocommerce_product_get_{prop}. That is why there is also woocommerce_product_get_regular_price, woocommerce_product_get_sale_price, and the trap below.
The working shape
add_filter( 'woocommerce_product_get_price', 'ti_member_price', 10, 2 );
add_filter( 'woocommerce_product_variation_get_price', 'ti_member_price', 10, 2 );
function ti_member_price( $price, $product ) {
if ( '' === $price || ! is_user_logged_in() ) {
return $price;
}
if ( ! wc_current_user_has_role( 'member' ) ) {
return $price;
}
// Start from the stored regular price, never from get_price().
$regular = (float) $product->get_regular_price( 'edit' );
$member = round( $regular * 0.9, wc_get_price_decimals() );
// A sale price lower than the member price still wins.
return (string) min( $member, (float) $price );
}
Four details in it are deliberate:
- The empty string passes through. A product with no price returns
'', andis_purchasable()treats a''price as not for sale. Turning that into0makes unpriced products buyable for free. - No call to
$product->get_price()inside the callback. It runs the filter again and loops until PHP runs out of memory. Use the$priceargument or theeditcontext, which skips filters. - The base is the regular price. If other code has called
set_price()on the cart's copy of the product, for example in woocommerce_before_calculate_totals,$pricealready contains that change. Multiplying$pricewould stack your discount on top of it. min()against the current price. Without it, a member pays 90 % of the regular price while a guest pays a deeper sale price.
The trap: variable products need three filters
A variable product's page shows a price range, such as "£20 – £30", and that range ignores both filters above. It is built by the variable product data store from each variation's price in the edit context, passed through a separate filter, and cached:
| What the shopper sees | Where the number comes from | Filter |
|---|---|---|
| Simple product price, cart line, order | get_price() |
woocommerce_product_get_price |
| Selected variation's price, cart line, order | get_price() on the variation |
woocommerce_product_variation_get_price |
| Price range on a variable product | Cached variation prices | woocommerce_variation_prices_price (plus _regular_price, _sale_price) |
Hook only the first two and the catalogue keeps advertising the old range while the selected variation and the cart show the new price. Add the range filters with the same logic:
add_filter( 'woocommerce_variation_prices_price', 'ti_member_range_price', 10, 3 );
function ti_member_range_price( $price, $variation, $product ) {
return ti_member_price( $price, $variation );
}
Then deal with the cache. The computed prices are stored in a transient named wc_var_prices_{product_id}, under a hash built from the tax setup and the callbacks attached to the three woocommerce_variation_prices_* filters. The hash records which callbacks are attached, not who is looking. A price that depends on the user is cached for the first visitor, then served to everyone. Add the deciding factor to the hash:
add_filter( 'woocommerce_get_variation_prices_hash', function ( $hash, $product, $for_display ) {
$hash[] = wc_current_user_has_role( 'member' ) ? 'member' : 'guest';
return $hash;
}, 10, 3 );
Use a group key like this, not the user ID, unless prices really are per user. Each distinct key adds an entry to the product's transient.
What the filter does reach
Because the value comes from the product object, the filtered price travels further than display code:
- Coupons calculate percentage discounts from the cart line, which is built from the filtered price, so a 10 % coupon on a member price takes 10 % of the member price.
- The REST API and the Store API read
get_price()in theviewcontext. Apps, feeds and the block cart all receive the filtered number; only a REST request withcontext=editgets the stored price. - Tax is calculated on the filtered price, inclusive or exclusive, exactly as on a stored price.
What the filter does not reach
- Sorting by price and the price filter. Both query the
wc_product_meta_lookuptable, filled from the stored_pricemeta. A filtered price does not move a product in "Sort by price" or out of a price range. If the difference matters, the price has to be stored, not filtered. Filter not working covers the lookup tables and where to regenerate them. - The sale badge.
is_on_sale()compares the regular and sale prices, notget_price(). A filtered lower price shows without a strikethrough or a Sale flash unless you also filter the sale price. - Page caches. A cached catalogue page shows whatever price the first visitor saw. User-dependent prices need the cache to bypass logged-in users or that customer group.
- Existing orders. They keep the totals they were created with.
Performance
get_price() is called many times per product on one page, for display, tax calculation, the cart and the mini cart. Keep the callback cheap: no database queries per call, and no remote lookups. Read anything expensive once per request and keep it in a static variable. For shops with many pricing rules, the trade-offs between filters and stored prices are covered in B2B and wholesale.
Verify
- As a member and as a guest, compare a simple product's price on the product page, in the cart and on a test order.
- On a variable product, compare the range, the selected variation's price and the cart line. All three should agree.
- Load the variable product as a guest after loading it as a member. The range must not carry over.
- Sort the catalogue by price and confirm you are not expecting the filtered price to change the order.
Frequently asked
- Variations are WC_Product_Variation objects, whose getters use the prefix woocommerce_product_variation_get_. Their price goes through woocommerce_product_variation_get_price, not woocommerce_product_get_price. Hook the same callback to both.
- This filter changes the price everywhere the product is read, including the catalogue, so shoppers see the price they will pay. woocommerce_before_calculate_totals changes only cart lines. Use the filter for prices that belong to the product or customer, and the cart hook for rules that depend on the cart, such as quantity breaks.
- No. Order line items store their totals when the order is created, and they are not recalculated from the product. Orders created from the admin do read the product's price when you add an item, so a filter that only makes sense for shoppers needs a guard there.