The hidden flaw in WooCommerce’s max quantity setting
If you’ve ever toggled “Sold individually” on a product and assumed you just made it one per customer — you didn’t. WooCommerce’s built-in quantity controls protect a single cart at a single moment. Once the customer hits Place Order, the limit resets and they can come right back and buy again. This isn’t a bug. It’s how WooCommerce was designed. But it absolutely will catch you off-guard on a limited drop.
Here’s exactly what’s happening, why, and the three ways to actually enforce a real per-customer limit.
Reproducing the gap in 60 seconds
You can demo this to yourself in a minute:
- Edit any product in WooCommerce.
- Open the Inventory tab. Check Sold individually. Save.
- As a customer, add to cart — the quantity input is locked at 1.
- Check out. Order completes.
- Visit the product again (still logged in, same session). Add to cart. Quantity is locked at 1 again, but the cart starts at 1. Check out. Second order completes.
Same customer, same product, two orders. Mission accomplished if you were a determined collector, mission failed if you were the store owner running a limited drop.
Why this happens (the architectural reason)
WooCommerce’s cart and product-quantity controls operate at the transaction level. Each cart is a fresh slate. The system asks “how many of this product is in this cart right now?” and applies the cap to that number. It does not ask “how many has this customer ever bought?”
That design choice makes sense for traditional e-commerce, where re-purchases are desired behavior. A coffee shop wants the regular to come back next Tuesday. A SaaS company wants the customer to add another seat. A craft brewery wants them to buy two six-packs after the weekend was rough. WooCommerce assumed the dominant case: stores want repeat purchases.
Limited drops are the inverse case: repeat purchases are explicitly forbidden. WooCommerce doesn’t have a primitive for that, so store owners reach for the only nearby tool (“Sold individually”), assume it does what the label seems to say, and only discover the truth when the launch tweet posts and Maria, John, and Maria’s alt-email all walk away with the limited edition shirt.
What the popular plugins actually do
WooCommerce Min/Max Quantities (official)
Adds Minimum quantity and Maximum quantity fields per product. Better than Sold individually for tuning your per-cart rules — you can set max 3, or min 2, or step increments. But the limit is still scoped to the current cart. Same flaw.
Advanced Product Quantities for WooCommerce / similar third-party plugins
Same pattern: more knobs on the per-cart limit. Some let you set role-based caps (wholesale buys in cases of 6, retail buys in singles). Useful, doesn’t solve the cross-order problem.
The category that does solve it
Per-customer / lifetime purchase-limit plugins. DropLock is in this category. So is “Restrict Products by User Role” with some configuration. The trick they all share: they query the customer’s prior orders at the moment of cart action and at checkout, and gate based on the running total.
The three real fixes, ranked
Fix 1: A snippet (one product, one launch)
If you only need this for a single product for a single drop, write a snippet. The shape:
add_filter( 'woocommerce_add_to_cart_validation', function( $passed, $product_id, $quantity ) {
if ( $product_id !== 12345 ) return $passed; // your product
if ( ! is_user_logged_in() ) return $passed; // guests get blocked at checkout instead
$orders = wc_get_orders( array(
'customer_id' => get_current_user_id(),
'status' => array( 'completed', 'processing', 'on-hold' ),
'limit' => -1,
) );
foreach ( $orders as $order ) {
foreach ( $order->get_items() as $item ) {
if ( (int) $item->get_product_id() === 12345 ) {
wc_add_notice( 'You've already purchased this limited product.', 'error' );
return false;
}
}
}
return $passed;
}, 10, 3 );
This catches logged-in customers at add-to-cart. It does not catch guests (they have no user ID until checkout), it does not catch cart-quantity updates, and it falls over the moment you add a second product to protect. But for a one-shot launch you’ll personally babysit, it’s acceptable.
Fix 2: A snippet that handles guests too
Better version — hook checkout validation and read the billing email:
add_action( 'woocommerce_after_checkout_validation', function( $data, $errors ) {
$email = sanitize_email( $data['billing_email'] ?? '' );
if ( ! $email ) return;
$orders = wc_get_orders( array(
'billing_email' => $email,
'status' => array( 'completed', 'processing', 'on-hold' ),
'limit' => -1,
) );
$purchased = 0;
foreach ( $orders as $order ) {
foreach ( $order->get_items() as $item ) {
if ( (int) $item->get_product_id() === 12345 ) {
$purchased += $item->get_quantity();
}
}
}
if ( $purchased >= 1 ) {
$errors->add( 'limit', 'You've already purchased this limited product.' );
}
}, 10, 2 );
Combine with Fix 1 for the logged-in case. You’re now covering 90% of real customers.
Where snippets still break:
- Variations — you have to check the parent or every variation manually.
- HPOS (High-Performance Order Storage) — if your snippet uses
get_posts()withpost_type => 'shop_order'instead ofwc_get_orders(), it silently returns nothing once HPOS is enabled. Audit every snippet before you flip HPOS on production. - Performance —
limit => -1is fine for one product on a small store. On a 100k-order store with the snippet on five products, every cart page load now triggers five full-history queries. Plan caching. - Admin / shop manager bypass — the snippet blocks you from placing a manual order for a VIP. Add a
current_user_can( 'manage_woocommerce' )escape hatch. - Custom error messages, badges, blocked-attempt logs — all on you to build.
Fix 3: A dedicated plugin (production stores)
This is what we built DropLock for. It handles all of the above:
- Add-to-cart, cart-update, and checkout validation in one cohesive flow.
- Logged-in and guest customers (matched by user ID + billing email, deduplicated by order ID).
- Variations roll up to the parent product automatically.
- HPOS-compatible by design.
- Admin / shop manager bypass built in.
- Per-product custom message and product page badge.
- Per-request cache so the lookup doesn’t hammer your DB.
- Blocked-attempt log so you can see who’s hitting the wall.
- One toggle per product. No globals to wrestle with.
DropLock for WooCommerce
Enforce real lifetime per-customer purchase limits. HPOS and Block Checkout supported. One toggle per product, then it does its job quietly.
See pricing — from $49What WooCommerce could do, but probably won’t
The WooCommerce core team could add a “Lifetime quantity per customer” setting alongside Sold individually. It’s a small UI change and the underlying query isn’t expensive. But it implies storing identity-keyed data per product, which intersects with privacy considerations, GDPR data retention, and edge cases (refunds? cancellations? imported orders?). The team has been deliberate about not letting features creep into the cart layer without a clear story across all of those.
So this is unlikely to land in core soon. Which means the gap stays open, and store owners have to fix it themselves — with snippets, with category plugins, or with us.
Quick checklist before your next drop
- Add-to-cart blocks customers who already purchased (logged-in).
- Checkout blocks guests using a previously-used billing email.
- Variations of the same product roll up to one shared limit.
- You can place a manual order as admin without being blocked.
- Refund or cancel behaviour matches what you want.
- HPOS, if enabled, doesn’t break the limit.
- You’ve tested a real second-purchase attempt end-to-end before launch.
If any of those are unchecked an hour before launch — either skip the launch or grab a plugin that covers them. The angry-customer cost of a botched drop is higher than the $49 price of solving it.