Changing How WooCommerce Displays Prices
This filter changes what the shopper reads and nothing else. The cart still charges the stored price, which is why using it to apply a discount produces a store that advertises one number and bills another.
This filter is a display filter, and the distinction is not academic. woocommerce_get_price_html changes the price a shopper reads on the product, in the grid and in related product blocks. It does not change the price the cart uses, the price tax is calculated on, or the price written to the order. A store that discounts here advertises one figure and charges another, and the first person to notice is usually a customer.
What arrives and what leaves
add_filter( 'woocommerce_get_price_html', 'ti_price_html', 10, 2 );
function ti_price_html( $price_html, $product ) {
return $price_html;
}
The first argument is assembled markup, not a number. For a product on sale it contains the original price inside a del element and the current price inside an ins. For a variable product with differing children it contains a formatted range. For a product with no price it can be an empty string.
The second argument is the product object, and it is what makes anything conditional possible:
add_filter( 'woocommerce_get_price_html', 'ti_price_suffix', 10, 2 );
function ti_price_suffix( $price_html, $product ) {
if ( '' === $price_html ) {
return $price_html;
}
if ( ! $product->is_type( 'simple' ) ) {
return $price_html;
}
return $price_html . ' <small class="ti-unit">' . esc_html__( 'per item', 'your-textdomain' ) . '</small>';
}
Appending rather than replacing is the whole technique. It preserves the sale markup, the currency formatting and the range structure, none of which you have to reimplement.
The empty string is a real case
A product with no price set returns an empty string, and so does a product whose price is hidden from visitors who are not logged in on a wholesale-style catalogue. Code that appends unconditionally produces a dangling suffix with no number in front of it — "per item" floating alone under the product title.
The guard costs one line and removes an entire class of report from a catalogue like the one described under B2B and wholesale, where hidden prices are the normal state rather than an error.
It runs far more often than you think
The filter fires for every product rendered anywhere a price appears: each tile in a grid, related products, upsells, cross-sells, the single product page, and widgets. On a category page showing forty products it runs forty times.
That makes it the wrong place for anything expensive. A database query or a remote call inside this callback multiplies by the number of products on screen, and the result is a category page that takes seconds to build while the product itself is fast. If a value must be looked up, compute it once per request and cache it in a static variable rather than querying per product.
Variable products and the range
For a variable product, the assembled value is a range when the children differ and a single price when they do not. Two consequences follow. Code that parses the markup to recover a number will find two numbers or one depending on the product, and code that appends wording such as "per item" will attach it to a range where it may not make sense.
Checking the product type before acting is the reliable route, and is_type( 'variable' ) is more honest than inspecting the markup. The underlying structure is the same one described in product variations.
What to use instead for real price changes
If the goal is that the shopper pays a different amount, the change has to happen where the price is stored or where the cart calculates:
- A coupon applied automatically covers most promotional cases without any code, and it appears correctly on the order.
- A sale price on the product is the supported route for a temporary reduction and keeps the struck-through display for free.
- A cart-level adjustment changes the line item itself, so the total, the tax and the order all agree.
All three keep display and billing in step, which is the property this filter cannot give you. Getting that wrong is a different problem from the store simply not showing prices, which starts at products not showing.
Verify across the awkward products
Check a simple product, a product on sale, a variable product with a range, a variable product whose children all cost the same, and a product with no price at all. Then add one of them to the cart and confirm the cart total matches what the page advertised.
That last check is the one that matters. If the two figures differ, the filter is being used for something it cannot do, and no amount of adjusting the markup will reconcile them — the same discipline that keeps a product card honest about what a button will do.
Frequently asked
- No. It only changes displayed markup. The cart, taxes and the order total all read the stored price, so the shopper sees your reduced figure and is charged the original one. Use a coupon or a price-setting hook instead.
- Because the incoming value is markup containing del and ins elements, and returning a plain formatted number throws that structure away. Append to the value rather than rebuilding it.
- That is the default for a variable product whose children differ in price. The filter receives the assembled range, not a single figure, so code expecting one number will misread it.