The Problem
A client pinged me Friday afternoon: their store had stopped taking orders. Customers added products to the cart fine, hit checkout, filled in the form, then got kicked back to the cart with a red banner reading "Sorry, this product cannot be purchased." No payment attempt, no order created, no useful logs in wp-content/debug.log. Nothing on the product page itself looked off: same In Stock badge, same price, same Add to Cart button.
If you have hit this on WooCommerce 9.x or 10.x and the product is genuinely purchasable, the message is misleading. WooCommerce throws it as a catch-all from WC_Cart::check_cart_items() whenever validation fails. The fix is figuring out which of the eight underlying conditions tripped.
Why It Happens
check_cart_items() runs on every cart and checkout request. It loops the cart, calls $product->is_purchasable() on each line, and if any product returns false it removes the line and shows the message. The check returns false when:
- The product is no longer published (status changed to draft or trashed).
- The price is empty or zero and "allow zero-priced sales" is off.
- Catalog visibility is set to Hidden or Search only, not Shop and search.
- A variable product's chosen variation no longer exists or has been disabled.
- The product requires login and the user logged out (membership plugins).
- A custom filter on
woocommerce_is_purchasablereturned false somewhere. - Out of stock with
manage_stockon and no backorders. - HPOS sync left the cart pointing at an orphaned product ID after a recent sync cycle.
The cart holds the product ID at add-to-cart time but re-validates at checkout, so a product that was fine ten minutes ago can fail now if anything above changed.
The Fix
Start by isolating which condition is firing. Drop this into a must-use plugin at wp-content/mu-plugins/debug-purchasable.php:
<?php
add_filter('woocommerce_is_purchasable', function ($purchasable, $product) {
if (!$purchasable) {
$reasons = [];
if (!in_array($product->get_status(), ['publish'], true)) {
$reasons[] = 'status:' . $product->get_status();
}
if ('' === $product->get_price()) {
$reasons[] = 'empty_price';
}
if (!in_array($product->get_catalog_visibility(), ['visible', 'catalog'], true)) {
$reasons[] = 'visibility:' . $product->get_catalog_visibility();
}
if (!$product->is_in_stock()) {
$reasons[] = 'out_of_stock';
}
error_log(sprintf(
'[purchasable=false] id=%d type=%s reasons=%s',
$product->get_id(),
$product->get_type(),
implode(',', $reasons) ?: 'filter_override'
));
}
return $purchasable;
}, 9999, 2);
Reproduce the error in an incognito window. Tail the log:
tail -f wp-content/debug.log | grep purchasable
The output tells you exactly which gate failed. From there:
If reasons=empty_price: Open the product, set a price (even 0.01), save. If it should be zero-priced, add this once:
add_filter('woocommerce_is_purchasable', function ($purchasable, $product) {
if ('' === $product->get_price() && $product->get_meta('_allow_free') === 'yes') {
return true;
}
return $purchasable;
}, 10, 2);
Then add a custom field _allow_free set to yes on the products you want free.
If reasons=visibility:hidden: Edit the product, open the Catalog Visibility box in the Publish sidebar, switch to "Shop and search results."
If reasons=status:draft: A workflow plugin like PublishPress demoted it. Republish and check the pending-review state.
If reasons=filter_override: Another plugin is returning false from woocommerce_is_purchasable. Find it with this:
add_filter('woocommerce_is_purchasable', function ($purchasable, $product) {
global $wp_filter;
if (!$purchasable && isset($wp_filter['woocommerce_is_purchasable'])) {
foreach ($wp_filter['woocommerce_is_purchasable']->callbacks as $priority => $callbacks) {
foreach ($callbacks as $cb) {
$name = is_array($cb['function'])
? get_class($cb['function'][0]) . '::' . $cb['function'][1]
: (is_string($cb['function']) ? $cb['function'] : 'closure');
error_log("[purchasable cb] priority=$priority fn=$name");
}
}
}
return $purchasable;
}, 99999, 2);
The output lists every callback hooked to that filter. The culprit is usually a membership, B2B, or wholesale plugin with a per-role gate. Disable them one by one to confirm.
If the product is a variation: Check wp_posts for the variation row. After a sloppy CSV reimport, parent product IDs survive but variation rows get duplicated or orphaned:
SELECT ID, post_status, post_parent
FROM wp_posts
WHERE post_type = 'product_variation'
AND post_parent = YOUR_PARENT_ID;
Any rows with post_status = 'trash' or with a post_parent that no longer exists need to be deleted or reattached. Run a single product lookup-replace via WP-CLI to fix the cart references:
wp wc product_variation list --parent=YOUR_PARENT_ID --status=any
Then delete trash rows with wp post delete <id> --force.
If you are on HPOS and recently ran a sync: The order tables can hold a product ID that no longer maps to a variation post. Trigger a full re-sync:
wp wc hpos verify_cot_data
wp wc hpos sync
Once the underlying cause is fixed, clear all session carts so existing customers do not stay stuck on the bad state:
wp transient delete --all
wp db query "DELETE FROM wp_woocommerce_sessions;"
For deeper context on cart validation hooks, the WooCommerce developer reference covers the full cart object lifecycle.
The Lesson
WooCommerce's "cannot be purchased" message is a single string for eight different failure modes, which is why every Google result tells you to do something different. Log the actual gate that failed, fix that one thing, and clear sessions so customers do not stay broken.
If your store is bleeding orders to a checkout error and you need it patched today, that is the kind of work I do — see my services, or read the related WooCommerce mini cart not updating after AJAX fix for another silent cart failure mode.
