Skip to content
System nominalUTC --:--:--Articles 080
MODULE 03 Lesson 07 / 18 30 MIN

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.

Lab 30 minutes

Write a small mu-plugin that answers four questions about a running store.

  • Print get_debug_type( WC()->cart ) on plugins_loaded, before_woocommerce_init, woocommerce_init and wp_loaded, from a frontend page view and again from /wp-json/.
  • On woocommerce_add_to_cart, log all six arguments plus WC()->session->get_customer_id(). Add the same product twice and compare the two $cart_item_key values 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.