Cart and checkout
The Cart Object and Sessions Behind the Checkout
Where the cart actually lives between requests, why WC()->cart is null in half your requests, and what the session row does and does not keep.
A gift-card extension redeems a code through its own REST route and adds the resulting
product to the cart. It works from a product page, and on /wp-json/sifero/v1/redeem
it dies with Call to a member function add_to_cart() on null. Same plugin, same
code path, different kind of request. WC()->cart is a property WooCommerce fills in
during boot, for some requests only.
The second surprise is that the cart is not stored anywhere. There is no cart table. What
persists is a session row keyed by a customer id, and the cart object is rebuilt from that row
on every request by looking each product up again. Code that treats WC()->cart as
durable state loses work the first time a product is deleted or renamed.
Scope is WooCommerce 11.2.1 on WordPress 7.0, PHP 8.3 or newer. Names, hooks, arguments and
schema were read from the 11.2.1 source tree. The hash tables came from a script that calls the
real wc_get_cart_item_data_hash() and mirrors
WC_Cart::generate_cart_id() line for line; no booted WordPress install was available
here.
WC()->cart is null for most of the request, and for whole request types
The property starts life as null: public $cart = null; in
includes/class-woocommerce.php. It is filled in by WC()->init(),
which WordPress calls on the init action at priority 0. That
method calls wc_load_cart(), and it only does so when
$this->is_request( 'frontend' ) is true:
case 'frontend':
return ( ! is_admin() || defined( 'DOING_AJAX' ) ) && ! defined( 'DOING_CRON' ) && ! $this->is_rest_api_request();
Read that as a list of exclusions: no cart in wp-admin, none under WP-Cron, none
on any REST request (is_rest_api_request() looks for the REST prefix in the request URI
or a rest_route parameter). A callback on plugins_loaded,
before_woocommerce_init or an admin screen sees a null cart. So does the gift-card
route above.
A second and subtler "too early" catches people who already know about init.
Even with the object present, the lines are missing:
WC_Cart_Session::get_cart_from_session() is hooked to wp_loaded, and
until it runs cart_contents is empty. WC_Cart::get_cart() says so
out loud.
public function get_cart() {
if ( ! did_action( 'wp_loaded' ) ) {
wc_doing_it_wrong( __FUNCTION__, __( 'Get cart should not be called before the wp_loaded action.', 'woocommerce' ), '2.3' );
}
if ( ! did_action( 'woocommerce_load_cart_from_session' ) ) {
$this->session->get_cart_from_session();
}
return array_filter( $this->get_cart_contents() );
}
That is a warning and not a failure because the method recovers by calling
get_cart_from_session() itself. Your code works and your log fills with notices, which
is the worse of the two outcomes. The hook that fits is woocommerce_init, fired
immediately after wc_load_cart():
<?php
add_action( 'plugins_loaded', function () {
// Null. init has not run.
var_dump( WC()->cart );
} );
add_action( 'init', function () {
// A WC_Cart on a frontend request. Null in wp-admin, WP-Cron and REST.
var_dump( WC()->cart instanceof WC_Cart );
} );
add_action( 'woocommerce_init', function () {
// Same as above, but this is the documented seam, and wc_load_cart() has run.
if ( ! WC()->cart instanceof WC_Cart ) {
return;
}
printf( "%d lines in the cart\n", count( WC()->cart->get_cart() ) );
} );
The Store API is the exception. It boots the cart inside a REST request on purpose:
CartController::load_cart() calls wc_load_cart(), then sets
$cart->cart_context = 'store-api'. Core branches on that string, and the next
lesson shows it deciding whether your fee applies. The default is 'shortcode'.
A guest is keyed by a random string, a logged-in customer by their user id
The session id is not a hash of the visitor. It comes from one line:
public function generate_customer_id() {
return is_user_logged_in() ? (string) get_current_user_id() : wc_rand_hash( 't_', 30 );
}
A logged-in customer's session key is their user id as a string, so user 7 and session key
"7" are the same thing. A guest gets a 30-character random string prefixed
t_. is_customer_guest() tests those two characters, and the session
table's session_key column is char(32), which fits both.
For a guest, that string has to survive a page load, so it goes into a cookie named
wp_woocommerce_session_ plus COOKIEHASH, filterable through
woocommerce_cookie. The value is
customer_id|expiration|expiring|hash, and the handler re-checks that hash with
hash_equals() on every read, so a hand-edited cookie destroys the session rather
than resuming it.
One detail matters before you depend on that cookie: it is not set when the session starts,
but when the cart stops being empty. maybe_set_cart_cookies() runs on
wp_loaded and shutdown and only calls
set_cart_cookies( true ) when ! $this->cart->is_empty(), because a
cookie stops the page being cacheable. Expiry defaults to one week for a logged-in customer and
two days for a guest, filterable through wc_session_expiration and capped at 30
days.
Sessions that end themselves
A feature flag called destroy-empty-sessions, added in 10.3.0 and still marked
experimental, deletes a guest session and its cookie when the session data is empty. It ships
disabled. An extension that depends on that cookie for a guest who has added nothing is the
compatibility risk its description warns about.
Logging in moves the existing session. migrate_guest_session_to_user_session()
rewrites the guest row under the user id, deletes the guest row and fires
woocommerce_guest_session_to_user_id with both ids. Nothing is lost, and an extension
that keys its own table by session id has a hook to follow the move.
The session row holds the cart, the coupons and a pointer to the draft order
WooCommerce writes a fixed, short list of keys from
WC_Cart_Session::set_session(), hooked to
woocommerce_after_calculate_totals at priority 1000: cart,
cart_totals, applied_coupons, coupon_discount_totals,
coupon_discount_tax_totals and removed_cart_contents. Each is written as
null rather than an empty array when it has no value, so an empty session can be
forgotten instead of stored. Three shipping keys join them on a shippable cart, and
store_api_draft_order holds the draft order the Checkout block builds.
The list of things that are not stored is the more useful one. The product object is
not: get_cart_for_session() exists to unset data on each line before
serialisation. Coming back in, get_cart_from_session() primes the post cache and
rebuilds each line with wc_get_product(). A line whose product is gone, is not
purchasable, or whose data_hash no longer matches is dropped, with a notice to the
customer.
Fees are not stored either, by design. add_fee()'s docblock says fees do not
persist, and reset_totals() calls remove_all_fees() at the start of
every calculation. A fee is a function of the current cart, not a thing that lives in it, and
the stored cart_totals is the same kind of value: a cache of the last calculation,
overwritten on the next.
Two session handlers ship in 11.2, and neither is object-cache-only
The session handler is replaceable: WC()->initialize_session() runs
apply_filters( 'woocommerce_session_handler', 'WC_Session_Handler' ) and calls
init() on what comes back. Exactly two classes in the 11.2.1 tree extend
WC_Session, and they answer the persistence question differently.
The default, WC_Session_Handler, uses a table, from WC_Install:
CREATE TABLE wp_woocommerce_sessions (
session_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
session_key char(32) NOT NULL,
session_value longtext NOT NULL,
session_expiry bigint(20) unsigned NOT NULL,
PRIMARY KEY (session_id),
KEY session_expiry (session_expiry),
UNIQUE KEY session_key (session_key)
)
Reads go through the object cache first. get_session() tries
wp_cache_get() in the group WC_SESSION_CACHE_GROUP
('wc_session_id') and only on a miss runs SELECT session_value ... WHERE
session_key = %s, then caches the result for the remaining session life. Writes happen on
shutdown at priority 20: an INSERT ... ON DUPLICATE KEY UPDATE plus a
matching wp_cache_set(). Cache keys carry a prefix from
WC_Cache_Helper::get_cache_prefix(), so one call invalidates every session entry,
which is what the cleanup cron does after deleting expired rows.
So the answer to "table or object cache" in 11.2 is the table, with a read-through cache in front. No shipped handler keeps sessions only in a persistent object cache, and no feature flag switches to one. A store whose sessions table stays small under load has a persistent object cache absorbing reads, not a different storage engine.
The second handler serves the Checkout and Cart blocks. It landed in 10.7.0 as
Automattic\WooCommerce\StoreApi\SessionHandler, a final class
whose docblock calls it token-based: it identifies the session from the
Cart-Token HTTP header instead of a cookie and, in its words, has no cookie, cron
or cache layer. It shares the same {prefix}woocommerce_sessions table. The Store
API selects it per request: AbstractCartRoute::load_cart_session() validates the
token, then adds a woocommerce_session_handler filter returning that class, after
which CartController::load_cart() boots the cart. That is how a block checkout
keeps a cart for a browser sending no WooCommerce cookie.
A cart item key is a hash of the product, not of the quantity
Two additions of the same product do not create two lines; they create one line with a
higher quantity. The reason is in generate_cart_id(), whose inputs are the product
id, the variation id, the variation attributes and your own $cart_item_data.
Quantity is not among them. add_to_cart() computes that id, hands it to
find_product_in_cart(), and on a hit calls set_quantity() with the old
quantity plus the new one.
WC_Cart::generate_cart_id() -> cart item key (mirrored body)
simple 42, no extras a1d0c6e83f027327d8461063f4ac58a6
simple 42, no extras, again a1d0c6e83f027327d8461063f4ac58a6
simple 42, engraved "AL" be50acbb72cef15ad4aa446b1c7634a4
simple 42, engraved "LA" 2adb52e8940108e9305dae62c07880ae
simple 42, engraved "A" + "L" (two keys) 29261be72126a313e74c2e174bece612
variable 42 -> variation 77, size=large a1ed2d294c04e3e9a90f475064be6aee
variable 42 -> variation 77, size=large again a1ed2d294c04e3e9a90f475064be6aee
simple 42, extras ["a"=>"bc"] fb77171f6cb5c930377131111c3d0436
simple 42, extras ["ab"=>"c"] fb77171f6cb5c930377131111c3d0436
wc_get_cart_item_data_hash() -> cart item data_hash (real function, real source)
simple product b5c1d5ca8bae6d4896cf1807cdf763f0
variable product 36f76fb62ac326633be66211f53a026c
variation: size=large 7122d4bda979f0ba22aaa595be4d52b8
variation: size=large, colour=red 05715271b9f302a8b8b7e5ae31f217ed
same variation, different product id 7122d4bda979f0ba22aaa595be4d52b8
Three things fall out of that table. The last two rows collide: the same values with a
different key shape produce the same hash, because the loop concatenates
trim( $key ) . trim( $value ) with no separator, so keep the keys fixed and put the
variation in the value. The data_hash table is the opposite property on purpose: it
covers the product type and its variation attributes and never the product id, so two
variation products with identical attributes hash the same. It fingerprints the product's shape,
which is all it is used for. And woocommerce_cart_id filters the whole key, which is a
compatibility trap rather than an extension point: what you change there has to stay stable across
requests.
The two hooks you will use fire at opposite ends of the operation
woocommerce_add_to_cart fires at the end of add_to_cart(), after
the line is in cart_contents, with six arguments:
do_action( 'woocommerce_add_to_cart', $cart_item_key, $product_id, $quantity, $variation_id, $variation, $cart_item_data );
Core is already listening: WC_Cart::__construct() adds
calculate_totals at priority 20, and WC_Cart_Session adds
persistent_cart_update and maybe_set_cart_cookies. At priority 10 the
cart has the new line but the totals predate it, so a callback reading
$cart->get_total() reads the previous pass. Read the line you were handed.
woocommerce_cart_item_removed is the mirror image and takes two arguments,
$cart_item_key and the cart object. The ordering inside
remove_cart_item() matters: the line is copied into
removed_cart_contents with its data unset, then
woocommerce_remove_cart_item fires, then the line leaves
cart_contents, and only then does woocommerce_cart_item_removed fire.
If your callback needs the product object, use woocommerce_remove_cart_item. The
line stays recoverable through restore_cart_item() until
clean_up_removed_cart_contents() clears it on a later page request, which is how
the cart's undo link works.
Write a small mu-plugin that answers four questions about a running store.
- Print
get_debug_type( WC()->cart )onplugins_loaded,before_woocommerce_init,woocommerce_initandwp_loaded, from a frontend page view and again from/wp-json/. - On
woocommerce_add_to_cart, log all six arguments plusWC()->session->get_customer_id(). Add the same product twice and compare the two$cart_item_keyvalues and the two quantities. - On
woocommerce_cart_item_removed, read$cart->get_removed_cart_contents()[ $cart_item_key ]and report which keys are missing. - As a guest with an empty cart, inspect the response cookies and say whether
wp_woocommerce_session_...is there.
Then answer: for a guest who has never added anything, what does
has_session() return, and why does that matter for an extension that writes to
the session on page load?
Show the solution and the reasoning
<?php
/**
* Plugin Name: Sifero session probe
*/
foreach ( array( 'plugins_loaded', 'before_woocommerce_init', 'woocommerce_init', 'wp_loaded' ) as $hook ) {
add_action( $hook, function () use ( $hook ) {
error_log( sprintf(
'%-24s cart=%-14s session=%s',
$hook,
function_exists( 'WC' ) && WC()->cart ? get_debug_type( WC()->cart ) : 'n/a',
function_exists( 'WC' ) && WC()->session ? get_debug_type( WC()->session ) : 'n/a'
) );
}, 999 );
}
add_action( 'woocommerce_add_to_cart', function ( $cart_item_key, $product_id, $quantity, $variation_id, $variation, $cart_item_data ) {
error_log( sprintf(
'add_to_cart key=%s product=%d qty=%d customer=%s',
$cart_item_key,
$product_id,
$quantity,
WC()->session->get_customer_id()
) );
}, 10, 6 );
add_action( 'woocommerce_cart_item_removed', function ( $cart_item_key, $cart ) {
$removed = $cart->get_removed_cart_contents()[ $cart_item_key ] ?? array();
error_log( 'removed keys: ' . implode( ',', array_keys( $removed ) ) );
}, 10, 2 );
What the four hooks show. plugins_loaded and
before_woocommerce_init report a null cart, because WC()->init() has
not run. woocommerce_init reports a WC_Cart, because
wc_load_cart() runs a few lines above the do_action that fires it,
and wp_loaded reports the same class with contents populated. From
/wp-json/, all four report null except on a Store API cart route.
What the add_to_cart log shows. Two additions of the same product log the
same $cart_item_key, and $quantity is what you sent rather than the
new line quantity, because add_to_cart() passes its own argument and the running
total lives in set_quantity(). For the resulting quantity, read
$cart->get_cart()[ $cart_item_key ]['quantity']: at priority 10 the line is
already updated and the totals are not.
What the removed-item log shows. The entry is there with no
data key, because remove_cart_item() unsets it before storing the
line. It still has product_id, variation_id,
quantity, data_hash and your own item data, which is enough to
restock or write a log row without loading the product.
The guest with an empty cart. has_session() returns true if
$_COOKIE[ $this->_cookie ] is set, or _has_cookie is set, or the
visitor is logged in.
For a logged-out visitor with no cookie, all three are false. Writing to the session anyway
sets _dirty, and save_data() then refuses to write because
has_session() is false, so the data is dropped at the end of the request with no
error. To store session data for a visitor who has not shopped yet, call
set_customer_session_cookie( true ) first and accept that those pages are now
uncacheable. That trade is why core waits for a non-empty cart.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.