how-to · · 8 min read
how to set up woocommerce quantity discounts (bulk pricing tiers)
Buy 5, save 5%. Buy 10, save 10%. Add quantity discount tiers to WooCommerce with a few hooks, show them on the product page, and check they still make money.
By Big Bad Agency
“Buy more, pay less per unit” is one of the oldest offers in retail, and WooCommerce doesn’t do it out of the box. Core has sale prices and coupons. A coupon can ask for a minimum spend, but nothing in core says “the price per unit drops when someone buys ten”.
You can add it with a plugin, or with a few hooks. Here’s the hook version, in full, plus the two things most quantity-discount snippets get wrong: the mini-cart showing the old price, and the discount quietly stacking with a coupon.
first, decide what the discount is for
A quantity discount is worth having when a bigger order genuinely costs you less per unit: one pick, one box, one label, one payment fee spread over more items. It’s a bad idea when every unit costs you the same to fulfil and your margin is thin, because then you’re paying people to buy what they’d have bought anyway.
So before any code, work out what a tier actually costs you. These numbers are made up for illustration:
- A product sells for $40 and costs you $30 all-in. You make $10 a unit.
- Ten percent off brings the price to $36. You now make $6 a unit.
- A 10% discount just took 40% of your profit on those units.
That can still be a good trade, if it moves more units or bigger orders than you’d otherwise get. It just has to be a decision. If you don’t know your cost per unit yet, start with profit per order, then come back.
the plan
We’ll give products tagged bulk-pricing three tiers:
| quantity | discount |
|---|---|
| 5+ | 5% off |
| 10+ | 10% off |
| 25+ | 15% off |
The discount goes into the unit price on the cart line, so the customer sees $36 instead of $40, not a separate fee. 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. define the tiers
function bb_qty_discount_tiers() {
$tiers = array(
5 => 5,
10 => 10,
25 => 15,
);
krsort( $tiers );
return $tiers;
}
function bb_qty_discount_percent( $qty ) {
foreach ( bb_qty_discount_tiers() as $min => $pct ) {
if ( $qty >= $min ) {
return $pct;
}
}
return 0;
}
function bb_qty_discount_applies( $product_id ) {
return has_term( 'bulk-pricing', 'product_tag', $product_id );
}
Keys are the minimum quantity, values are the percent off. krsort puts the biggest tier first, so the first match is the best one the customer qualifies for. The tag keeps the discount on the products you chose. Without a check like that, every product in the store gets it, including the ones where it loses money.
2. change the price on the cart line
function bb_apply_qty_discounts( $cart ) {
foreach ( $cart->get_cart() as $cart_item ) {
if ( ! bb_qty_discount_applies( $cart_item['product_id'] ) ) {
continue;
}
$fresh = wc_get_product( $cart_item['data']->get_id() );
$base = (float) $fresh->get_price( 'edit' );
$pct = bb_qty_discount_percent( $cart_item['quantity'] );
$cart_item['data']->set_price( $pct ? round( $base * ( 100 - $pct ) / 100, wc_get_price_decimals() ) : $base );
}
}
add_action( 'woocommerce_before_calculate_totals', 'bb_apply_qty_discounts', 20 );
add_action( 'woocommerce_cart_loaded_from_session', 'bb_apply_qty_discounts', 20 );
Three details in there are doing real work.
It always starts from a freshly loaded price. WooCommerce can calculate totals several times in one page load. If you took 10% off the price the cart is already holding, the second pass would take 10% off the discounted price, and the third pass would do it again. Starting from the product’s own price every time makes the function safe to run any number of times. It also means a customer who drops from 10 back to 4 goes back to full price instead of keeping a discount they no longer qualify for.
$cart_item['product_id'] is the parent product. For a variable product the tag lives on the parent, not on each size or colour. $cart_item['data'] is the variation itself, which is the right thing to price.
The second hook is the mini-cart fix. This is the bug most snippets ship with. When a page loads, WooCommerce rebuilds the cart from the session with fresh product objects, at full price. It only recalculates totals when something changed. So the totals are right, but the mini-cart in your header lists the old unit price beside them. Running the same function when the cart loads from the session puts the tier price back before anything is shown.
The price is rounded to your store’s decimal places so that unit price × quantity matches the line total to the cent.
3. show the saving in the cart
A discount the customer can’t see doesn’t persuade anyone. This shows the full price struck through next to the tier price:
function bb_qty_line_is_discounted( $cart_item ) {
if ( empty( $cart_item['data'] ) ) {
return false;
}
$fresh = wc_get_product( $cart_item['data']->get_id() );
return $fresh && (float) $cart_item['data']->get_price( 'edit' ) < (float) $fresh->get_price( 'edit' );
}
add_filter( 'woocommerce_cart_item_price', function ( $price_html, $cart_item ) {
if ( ! bb_qty_line_is_discounted( $cart_item ) ) {
return $price_html;
}
$fresh = wc_get_product( $cart_item['data']->get_id() );
return wc_format_sale_price(
wc_get_price_to_display( $fresh, array( 'price' => $fresh->get_price( 'edit' ), 'display_context' => 'cart' ) ),
wc_get_price_to_display( $fresh, array( 'price' => $cart_item['data']->get_price( 'edit' ), 'display_context' => 'cart' ) )
);
}, 10, 2 );
wc_format_sale_price is the same function WooCommerce uses for sale prices, so it looks like the rest of your store and includes screen-reader text for the old price. 'display_context' => 'cart' follows your cart’s tax display setting (including or excluding tax) rather than the shop’s.
One limit, stated plainly: this filter runs in the classic cart and mini-cart templates. The block-based Cart doesn’t use those templates, so this filter changes nothing there. The price change from step 2 still applies, because the block cart calculates totals through the same cart code. Check what your block cart shows before you rely on the struck-through display.
4. show the tiers on the product page
People can’t aim for a tier they don’t know exists. This prints a small table above the add-to-cart form, on simple and variable products:
add_action( 'woocommerce_before_add_to_cart_form', function () {
global $product;
if ( ! $product || ! bb_qty_discount_applies( $product->get_id() ) ) {
return;
}
echo '<table class="bulk-pricing"><thead><tr><th>' . esc_html__( 'Quantity', 'your-textdomain' ) . '</th><th>' . esc_html__( 'Discount', 'your-textdomain' ) . '</th></tr></thead><tbody>';
foreach ( array_reverse( bb_qty_discount_tiers(), true ) as $min => $pct ) {
/* translators: %d: percent off */
printf( '<tr><td>%d+</td><td>%s</td></tr>', (int) $min, esc_html( sprintf( __( '%d%% off', 'your-textdomain' ), $pct ) ) );
}
echo '</tbody></table>';
} );
It reads from the same bb_qty_discount_tiers() as the cart, so the table and the price can’t disagree. Change a tier in one place and both follow.
5. decide whether coupons stack
Out of the box, a 10% coupon applies on top of your 10% tier price. Sometimes that’s fine. Often it isn’t, and the “Exclude sale items” box on the coupon won’t help: WooCommerce decides what’s “on sale” from the product’s sale price, and a tier discount doesn’t set one.
If tiers and coupons shouldn’t stack, this makes percentage and fixed-product coupons skip any line that already has a tier discount:
add_filter( 'woocommerce_coupon_is_valid_for_product', function ( $valid, $product, $coupon, $values ) {
if ( $valid && bb_qty_line_is_discounted( $values ) ) {
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 you need to block those too, that’s a rule about the whole order, not a line.
variation: count all sizes together
As written, each cart line counts on its own. Six small and four large of the same shirt are two lines, 6 and 4, and only the first reaches a tier. If you’d rather count every variation of a product together, total the quantities by parent first:
function bb_apply_qty_discounts( $cart ) {
$qty_by_parent = array();
foreach ( $cart->get_cart() as $cart_item ) {
$parent = $cart_item['product_id'];
$qty_by_parent[ $parent ] = ( $qty_by_parent[ $parent ] ?? 0 ) + $cart_item['quantity'];
}
foreach ( $cart->get_cart() as $cart_item ) {
if ( ! bb_qty_discount_applies( $cart_item['product_id'] ) ) {
continue;
}
$fresh = wc_get_product( $cart_item['data']->get_id() );
$base = (float) $fresh->get_price( 'edit' );
$pct = bb_qty_discount_percent( $qty_by_parent[ $cart_item['product_id'] ] );
$cart_item['data']->set_price( $pct ? round( $base * ( 100 - $pct ) / 100, wc_get_price_decimals() ) : $base );
}
}
Now those ten shirts reach the 10+ tier together. The display and coupon code from steps 3 and 5 don’t need to change, because they compare each line’s price against its full price rather than recounting quantities.
before it goes live
- Test on staging first. Go just under each tier and just over it. Reduce a quantity back below a tier. Add a coupon. Place a full order and read it in the admin, not just on the thank-you page.
- Sale prices.
get_price( 'edit' )is the price the product is selling at right now, so a product on sale gets the tier discount on top of the sale price. If sale items shouldn’t get tiers, skip lines where$fresh->is_on_sale( 'edit' )is true. - Other price plugins. Currency switchers and wholesale or role-based pricing plugins often change prices through filters. Reading the price with
'edit'skips those filters, so 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: check the margin, not the revenue
Quantity discounts make revenue look good. Order size goes up, units go up, and the dashboard looks healthy. The question that matters is whether the extra units paid for the margin you gave away on the ones people would have bought anyway.
You can only answer that with your cost of goods recorded. Our free BigBad Store Toolkit adds a per-product cost field (with category defaults) to WooCommerce, so the cost is in the store next to the price, with cost and margin columns on the Products list. Pro adds margin over a date range, which is the before-and-after view you want here. Compare profit, not revenue, for a few weeks before and after. If profit didn’t move, the tiers are a giveaway, and you should lower them or limit them to the products where bigger orders really do cost less to ship.
when to use a plugin instead
The code above covers one set of tiers for a tagged group of products. If you need different tiers per product, edited by staff in the product screen, wholesale prices by customer role, “buy 3 get 1 free” offers or a discount rules builder, use a dedicated pricing 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 “buy more, pay less per unit, and show it”, this is the whole job, in code you own.
the full file
All of the above in one plugin file (with the per-line counting from step 2). Save it as wp-content/plugins/quantity-discounts/quantity-discounts.php and activate it under Plugins:
<?php
/**
* Plugin Name: Quantity Discounts
* Description: Bulk-pricing tiers for products tagged "bulk-pricing".
* Version: 1.0.0
*/
defined( 'ABSPATH' ) || exit;
function bb_qty_discount_tiers() {
$tiers = array(
5 => 5,
10 => 10,
25 => 15,
);
krsort( $tiers );
return $tiers;
}
function bb_qty_discount_percent( $qty ) {
foreach ( bb_qty_discount_tiers() as $min => $pct ) {
if ( $qty >= $min ) {
return $pct;
}
}
return 0;
}
function bb_qty_discount_applies( $product_id ) {
return has_term( 'bulk-pricing', 'product_tag', $product_id );
}
function bb_apply_qty_discounts( $cart ) {
foreach ( $cart->get_cart() as $cart_item ) {
if ( ! bb_qty_discount_applies( $cart_item['product_id'] ) ) {
continue;
}
$fresh = wc_get_product( $cart_item['data']->get_id() );
$base = (float) $fresh->get_price( 'edit' );
$pct = bb_qty_discount_percent( $cart_item['quantity'] );
$cart_item['data']->set_price( $pct ? round( $base * ( 100 - $pct ) / 100, wc_get_price_decimals() ) : $base );
}
}
add_action( 'woocommerce_before_calculate_totals', 'bb_apply_qty_discounts', 20 );
add_action( 'woocommerce_cart_loaded_from_session', 'bb_apply_qty_discounts', 20 );
function bb_qty_line_is_discounted( $cart_item ) {
if ( empty( $cart_item['data'] ) ) {
return false;
}
$fresh = wc_get_product( $cart_item['data']->get_id() );
return $fresh && (float) $cart_item['data']->get_price( 'edit' ) < (float) $fresh->get_price( 'edit' );
}
add_filter( 'woocommerce_cart_item_price', function ( $price_html, $cart_item ) {
if ( ! bb_qty_line_is_discounted( $cart_item ) ) {
return $price_html;
}
$fresh = wc_get_product( $cart_item['data']->get_id() );
return wc_format_sale_price(
wc_get_price_to_display( $fresh, array( 'price' => $fresh->get_price( 'edit' ), 'display_context' => 'cart' ) ),
wc_get_price_to_display( $fresh, array( 'price' => $cart_item['data']->get_price( 'edit' ), 'display_context' => 'cart' ) )
);
}, 10, 2 );
add_action( 'woocommerce_before_add_to_cart_form', function () {
global $product;
if ( ! $product || ! bb_qty_discount_applies( $product->get_id() ) ) {
return;
}
echo '<table class="bulk-pricing"><thead><tr><th>' . esc_html__( 'Quantity', 'your-textdomain' ) . '</th><th>' . esc_html__( 'Discount', 'your-textdomain' ) . '</th></tr></thead><tbody>';
foreach ( array_reverse( bb_qty_discount_tiers(), true ) as $min => $pct ) {
/* translators: %d: percent off */
printf( '<tr><td>%d+</td><td>%s</td></tr>', (int) $min, esc_html( sprintf( __( '%d%% off', 'your-textdomain' ), $pct ) ) );
}
echo '</tbody></table>';
} );
// Optional: stop percentage and fixed-product coupons stacking on a tier discount.
add_filter( 'woocommerce_coupon_is_valid_for_product', function ( $valid, $product, $coupon, $values ) {
if ( $valid && bb_qty_line_is_discounted( $values ) ) {
return false;
}
return $valid;
}, 10, 4 );