woocommerce limit purchase per customer

How to Limit WooCommerce Purchases to One Per Customer

WooCommerce’s “Sold Individually” option and the “Maximum quantity” field both look like they solve this, but they don’t. Both only limit quantity within a single order. A customer can still check out once, come back the next day, and order again – or open two browser tabs and place two separate orders back to back. If you’re running a limited drop, a one-per-customer promo, or trying to stop resellers from buying out your stock, that gap matters.

Enforcing a real lifetime limit means checking the customer’s past orders, not just what’s in their current cart. Here’s how to do it properly, including guest checkouts, which most tutorials skip entirely since guests don’t have an account to check purchase history against.

The Snippet

This checks past orders by user ID for logged-in customers and by billing email for guests. It validates the order history at two points: when the customer adds the product to the cart and again at checkout. This second check matters because the system does not know a guest’s email until they enter it on the checkout form.

Step 1 – A function to count how much a customer has already bought

/**
 * Count how many units of a product a customer has already purchased,
 * checking by user ID (logged in) or billing email (guest).
 */
define( 'WPCODEX_MAX_PER_CUSTOMER', 1 ); // Change this to allow more than 1

function wpcodex_get_customer_purchased_qty( $product_id, $user_id = 0, $billing_email = '' ) {

    if ( ! $user_id && ! $billing_email ) {
        return 0;
    }

    $args = array(
        'limit'  => -1,
        'status' => array( 'wc-processing', 'wc-completed', 'wc-on-hold' ),
        'return' => 'ids',
    );

    if ( $user_id ) {
        $args['customer_id'] = $user_id;
    } else {
        $args['billing_email'] = $billing_email;
    }

    $order_ids = wc_get_orders( $args );
    $total_qty = 0;

    foreach ( $order_ids as $order_id ) {
        $order = wc_get_order( $order_id );
        if ( ! $order ) {
            continue;
        }

        foreach ( $order->get_items() as $item ) {
            // get_product_id() returns the parent product ID even for variations,
            // so all variations of a product count toward the same limit automatically.
            if ( $item->get_product_id() === $product_id ) {
                $total_qty += $item->get_quantity();
            }
        }
    }

    return $total_qty;
}

Only processing, completed, and on-hold orders are counted, so a cancelled, failed, or refunded order won’t count against the customer – which matters, since otherwise someone who had a payment fail once would be locked out permanently.

Step 2 – Validate when adding to cart

/**
 * Block adding to cart if the customer has already reached their limit.
 */
add_filter( 'woocommerce_add_to_cart_validation', 'wpcodex_limit_one_per_customer_validation', 10, 3 );
function wpcodex_limit_one_per_customer_validation( $passed, $product_id, $quantity ) {

    $limit         = WPCODEX_MAX_PER_CUSTOMER;
    $user_id       = get_current_user_id();
    $billing_email = '';

    if ( ! $user_id && WC()->checkout() ) {
        $billing_email = WC()->checkout()->get_value( 'billing_email' );
    }

    if ( ! $user_id && ! $billing_email ) {
        return $passed; // guest hasn't entered an email yet - the checkout-stage check below will still catch this
    }

    $already_purchased = wpcodex_get_customer_purchased_qty( $product_id, $user_id, $billing_email );

    $already_in_cart = 0;
    foreach ( WC()->cart->get_cart() as $cart_item ) {
        if ( $cart_item['product_id'] === $product_id ) {
            $already_in_cart += $cart_item['quantity'];
        }
    }

    if ( ( $already_purchased + $already_in_cart + $quantity ) > $limit ) {
        wc_add_notice(
            sprintf( 'This product is limited to %d per customer, and our records show you have already reached that limit.', $limit ),
            'error'
        );
        return false;
    }

    return $passed;
}

Step 3 – A second check at checkout (this is the part most tutorials skip)

Add-to-cart validation alone isn’t enough for guests, since WooCommerce doesn’t know their email address until they type it into the checkout form. This second check runs right before the customer places the order and uses the email they just entered.

/**
 * Final validation at checkout, using the billing email just entered.
 * This is what actually catches guest customers trying to re-order.
 */
add_action( 'woocommerce_after_checkout_validation', 'wpcodex_limit_one_per_customer_checkout', 10, 2 );
function wpcodex_limit_one_per_customer_checkout( $data, $errors ) {

    $limit         = WPCODEX_MAX_PER_CUSTOMER;
    $user_id       = get_current_user_id();
    $billing_email = isset( $data['billing_email'] ) ? sanitize_email( $data['billing_email'] ) : '';

    foreach ( WC()->cart->get_cart() as $cart_item ) {
        $product_id = $cart_item['product_id'];
        $quantity   = $cart_item['quantity'];

        $already_purchased = wpcodex_get_customer_purchased_qty( $product_id, $user_id, $billing_email );

        if ( ( $already_purchased + $quantity ) > $limit ) {
            $product = wc_get_product( $product_id );
            $errors->add(
                'validation',
                sprintf( 'Sorry, "%s" is limited to %d per customer, and you have already reached that limit.', $product->get_name(), $limit )
            );
        }
    }
}

Where to Add This Code

  • Your child theme’s functions.php, or
  • A site-specific plugin (recommended – it survives theme updates), or
  • A code snippets plugin like WPCode or Code Snippets

Troubleshooting

A guest customer got through even though they’d ordered before.
This happens if they used a different email address the second time. Matching by billing email is the standard approach for guest checkout since there’s no account to tie orders to, but it can’t catch someone who deliberately uses a different email – that’s a known limitation of email-based matching, not a bug in the snippet.

A logged-in customer’s old order isn’t being counted.
Check the order’s status. Only processing, completed, and on-hold are counted by default. If you use custom order statuses (like the ones covered in our custom stock status post for made-to-order products), you’ll need to add those status slugs to the status array in Step 1 as well.

Variations aren’t rolling up to the parent limit correctly.
Double check you’re passing the parent product ID consistently. get_product_id() on an order item always returns the parent ID for variable products, but if you’re calling the counting function elsewhere with a variation ID instead of the parent ID, the comparison won’t match.

FAQ

Can I set a different limit for different products?
Yes – replace the single WPCODEX_MAX_PER_CUSTOMER constant with a check against product meta, so each product can define its own limit, similar to the per-product settings pattern used in our estimated delivery date snippet.

Does this slow down checkout with a lot of past orders?
For most stores, no – wc_get_orders with a customer_id or billing_email filter is an indexed lookup, not a full table scan. Extremely high order volumes (tens of thousands of orders per customer, which is unusual) could see a small delay, but that’s not typical.

Will this work with HPOS (High-Performance Order Storage)?
Yes, since the snippet uses wc_get_orders() rather than direct database queries or legacy post-meta functions, it works correctly whether HPOS is enabled or the store is still using the legacy post-based order storage.

For more detail on the available arguments for wc_get_orders(), including additional status and date filters you might want to add, the official WooCommerce code reference is the most reliable place to check against your specific version.

Leave a Reply

Your email address will not be published. Required fields are marked *