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.
| Array | Option key | Holds | Read 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.
Write a method whose rate depends on the package, then prove how long that rate lives.
- Register a shipping method with a method-level
enabledfield and instance fields for title, a free threshold and two prices. - Implement
calculate_shipping()so the cost comes from the package's contents, and pass the package toadd_rate(). - 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. - 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.
- Log a line from a
woocommerce_package_ratescallback 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. - 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.