WooCommerce’s catalog visibility system controls where products appear — in the shop, in search results, or only via direct URL. Understanding how to filter visibility programmatically lets you build role-based catalogs, B2B price lists, and pre-launch product pages without custom post status hacks.
Problem: A B2B WooCommerce site needs to hide certain products or entire categories from non-registered visitors or specific customer roles — wholesale products should not be visible to retail customers.
Solution: Use the woocommerce_product_query action to add a meta_query that filters products by a custom visibility meta field for the current user's role. For category-level restriction, override the WC_Product_Visibility logic with the woocommerce_product_is_visible filter and check current_user_can() against a custom capability.
The examples below filter catalog queries to hide products by category for guests, programmatically set product visibility, restrict specific products to logged-in users, and build a wholesale catalog that shows a different price tier.
set_catalog_visibility( $visibility );
$product->save();
}
// Hide an entire category from guests (B2B catalog)
add_action( 'woocommerce_product_query', function( WP_Query $q ) {
if ( is_user_logged_in() ) return;
$tax_query = (array) $q->get( 'tax_query' );
$tax_query[] = [
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => [ 'wholesale', 'trade-only' ],
'operator' => 'NOT IN',
];
$q->set( 'tax_query', $tax_query );
} );
// Filter the main shop query to exclude products tagged 'coming-soon' for guests
add_action( 'pre_get_posts', function( WP_Query $q ) {
if ( ! $q->is_main_query() || ! is_shop() ) return;
if ( current_user_can( 'manage_options' ) ) return;
$tax_query = (array) $q->get( 'tax_query' );
$tax_query[] = [
'taxonomy' => 'product_tag',
'field' => 'slug',
'terms' => [ 'coming-soon' ],
'operator' => 'NOT IN',
];
$q->set( 'tax_query', $tax_query );
} );
Show different prices to wholesale users and restrict single product pages:
roles, true ) ) return $price;
$wholesale = get_post_meta( $product->get_id(), '_wholesale_price', true );
return $wholesale ?: $price;
}, 10, 2 );
// Also apply to variation prices
add_filter( 'woocommerce_product_variation_get_price', function( string $price, WC_Product_Variation $product ): string {
if ( ! is_user_logged_in() ) return $price;
$user = wp_get_current_user();
if ( ! in_array( 'wholesale_customer', (array) $user->roles, true ) ) return $price;
$wholesale = get_post_meta( $product->get_id(), '_wholesale_price', true );
return $wholesale ?: $price;
}, 10, 2 );
// Redirect guests away from restricted single product pages
add_action( 'template_redirect', function() {
if ( ! is_product() || is_user_logged_in() ) return;
global $post;
$restricted = get_post_meta( $post->ID, '_requires_login', true );
if ( $restricted ) {
wp_safe_redirect( wp_login_url( get_permalink() ) );
exit;
}
} );
// Add 'Requires Login' meta box in the product editor
add_action( 'woocommerce_product_options_general_product_data', function() {
woocommerce_wp_checkbox( [
'id' => '_requires_login',
'label' => __( 'Requires Login', 'myplugin' ),
'desc_tip' => true,
'description' => __( 'Redirect guests to login before viewing this product.', 'myplugin' ),
] );
} );
NOTE: The woocommerce_product_query action modifies the WC-specific product loop query, while pre_get_posts modifies the broader WordPress query — use both when you need catalog restrictions to apply to archive pages, shortcodes, and block-rendered product lists simultaneously.