Tutorials··8 min read

How to Restrict Shipping Methods by User Role in WooCommerce

A retail customer wants to pick their shipping option. A wholesale customer doesn't — their contract says freight only. A dealer gets free pickup. A retail customer paying over $100 gets free shipping; a wholesale customer never does, because they already get a volume discount.

WooCommerce has no UI for this. It does have a filter hook, which is what we're going to use.


The filter: woocommerce_package_rates

This is the canonical hook for modifying available shipping methods at checkout. It runs after WooCommerce has computed all the methods available for the current cart, and before they're shown to the customer.

add_filter( 'woocommerce_package_rates', function ( $rates, $package ) {
    // $rates is an array of rate_id => WC_Shipping_Rate
    // $package contains the cart contents and destination
    return $rates; // return unchanged, or modified
}, 10, 2 );

Everything else we do in this guide is variants on filtering this array.


Pattern 1: Hide a method from specific roles

"Don't show USPS Ground to wholesale customers."

add_filter( 'woocommerce_package_rates', function ( $rates, $package ) {
    $user = wp_get_current_user();
    $is_wholesale = in_array( 'wholesale_customer', (array) $user->roles, true );

    if ( $is_wholesale ) {
        foreach ( $rates as $rate_id => $rate ) {
            if ( str_starts_with( $rate_id, 'flat_rate:' ) && $rate->label === 'USPS Ground' ) {
                unset( $rates[ $rate_id ] );
            }
        }
    }
    return $rates;
}, 10, 2 );

Note the rate matching: flat_rate:3 is instance-based. The number after the colon is the shipping method instance ID, which is per-zone. If you've configured USPS Ground in two zones, you'll have two different IDs for it. Match on label or instance ID depending on what you're trying to do.

Getting the instance IDs

Easiest way: go to WooCommerce → Settings → Shipping, pick a zone, and hover over the method's edit link. The URL has instance_id=X. Use those.

Or dump them at runtime:

add_filter( 'woocommerce_package_rates', function ( $rates, $package ) {
    error_log( print_r( array_keys( $rates ), true ) );
    return $rates;
}, 10, 2 );

Pattern 2: Force only one method for a role

"Wholesale customers get freight. That's the only option."

add_filter( 'woocommerce_package_rates', function ( $rates, $package ) {
    $user = wp_get_current_user();
    if ( ! in_array( 'wholesale_customer', (array) $user->roles, true ) ) {
        return $rates;
    }
    // Keep only the freight method (instance id 7 in our zone)
    return array_filter( $rates, fn( $rate ) => $rate->id === 'flat_rate:7' );
}, 10, 2 );

This removes choice entirely — wholesale sees one option, no dropdown.


Pattern 3: Show a method only to specific roles

"Free Pickup is for dealers only."

add_filter( 'woocommerce_package_rates', function ( $rates, $package ) {
    $user = wp_get_current_user();
    $is_dealer = in_array( 'dealer', (array) $user->roles, true );

    if ( ! $is_dealer ) {
        foreach ( $rates as $rate_id => $rate ) {
            if ( $rate->label === 'Free Pickup' ) {
                unset( $rates[ $rate_id ] );
            }
        }
    }
    return $rates;
}, 10, 2 );

Mirror image of Pattern 1 — remove for everyone except the role that's allowed.


Pattern 4: Different methods per role tier

This is where code starts to get unwieldy. Multiple roles, each with a different allowed set:

add_filter( 'woocommerce_package_rates', function ( $rates, $package ) {
    $user = wp_get_current_user();
    $roles = (array) $user->roles;

    $allowed = [ 'flat_rate:1', 'flat_rate:2' ]; // default (retail)

    if ( in_array( 'wholesale_customer', $roles, true ) ) {
        $allowed = [ 'flat_rate:7' ]; // freight
    } elseif ( in_array( 'dealer', $roles, true ) ) {
        $allowed = [ 'flat_rate:7', 'local_pickup:5' ]; // freight or pickup
    } elseif ( in_array( 'vip_customer', $roles, true ) ) {
        $allowed = [ 'flat_rate:1', 'flat_rate:2', 'free_shipping:9' ];
    }

    return array_filter( $rates, fn( $rate ) => in_array( $rate->id, $allowed, true ) );
}, 10, 2 );

At this point, every change to your shipping rules means a code deploy. If your e-commerce manager needs to change which role sees which method, that's a pull request and a production release. That's where plugins start paying for themselves.


Edge cases most tutorials miss

Users with multiple roles

A user can have multiple WordPress roles. If someone is both customer and wholesale_customer, which rule wins?

Your in_array checks above will match on the first role found. Define precedence explicitly:

$roles = (array) wp_get_current_user()->roles;

// Precedence: wholesale > dealer > vip > customer
foreach ( [ 'wholesale_customer', 'dealer', 'vip_customer', 'customer' ] as $priority_role ) {
    if ( in_array( $priority_role, $roles, true ) ) {
        $effective_role = $priority_role;
        break;
    }
}

Guest users

wp_get_current_user() returns a user with no roles for logged-out visitors. Any role check returns false. If you want a specific rule for guests (e.g., "guests can only use Free Shipping over $50"), handle them explicitly:

$user = wp_get_current_user();
if ( 0 === $user->ID ) {
    // Guest
    $allowed = [ 'free_shipping:9' ];
}

Cart & Checkout Blocks

The woocommerce_package_rates filter runs for both legacy (shortcode) and Blocks checkout. Your filtering works the same. But: Blocks may aggressively cache the shipping rates on the client. If your logic depends on a cart item (e.g., "hide Ground if there's a Hazmat product") the client cache can show stale options. Bust the cache with:

add_filter( 'woocommerce_store_api_disable_nonce_check', '__return_false' ); // don't do this in prod, just for debugging

The safer fix is to make sure your rate filtering is deterministic given the same cart + user. If it is, caching is fine.

Admin impersonating a customer

If you're using a "login as customer" plugin (like our Shop As Customer), the admin sees the impersonated user's role for shipping purposes, which is what you want. If you're rolling your own impersonation, make sure wp_get_current_user() returns the target user, not the admin.

Shipping zones

Rules above apply globally unless you scope them. If you want "wholesale customers in the US get freight, wholesale customers in the EU get DHL Express":

$destination_country = $package['destination']['country'];

if ( in_array( 'wholesale_customer', (array) wp_get_current_user()->roles, true ) ) {
    if ( $destination_country === 'US' ) {
        $allowed = [ 'flat_rate:7' ]; // US freight
    } elseif ( $destination_country === 'DE' ) {
        $allowed = [ 'flat_rate:14' ]; // DHL Express DE
    }
}

Every country and every role multiplies the complexity.


When to reach for a plugin

If you can answer yes to more than one of these, you're in plugin territory:

  • More than 3 roles with different shipping rules
  • Rules that change depending on cart contents, not just user role
  • E-commerce manager (non-developer) needs to modify rules
  • Store spans multiple zones with per-zone role rules
  • You want an audit log of who changed what rule when

Role Based Methods handles all of this with a single admin table — no code, no zone-by-zone config files. It also handles the payment-methods side of the same problem (see How to Hide Payment Methods from Specific Customers).


Testing your rules

Before shipping (pun intended), test:

  • Log in as each role, go to checkout with a cart that should trigger each rule. Confirm you see exactly the expected methods.
  • Log out, repeat for guests.
  • Test with multiple shipping zones if you have them — add items shipping to different destinations, confirm each address gets the right methods.
  • If using Blocks checkout: hard refresh to beat client-side caching and confirm.

The short version

  • woocommerce_package_rates is the filter you want
  • Match on instance ID (flat_rate:7) or label, depending on how specific you need
  • Handle multiple-roles, guests, and zones explicitly — none are automatic
  • If your logic has more than 3 branches, you're writing a plugin; just use one