Skip to content
System nominalUTC --:--:--Articles 080
MODULE 05 Lesson 15 / 18 35 MIN

Payments and shipping

Shipping Methods and Zones

A shipping method prices a package, not an order, and WooCommerce caches the result against a hash of that package. Both facts decide how you write the rate.

A free-shipping method reads the cart, decides the order qualifies, and stores that decision in the session. The customer then adds one more item, pushes the total back over the threshold, and the shipping line still says free. The calculation never ran again, because from WooCommerce's point of view the package had not changed in any way it cares about. Shipping rates are computed per package and cached against a hash, and code that behaves as if it is running per order or per page view breaks in exactly this way.

The method class itself is small: a constructor, two form field arrays, and a calculate_shipping() that ends in add_rate(). What takes longer to get right is the address of your rate in WooCommerce's world: which zone loaded you, which settings array you read from, what the filter you hook actually receives, and how long the number you produced stays valid.

A method is a settings object with one calculation attached

WC_Shipping_Method extends WC_Settings_API for the same reason a payment gateway does: so the class can describe its own admin form. Two settings arrays exist, and they are stored in two different options.

ArrayOption keyHoldsRead with
$form_fields woocommerce_{id}_settings Whether the method is enabled at all. get_option( $key )
$instance_form_fields woocommerce_{id}_{instance_id}_settings The title, cost and thresholds for one zone's instance. get_instance_option( $key )

The split is not cosmetic. A method can be added to three zones with different prices, and each of those is an instance with its own option row. If your rate reads get_option() for the price, all three zones charge the same and the merchant has no way to fix it from the admin. The enabled flag is the one thing that belongs in the method settings, because a disabled method should not appear in any zone.

<?php
class Sifero_Shipping_Weight_Band extends WC_Shipping_Method {

	public function __construct( $instance_id = 0 ) {
		$this->id                 = 'sifero_weight_band';
		$this->instance_id        = absint( $instance_id );
		$this->method_title       = 'Sifero weight bands';
		$this->method_description = 'Price by total cart weight, free above a threshold.';
		$this->supports           = array( 'shipping-zones', 'instance-settings' );
		$this->title              = $this->method_title;

		$this->init_form_fields();
		$this->init_instance_form_fields();
		$this->init_settings();
		$this->init_instance_settings();

		add_action( 'woocommerce_update_options_shipping_' . $this->id, array( $this, 'process_admin_options' ) );
	}

	public function init_instance_form_fields() {
		$this->instance_form_fields = array(
			'title'          => array( 'title' => 'Title', 'type' => 'text', 'default' => 'Weight band' ),
			'free_over'      => array( 'title' => 'Free over weight', 'type' => 'number', 'default' => '10' ),
			'base_rate'      => array( 'title' => 'Base rate', 'type' => 'text', 'default' => '4.50' ),
			'per_item_extra' => array( 'title' => 'Extra per item', 'type' => 'text', 'default' => '1.25' ),
		);
	}
}

Registration is a filter on woocommerce_shipping_methods, which core reads in WC_Shipping::get_shipping_method_class_names() next to its own flat_rate, free_shipping and local_pickup entries. The array key is the method id that appears in rate ids and in the ship_via field of a package.

<?php
add_filter(
	'woocommerce_shipping_methods',
	function ( $methods ) {
		$methods['sifero_weight_band'] = 'Sifero_Shipping_Weight_Band';
		return $methods;
	}
);

On my test store that filter produced flat_rate, free_shipping, local_pickup, sifero_weight_band from get_shipping_method_class_names(), and the instance I added to a zone answered to the rate id sifero_weight_band:1. The :1 is the instance id, and it is why two zones can both offer "Weight band" at different prices without colliding.

calculate_shipping receives a package, and add_rate stores it

The method signature is calculate_shipping( $package = array() ), and the package is the unit of work. A package carries contents (the cart line items, each with its data product object and quantity), contents_cost, destination, applied_coupons, cart_subtotal, and after computation a rates array. WC_Shipping_Method::get_rates_for_package() checks is_available( $package ), then calls calculate_shipping(), so the package you receive has already passed the availability check.

add_rate() takes an array whose useful keys are id, label, cost, taxes, calc_tax, meta_data and package. Passing the package is not required, but it does two things: add_rate() writes an "Items" meta entry listing the product names and quantities in that package, and the package is available to anything hooking woocommerce_shipping_method_add_rate. Omitting the package is how you end up with a rate that cannot explain itself in the order line.

<?php
public function calculate_shipping( $package = array() ) {
	$free_over = (float) $this->get_instance_option( 'free_over' );
	$base      = (float) $this->get_instance_option( 'base_rate' );
	$extra     = (float) $this->get_instance_option( 'per_item_extra' );

	$weight = 0.0;
	$items  = 0;

	foreach ( $package['contents'] as $item ) {
		$weight += (float) $item['data']->get_weight() * (int) $item['quantity'];
		$items  += (int) $item['quantity'];
	}

	$cost = ( $free_over > 0 && $weight > $free_over ) ? 0.0 : $base + max( 0, $items - 1 ) * $extra;

	$this->add_rate(
		array(
			'id'        => $this->get_rate_id(),
			'label'     => $this->get_instance_option( 'title' ),
			'cost'      => $cost,
			'package'   => $package,
			'meta_data' => array(
				'sifero_weight' => $weight,
				'sifero_items'  => $items,
			),
		)
	);
}

With a 2 kg widget at 25.00 and a 30 kg anvil, my store produced these rates, which also show the free-over branch and the per-item increment.

1 widget  (2 kg)  -> sifero_weight_band:1 "Weight band" cost 4.50  meta weight 2,  items 1
2 widgets (4 kg)  -> sifero_weight_band:1 "Weight band" cost 5.75  meta weight 4,  items 2
3 widgets (6 kg)  -> sifero_weight_band:1 "Weight band" cost 7.00  meta weight 6,  items 3
1 anvil   (30 kg) -> sifero_weight_band:1 "Weight band" cost 0.00  meta weight 30, items 1

Two details in the meta output are worth noticing. Every rate carries an Items entry because the package was passed, and the numeric meta arrives as strings, because everything crossing a rate's meta_data is stored the way order meta is. Compare with (string) rather than === against an integer when you read it back.

Zones decide which instances exist, and zone 0 is the fallback

When a package has a destination, WC_Shipping::load_shipping_methods() calls WC_Shipping_Zones::get_zone_matching_package( $package ) and loads the methods of the matched zone. The matching is on country, state and postcode, and the zones themselves are ordered: the first zone whose locations match wins. A zone with no locations at all, or zone 0, is the catch-all. Its name in the admin is "Locations not covered by your other zones", and it is a real zone you can add methods to.

My store had one zone for the United States with the weight-band instance in it. The same lookup for a package destined for the UK returned zone 0, and its method list was empty, so no rates were offered at all rather than an error. That is the behaviour to test for, because a store that only ships domestically shows "no shipping options" to everyone else, and the fix is a flat rate in zone 0.

destination US -> zone #1 "United States"   methods: sifero_weight_band:1
destination GB -> zone #0 "Locations not covered by your other zones" methods: (none)

Two things follow from that. A method that supports shipping-zones is only ever loaded for the matched zone's instances, so your calculate_shipping() never has to check the destination country itself. And is_available( $package ) is still the place to refuse a package, for example one whose contents the method cannot price, because get_rates_for_package() asks before it calculates.

woocommerce_package_rates runs once per package hash, not once per request

The filter woocommerce_package_rates receives the finished $package['rates'] array and the package, and returns the array. It is where you add a handling fee, drop a rate for a customer group, or re-label something. What matters is when it runs: inside calculate_shipping_for_package(), and only when the package hash differs from the hash stored in the session for that package key.

<?php
// Inside WC_Shipping::calculate_shipping_for_package(), simplified.
$wc_session_key = 'shipping_for_package_' . $package_key;
$stored_rates   = WC()->session->get( $wc_session_key );
$package_hash   = $this->get_package_hash( $package );

if ( ! is_array( $stored_rates )
	|| $package_hash !== $stored_rates['package_hash']
	|| 'yes' === get_option( 'woocommerce_shipping_debug_mode', 'no' ) ) {
		// ... run the methods, then:
		$package['rates'] = apply_filters( 'woocommerce_package_rates', $package['rates'], $package );
		WC()->session->set( $wc_session_key, array( 'package_hash' => $package_hash, 'rates' => $package['rates'] ) );
} else {
	$package['rates'] = $stored_rates['rates'];
}

The hash is a digest of the package with the volatile fields removed (subtotal, total, package_id, package_name, rates, package_index) plus a cache version. Change the contents and the hash changes; page through the cart twice without changing anything and it does not.

I watched that on a real store by logging a line from the filter. Loading the cart page twice in the same session, with the cart untouched, produced exactly one filter line. Adding a second product produced a second line. In request terms: the filter does not run on the second page view at all, and the rates come out of the session.

That is a performance feature with a trap attached. Anything your filter reads from outside the package, such as a plugin option holding a handling fee, is frozen into the customer's session for as long as the package hash stays the same. Changing the option does not invalidate the cached rates, because the cache version only moves when WooCommerce's own shipping settings change. I reproduced it with a 3.00 handling fee:

request 1, handling fee 0.00 -> weight band 4.50, meta sifero_handling_fee=0, filter ran
owner sets the fee to 3.00
request 2, same cart        -> weight band 4.50, meta sifero_handling_fee=0, filter did not run
request 3, cart changed     -> weight band 3.00, meta sifero_handling_fee=3, filter ran

The third line is not a bargain. The 30 kg anvil triggered the method's own free branch, and only then did the fresh filter run and add the fee. The customer in line two is looking at a stale rate, and if they check out in that session they pay it, because the chosen rate id still resolves in the package's rate array and the checkout line is built from the calculated rate rather than recalculated at the moment of payment.

The fix is to make the rate invalidate itself. When your own setting changes, bump the value behind the package hash: WC_Cache_Helper::get_transient_version( 'shipping', true ) increments the shipping cache version, which changes every package hash on the next request and forces a recalculation. Call it from the settings save handler of whatever screen holds the fee. Two other habits belong in the same list: do the work in the method's calculate_shipping() rather than in the filter whenever the value depends only on the package, and treat the filter as the place for rules that genuinely apply to the finished rate.

Rates are per package, which is why split shipping works at all

A cart produces one package by default, but woocommerce_cart_shipping_packages can return several: a marketplace with per-vendor packaging, a store that ships heavy items separately, an extension that splits by shipping class. Each package is hashed, cached, calculated and filtered on its own, and each gets its own rates and its own chosen method in the session.

Two consequences follow. Your calculate_shipping() sees only the items in the package it was given, so pricing a package on the whole cart total is wrong the moment a cart splits. And a filter that adds a flat handling fee per package charges it per package, which is either correct or a double charge depending on what the fee means. Deciding that up front saves a support thread later.

Shipping-cost validation happens on rate presence

WC_Checkout::validate_checkout() errors with "No shipping method has been selected" when the session's chosen method for a package is not present in that package's rates. It does not compare a stored cost with a recomputed one. That is why a stale rate can be submitted, and why clearing the session's shipping_for_package_N key is a blunt but effective fix when rates change underneath a customer.

Lab 45 minutes

Write a method whose rate depends on the package, then prove how long that rate lives.

  1. Register a shipping method with a method-level enabled field and instance fields for title, a free threshold and two prices.
  2. Implement calculate_shipping() so the cost comes from the package's contents, and pass the package to add_rate().
  3. Add the method to a zone for one country and print get_zone_matching_package() for that country and for another one. Print both zones' method lists.
  4. Print the rate id, label, cost and meta for one item, then for two of the same item, then for an item above your free threshold.
  5. Log a line from a woocommerce_package_rates callback and load the cart page twice without changing the cart, then once after adding an item. Print the three log lines and the rate cost each time.
  6. Make the filter read an option instead of the package, change the option, and load the cart twice more. Print the cost the customer sees each time.

Then answer in one sentence each: why the free-shipping branch can be wrong after a cart change, and what invalidates the cached rate.

Show the solution and the reasoning

The method in the lesson is the one I ran, so the solution here is the evidence rather than the code. The five prints, in order, were the method list, the zone match, the four rates, the filter log, and the stale-fee sequence.

registered ids: flat_rate, free_shipping, local_pickup, sifero_weight_band
US -> zone #1 "United States" methods: 1
GB -> zone #0 "Locations not covered by your other zones" methods:

1 widget  (2 kg)  -> sifero_weight_band:1 cost 4.50   meta {"sifero_weight":"2","sifero_items":"1"}
2 widgets (4 kg)  -> sifero_weight_band:1 cost 5.75   meta {"sifero_weight":"4","sifero_items":"2"}
1 anvil   (30 kg) -> sifero_weight_band:1 cost 0.00   meta {"sifero_weight":"30","sifero_items":"1"}

filter log, cart untouched:  20:21:17 filter ran
filter log, cart untouched:  (nothing)
filter log, item added:      20:21:18 filter ran

The free-shipping branch is the trap in its mildest form. The branch reads the package, so a cart change alters the hash and the branch re-evaluates; the danger is code that reaches for WC()->cart inside the method instead of the package it was handed. That version reads the cart as it is at calculation time, which is usually the same thing and occasionally is not: it is not the same thing during a REST request that recalculates rates for a package built a moment earlier, and it is not the same thing in a multi-package cart, where the whole-cart total decides a per-package price.

What invalidates the cache is WC_Cache_Helper::get_transient_version( 'shipping', true ), because it is folded into every package hash. Bumping it on any setting that your rate depends on is the one line that keeps the session honest. The alternative, clearing shipping_for_package_* from the session, works for the current visitor and does nothing for the thousands of other sessions holding the same stale number.

What none of this does is reprice an order that was already placed. Shipping lines are written onto the order at checkout from the rate the customer selected, so a rate that was stale at checkout stays stale on the order forever. If that matters, the checkbox worth considering is recalculating the shipping line when the order moves into processing and adding a note when the number changed.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.