how-to · · 9 min read
how to add a name your price field in woocommerce
Let customers choose what they pay for a WooCommerce product, with a minimum you set and the amount carried to the order. Hooks only, no plugin.
By Big Bad Agency
Pay what you want. Name your price. A tip jar on a digital download, a donation product, a “support the project” tier, a sliding-scale class. WooCommerce doesn’t do any of it out of the box. Every product has one price, set by you, and the customer can’t change it.
(If the digital download is an ebook, the delivery side is its own job. We cover it in how to sell an ebook with woocommerce.)
You can add it with a plugin, or with a handful of hooks. Here’s the hook version in full: a minimum price you set per product, a suggested price the customer starts from, and the amount they chose carried all the way to the order. Plus the three things most name-your-price snippets get wrong: the mini-cart snapping back to the old price, tax quietly landing on top of the number the customer typed, and a coupon taking a chosen price below your minimum.
first, decide what the minimum is for
A name-your-price product has two numbers, and both of them are decisions.
- The suggested price is what most people will pay. Plenty of customers accept whatever is already in the box. In this build it’s the product’s normal WooCommerce price, so it’s also what shows on shop pages and in your product feeds.
- The minimum is the lowest amount you’ll accept. For a donation it might be $1. For a physical product it should never be below what the product costs you to make and ship, or every customer who pays the minimum is a loss.
If you don’t know that cost yet, work it out before you set a minimum. Profit per order walks through the sum.
the plan
Products tagged name-your-price get:
- a Minimum price box in the product editor, next to the regular price
- an amount field on the product page, filled in with the suggested price
- a check that refuses anything under the minimum
- the chosen amount stored on the cart line and used as that line’s price
- the amount saved on the order line
This covers simple products. Variable products price each variation separately, and that needs a minimum per variation. That’s a bigger build, covered at the end.
Paste the code into a small plugin or your child theme’s functions.php. The whole thing, as one file, is at the end.
1. two small helpers
function bb_nyp_applies( $product ) {
return $product
&& $product->is_type( 'simple' )
&& has_term( 'name-your-price', 'product_tag', $product->get_id() );
}
function bb_nyp_minimum( $product ) {
return (float) $product->get_meta( '_bb_nyp_min' );
}
function bb_nyp_posted_amount() {
if ( ! isset( $_POST['bb_nyp_price'] ) || ! is_string( $_POST['bb_nyp_price'] ) ) {
return null;
}
return wc_format_decimal( wp_unslash( $_POST['bb_nyp_price'] ), wc_get_price_decimals() );
}
function bb_nyp_amount_ok( $product, $amount ) {
return is_numeric( $amount ) && (float) $amount > 0 && (float) $amount >= bb_nyp_minimum( $product );
}
bb_nyp_posted_amount() is doing more than it looks. wc_format_decimal turns your store’s decimal separator into a dot, strips everything that isn’t a digit, a dot or a minus sign, and rounds to your store’s decimal places. So a customer typing $20, 20,00 on a store that uses a comma, or 19.999 all becomes a number you can compare. The is_string check refuses a request that sends the field as an array instead of text.
2. add the minimum to the product editor
add_action( 'woocommerce_product_options_pricing', function () {
woocommerce_wp_text_input( array(
'id' => '_bb_nyp_min',
'label' => __( 'Minimum price', 'your-textdomain' ) . ' (' . get_woocommerce_currency_symbol() . ')',
'data_type' => 'price',
'desc_tip' => true,
'description' => __( 'The lowest amount a customer can pay when this product is tagged name-your-price.', 'your-textdomain' ),
) );
} );
add_action( 'woocommerce_admin_process_product_object', function ( $product ) {
if ( isset( $_POST['_bb_nyp_min'] ) ) {
$product->update_meta_data( '_bb_nyp_min', wc_format_decimal( wp_unslash( $_POST['_bb_nyp_min'] ) ) );
}
} );
woocommerce_product_options_pricing puts the box in the General tab, under the regular and sale price, which is where whoever edits the product will look for it. 'data_type' => 'price' gives it the same formatting and validation as WooCommerce’s own price boxes. Saving through woocommerce_admin_process_product_object means WooCommerce has already checked the editor’s security nonce before your code runs.
Leave the box empty and the minimum is zero, so any amount above zero is accepted.
3. show the field on the product page
add_action( 'woocommerce_before_add_to_cart_button', function () {
global $product;
if ( ! bb_nyp_applies( $product ) ) {
return;
}
$min = bb_nyp_minimum( $product );
$label = __( 'Name your price', 'your-textdomain' );
if ( wc_tax_enabled() && $product->is_taxable() && ! wc_prices_include_tax() ) {
$label .= ' ' . __( '(before tax)', 'your-textdomain' );
}
?>
<p class="form-row nyp-field">
<label for="bb_nyp_price"><?php echo esc_html( $label ); ?></label>
<input type="text" inputmode="decimal" id="bb_nyp_price" name="bb_nyp_price" value="<?php echo esc_attr( wc_format_localized_price( $product->get_price() ) ); ?>" />
<?php if ( $min > 0 ) : ?>
<small><?php
/* translators: %s: minimum price */
printf( esc_html__( 'Minimum %s', 'your-textdomain' ), wp_kses_post( wc_price( $min ) ) );
?></small>
<?php endif; ?>
</p>
<?php
} );
The field has to sit inside the add-to-cart form, or it never gets submitted. woocommerce_before_add_to_cart_button fires inside that form.
It’s a text box with inputmode="decimal", not type="number". A number input rejects a comma as the decimal separator in some browsers, and plenty of stores use one. Phones still get a number keypad.
The box starts at the suggested price. That’s the most useful default: most people pay it, and anyone who wants to pay more or less can see where they’re starting from.
The tax label is the bug most snippets ship with. The amount the customer types becomes the product’s price, and WooCommerce reads a product’s price the way your store enters prices. If your store enters prices excluding tax, a customer who types $20 pays $20 plus tax at checkout. That’s correct accounting, and a nasty surprise if nobody told them. The label says “(before tax)” in exactly that case. If your store enters prices including tax, the number they type is the number they pay, and the label stays plain.
4. check the amount before it goes in the cart
add_filter( 'woocommerce_add_to_cart_validation', function ( $passed, $product_id, $quantity ) {
$product = wc_get_product( $product_id );
$amount = bb_nyp_posted_amount();
if ( ! $passed || ! bb_nyp_applies( $product ) || null === $amount ) {
return $passed;
}
if ( ! is_numeric( $amount ) || (float) $amount <= 0 ) {
wc_add_notice( __( 'Please enter the amount you want to pay.', 'your-textdomain' ), 'error' );
return false;
}
if ( ! bb_nyp_amount_ok( $product, $amount ) ) {
/* translators: %s: minimum price */
wc_add_notice( sprintf( __( 'The minimum for this product is %s.', 'your-textdomain' ), wc_price( bb_nyp_minimum( $product ) ) ), 'error' );
return false;
}
return $passed;
}, 10, 3 );
Returning false stops the add-to-cart, and the notice says why. The minimum lives on the server, not in the browser. Anything you put in the HTML (a min attribute, a bit of JavaScript) can be edited by the person using the browser, so it can’t be the thing that protects your price.
5. keep the amount on the cart line
add_filter( 'woocommerce_add_cart_item_data', function ( $cart_item_data, $product_id ) {
$product = wc_get_product( $product_id );
$amount = bb_nyp_posted_amount();
if ( null !== $amount && bb_nyp_applies( $product ) && bb_nyp_amount_ok( $product, $amount ) ) {
$cart_item_data['bb_nyp_price'] = (float) $amount;
}
return $cart_item_data;
}, 10, 2 );
This repeats the minimum check. Step 4 runs when a customer submits the product form, but other code can call add_to_cart() directly and skip the validation filter. Checking again here means nothing under the minimum can reach a cart line, whichever way it got there.
Whatever you add here becomes part of the cart line’s identity. The same product at $15 and at $25 makes two separate lines, not one line with quantity two. That’s right: they’re two different prices.
6. charge the chosen amount
function bb_nyp_apply_prices( $cart ) {
foreach ( $cart->get_cart() as $cart_item ) {
if ( isset( $cart_item['bb_nyp_price'] ) ) {
$cart_item['data']->set_price( $cart_item['bb_nyp_price'] );
}
}
}
add_action( 'woocommerce_before_calculate_totals', 'bb_nyp_apply_prices', 20 );
add_action( 'woocommerce_cart_loaded_from_session', 'bb_nyp_apply_prices', 20 );
The first hook sets the price when totals are calculated. The second one is the mini-cart fix.
On every page load WooCommerce rebuilds the cart from the session with freshly loaded products, at their normal price, and it only recalculates totals when something changed. So without the second hook the totals are right, and the mini-cart in your header lists the suggested price next to them. Setting the price again when the cart loads from the session means the customer sees their own number everywhere.
Because the price is set from the stored amount, not adjusted from the current price, this is safe to run as many times as WooCommerce likes. It never compounds.
7. save it on the order
add_action( 'woocommerce_checkout_create_order_line_item', function ( $item, $cart_item_key, $values ) {
if ( isset( $values['bb_nyp_price'] ) ) {
$item->add_meta_data( '_bb_nyp_price', $values['bb_nyp_price'], true );
}
}, 10, 3 );
The order line’s total already reflects what the customer paid, so the money is right without this step. What this adds is a record that the price was chosen, not set by you. That matters later, when you want to know what people actually pay when they’re given the choice. The leading underscore keeps it out of the customer’s emails and the admin order screen, because it’s for your reports, not for them.
Place a test order through whichever checkout your store actually uses, classic or block, and confirm the meta is on the line before you rely on it.
8. tell shoppers before they click
Two optional pieces, and you’ll probably want both.
The first changes how the price reads on the product page and in shop listings:
add_filter( 'woocommerce_get_price_html', function ( $price_html, $product ) {
if ( '' === $price_html || ! bb_nyp_applies( $product ) ) {
return $price_html;
}
/* translators: %s: suggested price */
return sprintf( esc_html__( 'Pay what you want, suggested %s', 'your-textdomain' ), $price_html );
}, 10, 2 );
The second deals with the “Add to cart” buttons on shop and category pages. Those buttons don’t show your field, so a click adds the product at the suggested price and the customer never gets to choose. Sending those buttons to the product page instead fixes that:
add_filter( 'woocommerce_product_add_to_cart_url', function ( $url, $product ) {
return bb_nyp_applies( $product ) ? $product->get_permalink() : $url;
}, 10, 2 );
add_filter( 'woocommerce_product_add_to_cart_text', function ( $text, $product ) {
return bb_nyp_applies( $product ) ? __( 'Choose your price', 'your-textdomain' ) : $text;
}, 10, 2 );
add_filter( 'woocommerce_product_supports', function ( $supports, $feature, $product ) {
if ( 'ajax_add_to_cart' === $feature && bb_nyp_applies( $product ) ) {
return false;
}
return $supports;
}, 10, 3 );
The third filter matters. Without it, a classic theme’s shop button still adds the product in the background, at the suggested price, and the new link never gets followed.
These filters cover the classic shop templates. If your shop pages are built with the newer product blocks, check what their buttons do before you rely on this.
9. decide whether coupons apply
Out of the box, a 20% coupon applies to the customer’s chosen price, and that can take the line below your minimum. A $10 minimum with 20% off is $8.
If that’s not acceptable, this makes percentage and fixed-product coupons skip name-your-price lines:
add_filter( 'woocommerce_coupon_is_valid_for_product', function ( $valid, $product, $coupon, $values ) {
if ( $valid && isset( $values['bb_nyp_price'] ) ) {
return false;
}
return $valid;
}, 10, 4 );
The coupon still works on everything else in the cart. A fixed cart coupon ($10 off the order) isn’t checked line by line, so this doesn’t stop it. If those shouldn’t touch chosen prices either, that’s a rule about the whole order, not a line.
before it goes live
- Test on staging first. Pay the suggested price, pay more, pay exactly the minimum, try a cent under it, try zero, try letters. Put two different amounts of the same product in the cart. Place a full order and read it in the admin, not just on the thank-you page.
- Check the product page if it’s built with blocks. The field relies on
woocommerce_before_add_to_cart_button, which the classic product template runs. Some newer add-to-cart blocks build their own form. If the field doesn’t appear, that’s why. - Think about an upper limit. Someone typing 2000 when they meant 20 is a refund and an awkward email. If that worries you, add a maximum to the check in step 4. Ten times the suggested price is generous and still catches a slipped zero.
- Other price plugins. Currency switchers and wholesale or role-based pricing plugins often change prices through filters, and this code sets the price directly. Test any combination like that carefully, or let one plugin own pricing.
- Mind your text domain. Replace
your-textdomainwith your plugin’s or theme’s.
after it goes live: look at what people actually pay
The useful thing about letting customers choose is that they tell you what the product is worth to them. Step 7 records which order lines were chosen prices, so after a few weeks you can see how many people paid the suggested price, how many paid more, and how many went to the minimum.
If most people pay the minimum, the minimum is your real price, and it had better cover your cost. Our free BigBad Store Toolkit adds a per-product cost field to WooCommerce, with a margin column, so the cost sits in the store right next to the price you’re letting people set. Set the minimum from that number, not from a guess.
when to use a plugin instead
The code above covers simple products with one minimum and one suggested price each. If you need name-your-price on variable products with a minimum per variation, subscriptions where the customer picks the recurring amount, preset amount buttons ($5, $10, $25, other), or staff managing all of this without touching code, use a dedicated name-your-price plugin. That’s a whole product, and rebuilding one in functions.php isn’t a good use of anyone’s time.
If what you need is “let the customer choose, never below X, and make sure the right amount reaches the order”, this is the whole job, in code you own.
the full file
All of the above in one plugin file, with the optional coupon rule at the end. Save it as wp-content/plugins/name-your-price/name-your-price.php and activate it under Plugins:
<?php
/**
* Plugin Name: Name Your Price
* Description: Customer-chosen prices, with a per-product minimum, for simple products tagged "name-your-price".
* Version: 1.0.0
*/
defined( 'ABSPATH' ) || exit;
function bb_nyp_applies( $product ) {
return $product
&& $product->is_type( 'simple' )
&& has_term( 'name-your-price', 'product_tag', $product->get_id() );
}
function bb_nyp_minimum( $product ) {
return (float) $product->get_meta( '_bb_nyp_min' );
}
function bb_nyp_posted_amount() {
if ( ! isset( $_POST['bb_nyp_price'] ) || ! is_string( $_POST['bb_nyp_price'] ) ) {
return null;
}
return wc_format_decimal( wp_unslash( $_POST['bb_nyp_price'] ), wc_get_price_decimals() );
}
function bb_nyp_amount_ok( $product, $amount ) {
return is_numeric( $amount ) && (float) $amount > 0 && (float) $amount >= bb_nyp_minimum( $product );
}
// Minimum price box in the product editor.
add_action( 'woocommerce_product_options_pricing', function () {
woocommerce_wp_text_input( array(
'id' => '_bb_nyp_min',
'label' => __( 'Minimum price', 'your-textdomain' ) . ' (' . get_woocommerce_currency_symbol() . ')',
'data_type' => 'price',
'desc_tip' => true,
'description' => __( 'The lowest amount a customer can pay when this product is tagged name-your-price.', 'your-textdomain' ),
) );
} );
add_action( 'woocommerce_admin_process_product_object', function ( $product ) {
if ( isset( $_POST['_bb_nyp_min'] ) ) {
$product->update_meta_data( '_bb_nyp_min', wc_format_decimal( wp_unslash( $_POST['_bb_nyp_min'] ) ) );
}
} );
// Amount field on the product page.
add_action( 'woocommerce_before_add_to_cart_button', function () {
global $product;
if ( ! bb_nyp_applies( $product ) ) {
return;
}
$min = bb_nyp_minimum( $product );
$label = __( 'Name your price', 'your-textdomain' );
if ( wc_tax_enabled() && $product->is_taxable() && ! wc_prices_include_tax() ) {
$label .= ' ' . __( '(before tax)', 'your-textdomain' );
}
?>
<p class="form-row nyp-field">
<label for="bb_nyp_price"><?php echo esc_html( $label ); ?></label>
<input type="text" inputmode="decimal" id="bb_nyp_price" name="bb_nyp_price" value="<?php echo esc_attr( wc_format_localized_price( $product->get_price() ) ); ?>" />
<?php if ( $min > 0 ) : ?>
<small><?php
/* translators: %s: minimum price */
printf( esc_html__( 'Minimum %s', 'your-textdomain' ), wp_kses_post( wc_price( $min ) ) );
?></small>
<?php endif; ?>
</p>
<?php
} );
// Refuse anything under the minimum.
add_filter( 'woocommerce_add_to_cart_validation', function ( $passed, $product_id, $quantity ) {
$product = wc_get_product( $product_id );
$amount = bb_nyp_posted_amount();
if ( ! $passed || ! bb_nyp_applies( $product ) || null === $amount ) {
return $passed;
}
if ( ! is_numeric( $amount ) || (float) $amount <= 0 ) {
wc_add_notice( __( 'Please enter the amount you want to pay.', 'your-textdomain' ), 'error' );
return false;
}
if ( ! bb_nyp_amount_ok( $product, $amount ) ) {
/* translators: %s: minimum price */
wc_add_notice( sprintf( __( 'The minimum for this product is %s.', 'your-textdomain' ), wc_price( bb_nyp_minimum( $product ) ) ), 'error' );
return false;
}
return $passed;
}, 10, 3 );
// Keep the amount on the cart line.
add_filter( 'woocommerce_add_cart_item_data', function ( $cart_item_data, $product_id ) {
$product = wc_get_product( $product_id );
$amount = bb_nyp_posted_amount();
if ( null !== $amount && bb_nyp_applies( $product ) && bb_nyp_amount_ok( $product, $amount ) ) {
$cart_item_data['bb_nyp_price'] = (float) $amount;
}
return $cart_item_data;
}, 10, 2 );
// Charge it, including in the mini-cart.
function bb_nyp_apply_prices( $cart ) {
foreach ( $cart->get_cart() as $cart_item ) {
if ( isset( $cart_item['bb_nyp_price'] ) ) {
$cart_item['data']->set_price( $cart_item['bb_nyp_price'] );
}
}
}
add_action( 'woocommerce_before_calculate_totals', 'bb_nyp_apply_prices', 20 );
add_action( 'woocommerce_cart_loaded_from_session', 'bb_nyp_apply_prices', 20 );
// Record on the order line that the price was chosen.
add_action( 'woocommerce_checkout_create_order_line_item', function ( $item, $cart_item_key, $values ) {
if ( isset( $values['bb_nyp_price'] ) ) {
$item->add_meta_data( '_bb_nyp_price', $values['bb_nyp_price'], true );
}
}, 10, 3 );
// Say so on the product page and in shop listings.
add_filter( 'woocommerce_get_price_html', function ( $price_html, $product ) {
if ( '' === $price_html || ! bb_nyp_applies( $product ) ) {
return $price_html;
}
/* translators: %s: suggested price */
return sprintf( esc_html__( 'Pay what you want, suggested %s', 'your-textdomain' ), $price_html );
}, 10, 2 );
// Send shop-page buttons to the product page, where the field is.
add_filter( 'woocommerce_product_add_to_cart_url', function ( $url, $product ) {
return bb_nyp_applies( $product ) ? $product->get_permalink() : $url;
}, 10, 2 );
add_filter( 'woocommerce_product_add_to_cart_text', function ( $text, $product ) {
return bb_nyp_applies( $product ) ? __( 'Choose your price', 'your-textdomain' ) : $text;
}, 10, 2 );
add_filter( 'woocommerce_product_supports', function ( $supports, $feature, $product ) {
if ( 'ajax_add_to_cart' === $feature && bb_nyp_applies( $product ) ) {
return false;
}
return $supports;
}, 10, 3 );
// Optional: stop percentage and fixed-product coupons applying to chosen prices.
add_filter( 'woocommerce_coupon_is_valid_for_product', function ( $valid, $product, $coupon, $values ) {
if ( $valid && isset( $values['bb_nyp_price'] ) ) {
return false;
}
return $valid;
}, 10, 4 );