WordPress Shipping Rate Calculation in Background Jobs — The Missing Session Problem
· 12 min read
I was building a custom order automation for a WooCommerce store that needed to create orders programmatically via Action Scheduler. The orders required accurate shipping rates based on the customer's delivery address — the same rates a customer would see at checkout. Simple enough in theory. In practice, WooCommerce's shipping calculation is deeply entangled with the user session, and Action Scheduler runs in a context where no session exists.
The error that started the investigation was straightforward: a fatal error on wc_get_chosen_shipping_method_ids() because WC()->session returned null. But fixing it properly required understanding how WooCommerce internally resolves shipping rates — and building the pieces it expects to find.
Why WC()->session Doesn't Exist in Background Jobs
WooCommerce initialises its session handler during the wp_loaded action, but only for front-end and AJAX requests. Action Scheduler jobs run through WP-CLI or an admin-initiated cron context where the session handler is deliberately skipped. There's no customer browsing the site, so there's no session to initialise.
This means anything that relies on session data — cart contents, chosen shipping method, applied coupons, customer location — is unavailable. Functions like WC()->cart->calculate_shipping() and wc_get_chosen_shipping_method_ids() either fail outright or return empty results.
You can't just initialise a session manually either. Calling WC()->initialize_session() in a CLI context triggers its own cascade of issues with cookie handling. The cleaner approach is to bypass the session entirely and calculate shipping rates directly from the shipping zone system.
Building a Mock Customer for Zone Matching
WooCommerce determines which shipping methods are available by matching the customer's address against shipping zones. This matching uses the customer object to get the delivery address. In a background job, there's no customer object loaded, so I built a minimal mock:
class Mock_WC_Customer extends WC_Customer {
private string $mock_country;
private string $mock_state;
private string $mock_postcode;
private string $mock_city;
public function __construct(
string $country,
string $state = '',
string $postcode = '',
string $city = ''
) {
// Skip parent constructor to avoid session dependency
$this->mock_country = $country;
$this->mock_state = $state;
$this->mock_postcode = $postcode;
$this->mock_city = $city;
}
public function get_shipping_country( $context = 'view' ): string {
return $this->mock_country;
}
public function get_shipping_state( $context = 'view' ): string {
return $this->mock_state;
}
public function get_shipping_postcode( $context = 'view' ): string {
return $this->mock_postcode;
}
public function get_shipping_city( $context = 'view' ): string {
return $this->mock_city;
}
public function get_taxable_address(): array {
return [
$this->mock_country,
$this->mock_state,
$this->mock_postcode,
$this->mock_city,
];
}
}
The key override is get_taxable_address(). Several shipping plugins — including Flexible Shipping — call this method internally when evaluating rate conditions. If it's not returning the correct address, you get rates for the wrong zone or no rates at all.
Calculating Shipping Rates Without a Cart
With the mock customer in place, I built the shipping package manually and ran it through WooCommerce's zone matching:
function calculate_shipping_for_order(
array $items,
string $country,
string $state = '',
string $postcode = '',
string $city = ''
): array {
// Build a shipping package matching WooCommerce's expected structure
$package = [
'contents' => [],
'contents_cost' => 0,
'applied_coupons' => [],
'user' => [ 'ID' => 0 ],
'destination' => [
'country' => $country,
'state' => $state,
'postcode' => $postcode,
'city' => $city,
'address' => '',
'address_2' => '',
],
];
// Populate package contents from order items
foreach ( $items as $item ) {
$product = wc_get_product( $item['product_id'] );
if ( ! $product || ! $product->needs_shipping() ) {
continue;
}
$package['contents'][] = [
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'data' => $product,
'line_total' => $product->get_price() * $item['quantity'],
];
$package['contents_cost'] += $product->get_price() * $item['quantity'];
}
if ( empty( $package['contents'] ) ) {
return [];
}
// Temporarily set the mock customer so plugins can access it
$original_customer = WC()->customer;
WC()->customer = new Mock_WC_Customer(
$country, $state, $postcode, $city
);
// Find the matching shipping zone
$zone = WC_Shipping_Zones::get_zone_matching_package( $package );
$methods = $zone->get_shipping_methods( true ); // true = enabled only
$rates = [];
foreach ( $methods as $method ) {
// Calculate rates for this method
$method->calculate_shipping( $package );
foreach ( $method->rates as $rate ) {
$rates[] = [
'method_id' => $rate->get_method_id(),
'label' => $rate->get_label(),
'cost' => (float) $rate->get_cost(),
'taxes' => $rate->get_taxes(),
];
}
}
// Restore original customer
WC()->customer = $original_customer;
return $rates;
}
The critical detail is temporarily replacing WC()->customer with the mock. Some shipping plugins — Flexible Shipping being a notable one — don't just use the package destination array. They reach back into WC()->customer to get the address, particularly for conditional logic like "free shipping over a certain amount for UK postcodes." Without the mock customer in place, those conditions evaluate against empty data and the method either returns no rates or incorrect ones.
Flexible Shipping Compatibility
Flexible Shipping deserves a specific mention because it has its own internal rate calculation that goes beyond what standard WooCommerce shipping methods do. It evaluates rules based on cart weight, item count, and cart total — all of which it reads from the package contents.
The package structure above works correctly with Flexible Shipping as long as each item in contents includes the data key pointing to a valid WC_Product object and the line_total is set. Flexible Shipping uses these to calculate weight-based and value-based rules.
I verified this by comparing rates returned by the function against rates shown at checkout for the same cart contents and address. They matched across all shipping zones the store had configured.
Using It in Practice
In the Action Scheduler callback, calling this function is straightforward:
$rates = calculate_shipping_for_order(
[
[ 'product_id' => 1234, 'quantity' => 2 ],
[ 'product_id' => 5678, 'quantity' => 1 ],
],
'GB', '', 'SW1A 1AA', 'London'
);
// Use the first available rate (or apply your own selection logic)
if ( ! empty( $rates ) ) {
$shipping_rate = $rates[0];
// Add to your programmatically created order...
}
This pattern has been running reliably in production on a store that generates around 50 automated orders per day. It handles all the store's shipping zones — UK standard, UK express, EU, and rest of world — without any session dependency.
The broader lesson here is that WooCommerce's architecture assumes a browser-based checkout flow. When you need to replicate that logic in a background context, you have to reconstruct the pieces WooCommerce expects to find: the customer object, the package array, and the product data. Skip any of those, and individual shipping plugins will break in their own unique ways.
This is exactly the kind of deep WooCommerce problem I deal with regularly as part of my WooCommerce maintenance and support work.
Need help with something similar? Check out my maintenance plans.
