Skip to content
Pricing and Quotes

WooCommerce Hide Price for Specific Category: Every Surface That Has to Agree

A WooCommerce hide price for specific category rule takes five lines of PHP, then eight more surfaces have to agree with it. Here is the whole list.

Most shops that hide prices do not want to hide all of them. One range is made to order, or trade only, or priced per project, and that range should say “price on request” while the other four hundred products keep their price and their Add to Cart button. If you have been searching for a WooCommerce hide price for specific category rule, the first half of the job is a five-line filter that you can paste today.

The second half is the part that takes a week. Those five lines change the price HTML on the product page and in the shop loop. They do not change the price filter widget, the variation JSON embedded in a variable product page, the Store API response a block theme reads, the JSON-LD offer your SEO plugin prints, or the wc/v3 REST payload your marketplace connector pulls. A partly hidden catalogue is harder to keep honest than a fully hidden one, because every surface has to give the same yes-or-no answer about the same product, and by default they each work it out separately.

What a WooCommerce hide price for specific category rule really is#

It is a predicate: given a product and the current visitor, is the price hidden or not? Write that predicate once, in one function, and have every filter call it. The common failure mode is writing the condition inline inside the price filter, then writing something slightly different three weeks later inside the structured data filter, and never noticing that the two disagree for variations or for child categories.

Start with the function, not with the filter.

// Which product_cat terms are quote-only, including their children.
function shop_hidden_term_ids() {
    static $ids = null;
    if ( null !== $ids ) {
        return $ids;
    }
    $ids = array();
    foreach ( array( 'made-to-order', 'trade-only' ) as $slug ) {
        $term = get_term_by( 'slug', $slug, 'product_cat' );
        if ( ! $term ) {
            continue;
        }
        $ids[] = (int) $term->term_id;
        $ids   = array_merge( $ids, get_term_children( $term->term_id, 'product_cat' ) );
    }
    $ids = array_values( array_unique( array_map( 'intval', $ids ) ) );
    return $ids;
}

// The single source of truth. Everything else calls this.
function shop_price_is_hidden( $product ) {
    if ( ! $product instanceof WC_Product ) {
        return false;
    }
    $id = $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id();
    return has_term( shop_hidden_term_ids(), 'product_cat', $id );
}

Two details in there are the ones people get wrong. First, has_term() only matches terms actually assigned to the post; it does not walk up the tree. If your products sit in CNC parts, which is a child of Made to order, then has_term( 'made-to-order', ... ) returns false and nothing is hidden. Collecting the children with get_term_children() fixes that, and caching the list in a static keeps you from running two term queries per product in a loop of forty.

Second, a WC_Product_Variation has no categories of its own, so asking it directly always returns false. Resolve the parent ID or every variation in the range slips through the check while the parent looks fine. Grouped products have the opposite shape: each child is an ordinary standalone product with its own categories, so a quote-only child will be hidden inside an otherwise visible grouped parent, and a visible child will keep its price inside a hidden one. That is usually correct behaviour, but check it against what you actually meant.

If you would rather flag individual products than whole categories, swap product_cat for product_tag and tag them quote-only, or read a checkbox you save as post meta. The predicate signature stays the same, so the rest of this article does not change.

The filter everyone starts with#

With the predicate in place, the visible part is short.

add_filter( 'woocommerce_get_price_html', 'shop_filter_price_html', 100, 2 );
function shop_filter_price_html( $price_html, $product ) {
    if ( shop_price_is_hidden( $product ) ) {
        return '<span class="price-on-request">' . esc_html__( 'Price on request', 'my-shop' ) . '</span>';
    }
    return $price_html;
}

Priority 100 matters more than it looks. Plenty of themes and currency-switcher plugins also filter woocommerce_get_price_html; running late means yours is the last word. This one filter covers the single product page, the shop and category loops, related products, upsells, cross-sells and the grouped product table, because all of them render through get_price_html().

It does not cover anything that reads the raw price rather than the formatted HTML. That is the whole rest of this article.

Targeting by role, not only by category#

Category answers “which products”. Role answers “which people”. Most trade catalogues need both: the made-to-order range is quote-only for the public, but an approved wholesale account should see its prices and check out normally. Put the role test inside the same predicate so it cannot drift. The version below replaces the earlier shop_price_is_hidden() — keep both and PHP will stop with a redeclaration error.

function shop_price_is_hidden( $product ) {
    if ( ! $product instanceof WC_Product ) {
        return false;
    }
    $id = $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id();
    if ( ! has_term( shop_hidden_term_ids(), 'product_cat', $id ) ) {
        return false;
    }
    $can_see = array( 'wholesale_customer', 'shop_manager', 'administrator' );
    $user    = wp_get_current_user();
    if ( $user->ID && array_intersect( $can_see, (array) $user->roles ) ) {
        return false;
    }
    return true;
}

Three warnings about the role branch. Page caches usually serve a single cached copy to all anonymous visitors and bypass for logged-in users, which is the behaviour you want, but a badly configured cache will hand a wholesale-priced page to a guest; test it with a private window before you trust it. On REST and Store API requests there is often no logged-in user at all, so the predicate must default to hidden rather than assuming a session. And a role check is a display rule, not a permission system — it is fine for pricing, it is not a way to keep a document private.

If the whole shop, rather than one range, should be gated behind an account, the simpler pattern in hiding WooCommerce prices until customers log in is a better starting point than this one.

Where a partly hidden catalogue leaks#

A WooCommerce hide price for specific category rule that stops at the price HTML has eight places left to go wrong. Here they are, in roughly the order people discover them, usually by finding their own numbers in a Google result.

  • Sorting by price. The Sort by: price low to high control still orders your hidden products correctly, because it sorts on the wc_product_meta_lookup table. Anyone can binary-search a hidden product’s price to within a few pounds by watching where it lands between two visible ones.
  • The price filter. The classic Filter by price widget and the block editor’s price filter both read minimum and maximum from that same lookup table. A slider that runs to £8,400 when your visible catalogue tops out at £300 has told the visitor what the hidden range costs.
  • Variable products. get_price_html() handles the “from £x” range on the page, but WooCommerce also prints every variation’s price into the data-product_variations JSON so the front-end script can swap prices without a request. View source and it is all there.
  • The Store API. Block product grids and their filter blocks read /wp-json/wc/store/v1/products, whose prices node is serialised from the raw amounts through its own schema and never passes through woocommerce_get_price_html. The collection-data endpoint returns the aggregated price range separately again.
  • Structured data. WooCommerce core prints a JSON-LD Offer with the price in it. So, independently, do Rank Math, Yoast, All in One SEO and the schema plugins. Each one is its own code path, and each one feeds Google’s rich results.
  • REST and GraphQL. wc/v3 product and variation endpoints expose price, regular_price, sale_price and price_html. WPGraphQL with WooGraphQL exposes the same values under different field names.
  • Cart, checkout and e-mail. If a hidden product can still be added to the cart, the mini-cart, the order review table and the confirmation e-mail will all print its price quite happily.
  • Product feeds. CTX Feed, Google Product Feed and similar exporters read prices straight from the product object. No display filter will ever stop them.

Three of these have short, sane fixes. Structured data first, since it is the one that ends up in search results:

add_filter( 'woocommerce_structured_data_product', function ( $markup, $product ) {
    if ( shop_price_is_hidden( $product ) ) {
        unset( $markup['offers'] );
    }
    return $markup;
}, 10, 2 );

That covers WooCommerce core only. Rank Math, Yoast, All in One SEO and any schema plugin you run each build their own graph and each need their own filter.

Then the classic REST API, for both products and variations:

function shop_blank_rest_prices( $response, $object ) {
    if ( ! shop_price_is_hidden( $object ) ) {
        return $response;
    }
    foreach ( array( 'price', 'regular_price', 'sale_price' ) as $field ) {
        if ( isset( $response->data[ $field ] ) ) {
            $response->data[ $field ] = '';
        }
    }
    $response->data['price_html'] = '';
    return $response;
}
add_filter( 'woocommerce_rest_prepare_product_object', 'shop_blank_rest_prices', 10, 2 );
add_filter( 'woocommerce_rest_prepare_product_variation_object', 'shop_blank_rest_prices', 10, 2 );

And the variation JSON on the product page:

add_filter( 'woocommerce_available_variation', function ( $data, $parent, $variation ) {
    if ( shop_price_is_hidden( $variation ) ) {
        $data['price_html']            = '<span class="price-on-request">' . esc_html__( 'Price on request', 'my-shop' ) . '</span>';
        $data['display_price']         = '';
        $data['display_regular_price'] = '';
    }
    return $data;
}, 10, 3 );

Be honest with yourself about that last one: blanking display_price leaves the add-to-cart script with no number to work with, so quantity totals and some theme scripts will behave oddly. That is only acceptable because a product with a hidden price should not be purchasable anyway, which is the next section. The wider problem of ranges is covered in more detail in hiding the price range on variable products.

The Store API is where a snippet library stops being fun. There is no tidy per-field filter equivalent to woocommerce_get_price_html for the numeric prices object; you end up intercepting the dispatched REST response for the /wc/store/v1/ routes and rewriting the prices node, the price_html string and the aggregated range in collection-data, then doing it again for cart responses. It is perfectly doable, but it is a few hundred lines and it needs re-testing whenever the schema version moves. This is roughly the point where a plugin earns its keep: PriceVeil applies one rule server-side across the storefront, the Store API, structured data (WooCommerce core JSON-LD plus Rank Math, Yoast, All in One SEO and Schema & Structured Data for WP), the wc/v3 REST API and GraphQL. Its free version hides from everyone or from guests only; choosing which categories, products and roles the rule applies to is a Pro feature.

Which surfaces the price HTML filter actually covers#

SurfaceCovered by the price HTML filter?What it needs
Product page, shop loop, relatedYesNothing further
Grouped product tableYesEach child is judged on its own categories — confirm that is what you meant
Variable price rangeYesPlus the variation JSON filter
Variation JSON in page sourceNowoocommerce_available_variation
Sort by price, price filterNoRemove the controls, or exclude the terms from the query
Store API prices and collection rangeNoRewrite the REST response for the store routes
JSON-LD, core and SEO pluginsNoOne filter per source
REST wc/v3, GraphQLNoBlank the price fields per endpoint
Cart, checkout, e-mailsNoStop the product being purchasable
Product feed exportersNoExclude the categories in the feed plugin

Hiding the price usually means removing the button too#

A product with no visible price and a live Add to Cart button is worse than either extreme: the customer can complete a purchase at a figure they were never shown, and your first sight of the problem is a refund request. For the hidden subset only, make them non-purchasable.

add_filter( 'woocommerce_is_purchasable', function ( $purchasable, $product ) {
    return shop_price_is_hidden( $product ) ? false : $purchasable;
}, 10, 2 );

That single filter does a lot of work, because WooCommerce re-checks purchasability when anything is added to the cart, including through the Store API. The button still needs removing from the classic loop and the single template, and block themes need their own handling; removing the Add to Cart button and building catalog mode without a plugin both go through that in full. If you use PriceVeil instead, its catalog mode is free — it strips add-to-cart from classic and block templates, including the variations form, and rejects Store API cart writes so the API is not a side door — but it is a shop-wide switch, and narrowing any of the hiding to chosen categories, products or roles is Pro targeting.

Then give the visitor something to do instead. A quote button standing where the Add to Cart button used to be is an obvious next step in a way that a “contact us” line at the bottom of the page is not, and adding a request-a-quote flow covers the form, the nonce, the rate limiting and where the requests should land.

What none of this solves#

  • Themes that call get_price() directly. Some page builders and premium themes render prices from the raw getter or from their own product widgets. No display filter reaches them; you have to find the template and edit it.
  • Feeds and integrations. If a plugin exports your catalogue to Google, Meta or a marketplace, exclude the hidden categories in that plugin’s own settings. Nothing in your theme’s functions.php will do it for you.
  • Pages already cached, and already indexed. A page cached before you added the rule keeps serving the old HTML until it is purged, and a search result crawled last month keeps showing the old price until Google recrawls. Purge everything once, then request reindexing for the affected URLs.
  • Inference from what is left. Sorting, filtering and faceted search let a determined visitor bracket a hidden price even when the number itself never appears. If that matters commercially, remove the price sorting and filtering controls on those archives rather than pretending the number is secret.
  • Your own admin exports. CSV exports, invoices and order e-mails for staff still contain real figures, and should.

What to do next#

Write the predicate first and keep it in a small site plugin rather than functions.php, so a theme update cannot take your pricing rules with it. Add the price HTML filter, the purchasability filter, the structured data filter and the REST filters. Then verify rather than assume: open one hidden product in a private window, view source and search the raw HTML for the number; hit /wp-json/wc/store/v1/products?per_page=100 and search that; run the URL through Google’s Rich Results Test; and sort a category by price to see what the ordering tells you. The method we use for that, and the reasoning behind checking each surface separately, is written up in how we leak-test every WooCommerce plugin.

If that checklist comes back clean, you are done and you own the code. If the Store API and schema layers are where it falls apart — which is the usual outcome on a block theme — PriceVeil’s documentation covers how it is configured, its settings screen prints a coverage report naming both what is protected automatically and what you still have to check yourself, and its wp priceveil selftest command walks every product asserting that the price HTML, the structured data and the Store API response are blank, exiting non-zero on any leak. Either way, treat a WooCommerce hide price for specific category rule as a catalogue-wide invariant to be tested, not a snippet to be pasted and forgotten.

Keep reading

Related articles