wc-shipping-method
Build a zone-based WooCommerce shipping method with `WC_Shipping_Method`. Covers deferred class loading, registration, instance settings and modal support flags, package-based calculation, unique rate IDs, decimal/tax handling, availability, save behavior, caching, WooCommerce 11
Install
npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/woocommerce/wc-shipping-method
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart
git clone https://github.com/Lonsdale201/wp-agent-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lonsdale201/wp-agent-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
WooCommerce shipping method
WooCommerce calculates shipping per package. A method receives one package and adds zero or more rates for that package.
Load and register
With Composer PSR-4 autoloading, defer registration until WooCommerce has loaded its shipping base class:
add_action( 'woocommerce_shipping_init', static function (): void {
add_filter( 'woocommerce_shipping_methods', static function ( array $methods ): array {
$methods['myplugin_carrier'] = MyPlugin\Shipping\CarrierMethod::class;
return $methods;
} );
} );
The array key must match the class $id. Register a class name; WooCommerce creates one object per zone-method instance.
Zone method scaffold
namespace MyPlugin\Shipping;
use Automattic\WooCommerce\Utilities\NumberUtil;
final class CarrierMethod extends \WC_Shipping_Method {
protected $cost = '0';
public function __construct( $instance_id = 0 ) {
$this->id = 'myplugin_carrier';
$this->instance_id = absint( $instance_id );
$this->method_title = __( 'My Carrier', 'myplugin' );
$this->method_description = __( 'Calculated delivery by My Carrier.', 'myplugin' );
$this->supports = array(
'shipping-zones',
'instance-settings',
'instance-settings-modal',
);
$this->init_form_fields();
// get_option() reads instance settings lazily for declared instance fields.
$this->title = $this->get_option( 'title', __( 'My Carrier', 'myplugin' ) );
$this->tax_status = $this->get_option( 'tax_status', 'taxable' );
$this->cost = $this->get_option( 'cost', '0' );
}
public function init_form_fields(): void {
$this->instance_form_fields = array(
'title' => array(
'title' => __( 'Name', 'myplugin' ),
'type' => 'text',
'default' => __( 'My Carrier', 'myplugin' ),
'description' => __( 'Shown to customers at checkout.', 'myplugin' ),
'desc_tip' => true,
),
'cost' => array(
'title' => __( 'Cost', 'myplugin' ),
'type' => 'text',
'default' => '0',
'sanitize_callback' => static function ( $value ): string {
return NumberUtil::sanitize_cost_in_current_locale( $value );
},
),
'tax_status' => array(
'title' => __( 'Tax status', 'myplugin' ),
'type' => 'select',
'default' => 'taxable',
'options' => array(
'taxable' => __( 'Taxable', 'myplugin' ),
'none' => _x( 'None', 'Tax status', 'myplugin' ),
),
),
);
}
public function calculate_shipping( $package = array() ): void {
if ( empty( $package['destination']['country'] ) ) {
return;
}
$cost = wc_format_decimal( $this->cost );
if ( '' === $cost || (float) $cost < 0 ) {
return;
}
$this->add_rate( array(
'id' => $this->get_rate_id( 'standard' ),
'label' => $this->title,
'cost' => $cost,
'package' => $package,
) );
}
}
Declare extension-owned properties instead of relying on dynamic properties.
Settings model
instance_form_fields fully controls the per-zone modal. WooCommerce does not inject a default title/cost field into custom methods.
| Support flag | Effect |
|---|---|
shipping-zones |
Method can be added to zones |
instance-settings |
Method has per-instance settings |
instance-settings-modal |
Use the current Backbone modal UI |
settings |
Legacy/global non-instance settings page |
For no modal, use only:
$this->supports = array( 'shipping-zones' );
Do not hide unwanted fields with CSS/JavaScript or declare then unset them. Declare only the fields owned by the method.
get_instance_option() lazily calls init_instance_settings(). The base settings arrays already default to arrays; a method with no fields does not need init_settings() merely to avoid null notices.
Saving settings
The shipping-zone AJAX flow calls the selected instance's process_admin_options() directly. This validates the instance_id, reads only declared fields, applies sanitizers, and updates the instance option.
The classic action wiring:
add_action(
'woocommerce_update_options_shipping_' . $this->id,
array( $this, 'process_admin_options' )
);
is required for global/legacy settings. Built-in methods may register it for mixed compatibility, but a zone-only method does not depend on this action for modal saves.
Package contract
Use the supplied package, not global cart assumptions:
contents cart lines in this package
contents_cost package contents value
applied_coupons active coupon codes
user shopper data
destination country/state/postcode/city/address
cart_subtotal package/cart subtotal context
A cart can be split into several packages. Calling WC()->cart->get_cart() inside calculation can price items that are not in the current package.
External carrier calls must have short timeouts and deterministic fallbacks. Cache by a bounded hash of normalized destination, package dimensions/weight/value, service settings, and currency. Never include full addresses or customer PII in logs/cache keys.
Rate IDs and multiple services
get_rate_id() returns method and instance components. If one method emits multiple rates, pass a stable service suffix:
$this->get_rate_id( 'standard' );
$this->get_rate_id( 'express' );
Without suffixes, later rates can collide. Never use a translated label as an ID.
Costs and taxes
- Store costs as sanitized decimal strings; do not use localized raw input in calculations.
- Leave
taxesunset inadd_rate()to let WooCommerce calculate shipping taxes from cost and tax status. - Pass
taxes => falseonly for an intentionally non-taxable rate. - Do not manually add tax to cost unless the contract explicitly supplies tax-inclusive rates and you correctly handle
woocommerce_shipping_prices_include_tax. - Avoid negative shipping rates; use discounts/coupons for discounts.
Availability
Return no rates when requirements are not met. If overriding is_available(), retain parent/zone enablement behavior and evaluate only package-relevant rules. Sanitize destination data before sending it to a carrier.
Shipping-class taxonomy visibility in WooCommerce 11.0
WooCommerce 11.0 registers product_shipping_class with public => false, rewrite => false, and no frontend query variable. Product/variation assignment, WC_Product::get_shipping_class_id(), rate calculations, and admin management continue to work; the change removes shipping-class public archives and public taxonomy discovery.
Use product CRUD and term APIs for shipping logic. Do not build customer-facing URLs, sitemap entries, or frontend queries that depend on product_shipping_class being public. If a site-specific integration deliberately needs the old visibility, it can alter registration through woocommerce_taxonomy_args_product_shipping_class or WordPress's register_product_shipping_class_taxonomy_args, but test rewrite/query exposure and information disclosure explicitly. For a new public classification feature, prefer a separate extension-owned taxonomy rather than repurposing shipping configuration as storefront content.
Testing
Test:
- Add/remove method in several zones and save separate instance settings.
- Guest and logged-in addresses, incomplete postcode, no-shipping destinations.
- Multiple packages and multiple services from one instance.
- Taxable/non-taxable stores, decimal separators, zero cost, coupons.
- Carrier timeout/error, cache hit/miss, duplicate recalculation in one request.
- Classic and Store API/Checkout Block rate display.
Critical rules
- Register/load after
woocommerce_shipping_init. - Price the supplied package only.
- Give every emitted service a stable unique rate suffix.
- Sanitize settings and decimal values at the boundary.
- Do not perform unbounded carrier calls on every recalculation.
- Do not rely on a settings action for per-zone AJAX persistence.
- Do not use
product_shipping_classas a public archive or sitemap taxonomy in WooCommerce 11.0.
References
- Base settings/rate contract:
includes/abstracts/abstract-wc-shipping-method.php. - Zone method examples:
includes/shipping/free-shippingandincludes/shipping/flat-rate. - Official documentation: https://woocommerce.com/document/shipping-method-api/
- Verified source paths:
wp-content/plugins/woocommerce/includes/abstracts/abstract-wc-settings-api.phpwp-content/plugins/woocommerce/includes/class-wc-shipping.phpwp-content/plugins/woocommerce/includes/shipping/free-shipping/class-wc-shipping-free-shipping.phpwp-content/plugins/woocommerce/includes/shipping/flat-rate/class-wc-shipping-flat-rate.php
Files (wp-agent-skills)
-
SKILL.md 9.9 KB
--- name: wc-shipping-method description: Build a zone-based WooCommerce shipping method with `WC_Shipping_Method`. Covers deferred class loading, registration, instance settings and modal support flags, package-based calculation, unique rate IDs, decimal/tax handling, availability, save behavior, caching, WooCommerce 11's private `product_shipping_class` taxonomy boundary, and testing. Use when adding carrier rates, shipping-class rules, custom shipping logic, a feature-only settings modal, or a method with no per-zone settings UI. metadata: wp-skills-author: "Soczó Kristóf" wp-skills-contact: "mailto:lonsdale201@hotmail.com" wp-skills-plugin: "woocommerce" wp-skills-plugin-version-tested: "11.0.0" wp-skills-php-min: "7.4" wp-skills-last-updated: "2026-08-05" --- # WooCommerce shipping method WooCommerce calculates shipping per package. A method receives one package and adds zero or more rates for that package. ## Load and register With Composer PSR-4 autoloading, defer registration until WooCommerce has loaded its shipping base class: ```php add_action( 'woocommerce_shipping_init', static function (): void { add_filter( 'woocommerce_shipping_methods', static function ( array $methods ): array { $methods['myplugin_carrier'] = MyPlugin\Shipping\CarrierMethod::class; return $methods; } ); } ); ``` The array key must match the class `$id`. Register a class name; WooCommerce creates one object per zone-method instance. ## Zone method scaffold ```php namespace MyPlugin\Shipping; use Automattic\WooCommerce\Utilities\NumberUtil; final class CarrierMethod extends \WC_Shipping_Method { protected $cost = '0'; public function __construct( $instance_id = 0 ) { $this->id = 'myplugin_carrier'; $this->instance_id = absint( $instance_id ); $this->method_title = __( 'My Carrier', 'myplugin' ); $this->method_description = __( 'Calculated delivery by My Carrier.', 'myplugin' ); $this->supports = array( 'shipping-zones', 'instance-settings', 'instance-settings-modal', ); $this->init_form_fields(); // get_option() reads instance settings lazily for declared instance fields. $this->title = $this->get_option( 'title', __( 'My Carrier', 'myplugin' ) ); $this->tax_status = $this->get_option( 'tax_status', 'taxable' ); $this->cost = $this->get_option( 'cost', '0' ); } public function init_form_fields(): void { $this->instance_form_fields = array( 'title' => array( 'title' => __( 'Name', 'myplugin' ), 'type' => 'text', 'default' => __( 'My Carrier', 'myplugin' ), 'description' => __( 'Shown to customers at checkout.', 'myplugin' ), 'desc_tip' => true, ), 'cost' => array( 'title' => __( 'Cost', 'myplugin' ), 'type' => 'text', 'default' => '0', 'sanitize_callback' => static function ( $value ): string { return NumberUtil::sanitize_cost_in_current_locale( $value ); }, ), 'tax_status' => array( 'title' => __( 'Tax status', 'myplugin' ), 'type' => 'select', 'default' => 'taxable', 'options' => array( 'taxable' => __( 'Taxable', 'myplugin' ), 'none' => _x( 'None', 'Tax status', 'myplugin' ), ), ), ); } public function calculate_shipping( $package = array() ): void { if ( empty( $package['destination']['country'] ) ) { return; } $cost = wc_format_decimal( $this->cost ); if ( '' === $cost || (float) $cost < 0 ) { return; } $this->add_rate( array( 'id' => $this->get_rate_id( 'standard' ), 'label' => $this->title, 'cost' => $cost, 'package' => $package, ) ); } } ``` Declare extension-owned properties instead of relying on dynamic properties. ## Settings model `instance_form_fields` fully controls the per-zone modal. WooCommerce does not inject a default title/cost field into custom methods. | Support flag | Effect | |---|---| | `shipping-zones` | Method can be added to zones | | `instance-settings` | Method has per-instance settings | | `instance-settings-modal` | Use the current Backbone modal UI | | `settings` | Legacy/global non-instance settings page | For no modal, use only: ```php $this->supports = array( 'shipping-zones' ); ``` Do not hide unwanted fields with CSS/JavaScript or declare then unset them. Declare only the fields owned by the method. `get_instance_option()` lazily calls `init_instance_settings()`. The base settings arrays already default to arrays; a method with no fields does not need `init_settings()` merely to avoid null notices. ## Saving settings The shipping-zone AJAX flow calls the selected instance's `process_admin_options()` directly. This validates the `instance_id`, reads only declared fields, applies sanitizers, and updates the instance option. The classic action wiring: ```php add_action( 'woocommerce_update_options_shipping_' . $this->id, array( $this, 'process_admin_options' ) ); ``` is required for global/legacy settings. Built-in methods may register it for mixed compatibility, but a zone-only method does not depend on this action for modal saves. ## Package contract Use the supplied package, not global cart assumptions: ```text contents cart lines in this package contents_cost package contents value applied_coupons active coupon codes user shopper data destination country/state/postcode/city/address cart_subtotal package/cart subtotal context ``` A cart can be split into several packages. Calling `WC()->cart->get_cart()` inside calculation can price items that are not in the current package. External carrier calls must have short timeouts and deterministic fallbacks. Cache by a bounded hash of normalized destination, package dimensions/weight/value, service settings, and currency. Never include full addresses or customer PII in logs/cache keys. ## Rate IDs and multiple services `get_rate_id()` returns method and instance components. If one method emits multiple rates, pass a stable service suffix: ```php $this->get_rate_id( 'standard' ); $this->get_rate_id( 'express' ); ``` Without suffixes, later rates can collide. Never use a translated label as an ID. ## Costs and taxes - Store costs as sanitized decimal strings; do not use localized raw input in calculations. - Leave `taxes` unset in `add_rate()` to let WooCommerce calculate shipping taxes from cost and tax status. - Pass `taxes => false` only for an intentionally non-taxable rate. - Do not manually add tax to cost unless the contract explicitly supplies tax-inclusive rates and you correctly handle `woocommerce_shipping_prices_include_tax`. - Avoid negative shipping rates; use discounts/coupons for discounts. ## Availability Return no rates when requirements are not met. If overriding `is_available()`, retain parent/zone enablement behavior and evaluate only package-relevant rules. Sanitize destination data before sending it to a carrier. ## Shipping-class taxonomy visibility in WooCommerce 11.0 WooCommerce 11.0 registers `product_shipping_class` with `public => false`, `rewrite => false`, and no frontend query variable. Product/variation assignment, `WC_Product::get_shipping_class_id()`, rate calculations, and admin management continue to work; the change removes shipping-class public archives and public taxonomy discovery. Use product CRUD and term APIs for shipping logic. Do not build customer-facing URLs, sitemap entries, or frontend queries that depend on `product_shipping_class` being public. If a site-specific integration deliberately needs the old visibility, it can alter registration through `woocommerce_taxonomy_args_product_shipping_class` or WordPress's `register_product_shipping_class_taxonomy_args`, but test rewrite/query exposure and information disclosure explicitly. For a new public classification feature, prefer a separate extension-owned taxonomy rather than repurposing shipping configuration as storefront content. ## Testing Test: 1. Add/remove method in several zones and save separate instance settings. 2. Guest and logged-in addresses, incomplete postcode, no-shipping destinations. 3. Multiple packages and multiple services from one instance. 4. Taxable/non-taxable stores, decimal separators, zero cost, coupons. 5. Carrier timeout/error, cache hit/miss, duplicate recalculation in one request. 6. Classic and Store API/Checkout Block rate display. ## Critical rules - Register/load after `woocommerce_shipping_init`. - Price the supplied package only. - Give every emitted service a stable unique rate suffix. - Sanitize settings and decimal values at the boundary. - Do not perform unbounded carrier calls on every recalculation. - Do not rely on a settings action for per-zone AJAX persistence. - Do not use `product_shipping_class` as a public archive or sitemap taxonomy in WooCommerce 11.0. ## References - Base settings/rate contract: `includes/abstracts/abstract-wc-shipping-method.php`. - Zone method examples: `includes/shipping/free-shipping` and `includes/shipping/flat-rate`. - Official documentation: <https://woocommerce.com/document/shipping-method-api/> - Verified source paths: - `wp-content/plugins/woocommerce/includes/abstracts/abstract-wc-settings-api.php` - `wp-content/plugins/woocommerce/includes/class-wc-shipping.php` - `wp-content/plugins/woocommerce/includes/shipping/free-shipping/class-wc-shipping-free-shipping.php` - `wp-content/plugins/woocommerce/includes/shipping/flat-rate/class-wc-shipping-flat-rate.php`
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.