Architecture

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

WooCommerce is not an application that runs next to WordPress: it is one WordPress plugin, a few thousand PHP files that WordPress includes on every request, plus a React front end that ships already built. Everything WooCommerce does, from registering the product post type to taking a payment, happens through the same hooks, loaders, REST server and database layer that any other plugin uses. That is why this page spends its first half on WordPress: a developer who knows which WordPress mechanism WooCommerce relies on at each step can predict where a customisation belongs and why it sometimes does not run.

The page was written against WooCommerce 11.2.1 (the stable release of 2026-10-09; 11.3.0-beta.1 followed on 2026-10-26) on WordPress 7.1.3, PHP 8.3.36 and MariaDB 11.8.9, and checked on 2026-10-27. WooCommerce 11.1.0 raised the WordPress minimum to 7.0, the plugin header of 11.2.1 still says PHP 7.4, and PHP 8.1 becomes the minimum from 11.6 (February 2027); the versions page explains why the plugin header and the merchant-facing server requirements disagree. Architecture does not depend on the plan or the hosting: the code described here is the same on a shared host and on a Docker container, and it is the free plugin from WordPress.org that is analysed. WooPayments and the paid extensions of the Woo Marketplace are separate plugins built on exactly the same foundations and are not part of the 11.2.1 download.

The page follows the code in the order a request meets it. It starts with how WooCommerce sits on WordPress, then the directories of the plugin and the three autoloaders, then the bootstrap with the real hook priorities, the container that builds the modern classes, the packages that are bundled, the feature flags of FeaturesController and finally where an extension should attach. The running example is Pocket Coffee, the small specialty-coffee shop used across this section: the plugin pocket-coffee-architecture records the boot order and declares its feature compatibility, and the next page, Hooks: Actions and Filters, builds the roast date badge and the order-placed log on the same foundations. The WordPress concepts used here (plugins, hooks, blocks, WP-CLI) are explained as they appear; the WooCommerce landing page lists the rest.

The shape of a WooCommerce store

The figure shows the layers of a store. Four kinds of client reach WordPress: the shopper’s browser, which receives HTML and block-rendered pages; wp-admin, a mix of PHP screens and React pages; REST and Store API clients; and WP-CLI, which boots the very same WordPress and WooCommerce code and runs a command instead of rendering a page. WordPress core owns the lifecycle (plugin loading, hooks, the REST server, the template loader, cron and the block editor); WooCommerce is a plugin directory that registers itself through those hooks; the extensions and themes of the merchant attach to WooCommerce through the same hooks and a handful of WooCommerce-specific extension points.

Layers of a WooCommerce store: clients, WordPress core, the WooCommerce plugin split into includes, src, packages and templates, extensions and themes attached through hooks, and one database

Two consequences are worth stating before any code. First, there is no WooCommerce process, service or daemon: a request is a PHP process that loads WordPress, which loads the active plugins, and every WooCommerce feature is a callback attached to a hook of that process. Second, the database is the WordPress database; WooCommerce adds tables and rows to it but has no storage of its own, and everything it needs to persist between requests (the cart, the session, the order) has to be written there or to a cache that WordPress knows about.

How WooCommerce sits on WordPress

What WooCommerce adds to WordPress

On an installation with WooCommerce active, the extra WordPress objects can be listed with WP-CLI. The lab runs WP-CLI in a throw-away Docker container attached to the WordPress container; the commands below drop that wrapper, and the output is what the 11.2.1 store printed.

wp post-type list --fields=name,public --format=table | grep -E "product|shop_|name"
wp taxonomy list --fields=name,object_type --format=table | grep -E "product|name"
wp role list --fields=role,name --format=table
name    public
product    1
product_variation
shop_order
shop_order_refund
shop_coupon
shop_order_placehold
name    object_type
product_brand    product
product_type    product
product_visibility    product, product_variation
product_cat    product
product_tag    product
product_shipping_class    product, product_variation
pos_product_visibility    product, product_variation
pa_origin    product
pa_roast    product
role    name
administrator    Administrator
editor    Editor
author    Author
contributor    Contributor
subscriber    Subscriber
customer    Customer
shop_manager    Shop manager

The post-type and taxonomy listings are filtered by grep to the WooCommerce entries (the rest belong to WordPress itself), and the two pa_* taxonomies at the end are global attributes that other examples of this section created on the lab store. The output shows the WordPress side of the WooCommerce data model:

WordPress object What WooCommerce uses it for

Post types product and product_variation

Products and the variations of a variable product. Registered at priority 5 of init by WC_Post_Types. They are ordinary posts, so the Posts API, the block editor, revisions and WP_Query all see them.

Post types shop_order, shop_order_refund and shop_order_placehold

The legacy storage of orders and refunds. With HPOS (High-Performance Order Storage) enabled, as on the lab store, orders live in wp_wc_orders and its sibling tables and wp_posts only holds a shop_order_placehold row that reserves the order ID. The HPOS page covers the consequences.

Post type shop_coupon

Coupons.

Taxonomies product_cat, product_tag, product_type, product_visibility, product_shipping_class, product_brand

Categories, tags, the product type (simple, variable …​), visibility flags such as featured, the shipping class and brands. product_type and product_visibility are internal and have no admin screen of their own.

Attribute taxonomies pa_*

One taxonomy per global product attribute, created from a row of the table woocommerce_attribute_taxonomies, for example pa_origin.

Roles customer and shop_manager

The customer role has only the read capability. The shop manager role and the administrator hold the manage_woocommerce capability, which gates every WooCommerce admin screen.

Pages

Shop, Cart, Checkout and My account are created at install time; their IDs are stored in options and read with wc_get_page_id( 'shop' ) and its siblings. On the lab store they are pages 5, 6, 7 and 8.

Rewrite endpoints

Thirteen endpoints are added to the My account and Checkout pages, among them order-pay, order-received, orders, view-order, downloads, edit-account, edit-address, payment-methods and lost-password (the list returned by WC()->query->get_query_vars()).

Shortcodes

[woocommerce_cart], [woocommerce_checkout], [woocommerce_my_account], [woocommerce_order_tracking], [products], [product_page], [product_categories], [add_to_cart] and a few more. They are the classic way to place the shop pages and still work.

REST namespaces

wc/v3 (and the older wc/v1 and wc/v2), wc/store/v1, wc-admin, wc-analytics, wc-telemetry, wc/private and, for WooCommerce.com connections, wccom-site/v3, all listed by GET /wp-json/.

Options

185 woocommerce_* rows in wp_options on the lab store: store address, currency, tax and shipping settings, page IDs, feature switches, the database version.

WP-Cron and Action Scheduler

A few WordPress cron events (for example wc_admin_unsnooze_admin_notes) plus the action_scheduler_run_queue event that drains the queue of the bundled Action Scheduler.

Custom tables

Sessions, tax rates, shipping zones and methods, API keys, payment tokens, order items, downloadable permissions, lookup tables for analytics and product search, the HPOS tables, rate limits, stock reservations and Action Scheduler’s own four tables: 41 in total on the lab store on the day of the run (wp db tables prints them).

The REST namespaces and the table list can be checked in a minute against any store:

curl -s http://localhost:8081/wp-json/ | python3 -c 'import sys,json; print(json.load(sys.stdin)["namespaces"])'
wp db tables --all-tables-with-prefix | grep -E "wc_|woocommerce|actionscheduler" | wc -l
['oembed/1.0', 'ec-hello/v1', 'wc/v3', 'wc/v1', 'wc/pos/v1/catalog', 'jetpack/v4', 'wc-admin', 'wc-analytics', 'wc/store', 'wc/store/v1', 'wc/private', 'wc/v2', 'wc-admin-email', 'wc-push-notifications', 'wc-telemetry', 'wccom-site/v3', 'wp-abilities/v1', 'mcp', 'wp/v2', 'wp-site-health/v1', 'wp-block-editor/v1']
41

ec-hello/v1 is the route of a smoke-test plugin on the lab store; wp-abilities/v1 and mcp come from the WordPress Abilities API and the MCP Adapter plugin, which the AI page covers.

What WordPress keeps doing

WooCommerce replaces very little of WordPress; it reuses it. The WordPress plugin loader includes the main file. The WordPress hook system, described on the next page, is the only extension mechanism, so a WooCommerce callback and a callback of the merchant’s own plugin sit in the same priority list. The template loader decides which file renders a request: for a classic theme WooCommerce filters template_include and loads a template from the templates/ directory unless the theme overrides it, and for a block theme the Site Editor templates are used and WooCommerce contributes block templates and patterns. The REST server serves wc/v3 and wc/store/v1 like any other namespace. Users, roles and capabilities come from WordPress: a WooCommerce customer is a WordPress user with the customer role, and a guest has no user at all (the cart of a guest is a session row identified by a cookie, see below). Cron is WordPress cron, with Action Scheduler layered on top for work that must survive and be retried.

The two places where WooCommerce deliberately steps beside WordPress are storage and the shopper’s state. Orders can leave wp_posts entirely (HPOS), and the cart and the customer of a visitor live in a WooCommerce-owned session table and cookie (the table wp_woocommerce_sessions and the cookie wp_woocommerce_session_<hash>; for a logged-in user the session is keyed by the user ID, which the lab confirmed: a WP-CLI process run as user 1 shows a session handler whose customer ID is 1). The cart is also the object WordPress knows least about: it is loaded only on requests that need it, as the next listing shows.

wp --exec='define( "WP_ADMIN", true );' eval 'echo "is_admin(): ", var_export( is_admin(), true ), "\ncart: ", is_null( WC()->cart ) ? "null" : get_class( WC()->cart ), "\nsession: ", is_null( WC()->session ) ? "null" : get_class( WC()->session ), "\ncustomer: ", is_null( WC()->customer ) ? "null" : get_class( WC()->customer ), "\n";'
is_admin(): true
cart: null
session: null
customer: null

The same command without the --exec argument prints WC_Cart, WC_Session_Handler and WC_Customer, because a WP-CLI process is not an admin request. The rule is in WooCommerce::init(): wc_load_cart() runs only for a "frontend" request, which the class defines as not admin (or an AJAX call), not cron and not REST. It matters for any code that runs from an admin screen or a scheduled action: WC()->cart is null there, and calling wc_load_cart() first is the documented workaround.

The plugin on disk

The 11.2.1 download contains these top-level entries:

woocommerce/
  woocommerce.php    plugin header and bootstrap
  includes/          legacy global classes, 629 PHP files
  src/               namespaced classes, 1,448 PHP files
  packages/          bundled packages: action-scheduler, blueprint, email-editor, woocommerce-admin
  templates/         classic PHP templates, block templates, 230 PHP files
  assets/            built JavaScript, CSS, fonts and images (assets/client holds the React builds)
  patterns/          block patterns
  vendor/  lib/      Composer packages and the Jetpack autoloader; lib/packages holds namespaced third-party code
  i18n/  sample-data/  readme.txt  uninstall.php  license.txt

The JavaScript source of the admin app and of the blocks is not in the plugin; only the built bundles are, and the sources live in the client directory of the public monorepo on GitHub. A developer who reads the PHP of an installed store sees all the server code but only minified front-end code.

includes versus src

The two code directories are two eras of the same plugin, and the difference decides how a class is found, built and supported.

includes/ src/

Naming

Global classes with a WC_ prefix, such as WC_Order, WC_Cart and WC_Product, and procedural files such as wc-core-functions.php

Namespaced classes under Automattic\WooCommerce\, with PSR-4 file names

Loading

WC_Autoloader maps WC_Some_Class to includes/class-wc-some-class.php, with extra folders for gateways, shipping, shortcodes, meta boxes and REST controllers; the core files are also included explicitly by WooCommerce::includes()

The Jetpack autoloader reads the Composer class map generated for src/

Construction

new WC_Cart(), the global WC() object and static methods

wc_get_container()->get( Some::class ): built once by the container, dependencies injected

Public contract

This is the API that books, themes and thousands of plugins call: CRUD objects, the cart, hooks

Mixed: Utilities, Enums and the documented Store API and block classes are meant for extensions; everything under Internal is not

The src/ directory has these top-level folders: Abilities, Admin, Blocks, Caches, Caching, Checkout, Database, Enums, Gateways, Internal, LayoutTemplates, Proxies, StoreApi and Utilities, plus Container.php, Packages.php, Autoloader.php and Deprecated.php. The folder names show how the plugin absorbed its former feature plugins: src/Blocks holds what used to be the standalone WooCommerce Blocks plugin (148 entries in Blocks/BlockTypes), src/StoreApi the Store API (40 files in Routes/V1) and src/Admin what was the WooCommerce Admin plugin.

The rule of Internal deserves emphasis because it decides whether an extension survives updates. The namespace Automattic\WooCommerce\Internal (59 entries in 11.2.1) holds implementation classes that the source marks @internal: WooCommerce may rename, move or delete them in any release, and the backward-compatibility promise of the plugin does not cover them. FeaturesController, CustomOrdersTableController and the abstractions of HPOS live there, and so do most of the classes the container builds at boot. When an official recipe has to use one, it guards it, as the HPOS recipe book does with class_exists() before it asks the container for the CustomOrdersTableController. The public helpers sit one level up: Automattic\WooCommerce\Utilities\FeaturesUtil, OrderUtil, PluginUtil, the enums in Enums (OrderStatus, ProductType and the others), and the extension points of the Store API and the blocks.

The flowchart condenses the decision. Its first question is the one that matters in practice: is the class meant for you?

flowchart TD A["I need a WooCommerce class or function"] --> B{"Does the name start with WC_ or is it a wc_ function?"} B -- yes --> C["includes/: the long-standing public API: WC_Order, WC_Cart, wc_get_order(), wc_get_product()"] B -- no --> D{"Which part of the namespace Automattic\WooCommerce\ ?"} D -- "Internal\" --> E["Not API: it can change in any release. Look for a public helper, a hook or a REST route first; if you must use it, guard it with class_exists()"] D -- "Utilities\, Enums\" --> F["Public helpers: FeaturesUtil, OrderUtil, PluginUtil, OrderStatus ..."] D -- "StoreApi\, Blocks\" --> G["Use the documented entry points only: ExtendSchema from StoreApi::container(), IntegrationInterface, AbstractPaymentMethodType"] D -- "Admin\" --> H["Admin features and REST controllers: extend through the documented filters, not by subclassing"]

The three autoloaders

Three class loaders cooperate, and knowing them explains two classes of errors: a "class not found" for a WC_ class that has been moved, and a fatal error during an in-place update.

  1. The Composer class map and PSR-4 rules for Automattic\WooCommerce\, loaded by src/Autoloader.php through vendor/autoload_packages.php, the Jetpack autoloader. The Jetpack autoloader exists so that when two plugins bundle different versions of the same Composer package, the newest copy wins.

  2. WC_Autoloader, registered by includes/class-wc-autoloader.php, which turns a class name such as WC_Gateway_Cheque into the file includes/gateways/cheque/class-wc-gateway-cheque.php and similar mapped locations, and refuses to load the retired WC_API class.

  3. A WooCommerce-scoped PSR-4 loader appended to the SPL autoload stack as a last resort. When WordPress replaces the files of the plugin in the middle of a request (an in-place update), the Jetpack class map that was snapshotted at the start of the request cannot find a class that is new in the update; the fallback loads it from disk instead of crashing the request.

The page on extension development shows how a plugin of your own should use Composer; for the PHP basics see namespaces and autoloading and Composer and packages.

The bootstrap, hook by hook

woocommerce.php

The main file does five things before any hook has fired, and it is short enough to read in a minute:

  1. It defines WC_PLUGIN_FILE and loads src/Autoloader.php and src/Packages.php; if the Composer autoloader is missing it returns early and the plugin does nothing (a broken or partial installation shows an admin notice instead of a fatal error).

  2. It calls Packages::init(), which only registers hooks: package preparation on plugins_loaded at priority -100, package initialisation at priority 10, and the guards that stop a merged feature plugin from being activated beside the core.

  3. It registers the WooCommerce-scoped fallback autoloader described above.

  4. It includes includes/class-woocommerce.php and builds the container, $GLOBALS['wc_container'] = new Automattic\WooCommerce\Container().

  5. It defines the functions WC() (the WooCommerce singleton) and wc_get_container(), and sets $GLOBALS['woocommerce'] for the code that still reads the global.

WC() calls WooCommerce::instance(), which constructs the object once. The constructor defines the constants (WC_ABSPATH, WC_VERSION, WC_ROUNDING_PRECISION and about a dozen more), registers the extra tables in the $wpdb object so that $wpdb->order_itemmeta and $wpdb->wc_product_meta_lookup exist, calls includes() and finally init_hooks(). includes() loads about a hundred files; which ones depends on the request:

Condition Extra files loaded

Always

Abstract classes, core functions, data stores, WC_Query, emails, REST authentication, tracking, and the Action Scheduler package (packages/action-scheduler/action-scheduler.php)

WP-CLI

class-wc-cli.php, the wp wc commands

Admin request

includes/admin/class-wc-admin.php and the legacy reports shim

Frontend, REST or the post editor

frontend_includes(): cart and notice functions, wc-template-hooks.php (which attaches the default classic template callbacks to the template hooks), the template loader, scripts, the form handler, WC_Cart, WC_Customer and the session handler

Active default theme

One of the class-wc-twenty-*.php compatibility files for the Twenty Ten to Twenty Twenty-Three themes; none for newer themes

The last row is a useful reminder of how old the plugin is: it carries layout fixes for themes that WordPress released between 2010 and 2023. The init_hooks() method is where the process becomes WooCommerce. It registers the activation hook (WC_Install::install), a shutdown function that logs fatal errors, some thirty hooks of its own, and then asks the container for roughly sixty-five service classes: thirty-three are only fetched, because their constructor or init() attaches hooks, and thirty-two more are fetched and told to register().

The order in which hooks fire

The next example is the plugin pocket-coffee-architecture. Its main file hooks seven moments of the boot at priority -10000, which is earlier than anything WooCommerce registers, and records for each whether the WooCommerce class and the function WC() exist. Its only other jobs are to declare compatibility with two WooCommerce features, which the feature-flag section explains, and to add a WP-CLI command that prints the log.

<?php
/**
 * Plugin Name:          Pocket Coffee Architecture
 * Description:          Records the order in which WordPress and WooCommerce boot, and declares feature compatibility.
 * Version:              0.1.0
 * Requires at least:    7.0
 * Requires PHP:         7.4
 * Requires Plugins:     woocommerce
 * WC requires at least: 11.0
 * WC tested up to:      11.2
 * Text Domain:          pocket-coffee-architecture
 * License:              GPL-2.0-or-later
 *
 * @package PocketCoffee\Architecture
 */

namespace PocketCoffee\Architecture;

use Automattic\WooCommerce\Utilities\FeaturesUtil;

defined( 'ABSPATH' ) || exit;

require_once __DIR__ . '/includes/class-boot-log.php';

// This line runs while WordPress includes the plugin file, before plugins_loaded.
Boot_Log::add( 'plugin file included' );

foreach ( array( 'plugins_loaded', 'woocommerce_loaded', 'after_setup_theme', 'init', 'before_woocommerce_init', 'woocommerce_init', 'wp_loaded' ) as $pc_hook ) {
	// Priority -10000 is earlier than anything WooCommerce registers (its lowest is -9999).
	add_action(
		$pc_hook,
		static function () use ( $pc_hook ) {
			Boot_Log::add( $pc_hook . ' (priority -10000)' );
		},
		-10000
	);
}

add_action(
	'before_woocommerce_init',
	static function () {
		// Compatibility declarations are only accepted while this action is running.
		FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
		FeaturesUtil::declare_compatibility( 'cart_checkout_blocks', __FILE__, true );
	}
);

/**
 * Returns the boot log as text.
 *
 * @return string
 */
function boot_report() {
	return implode( "\n", Boot_Log::all() ) . "\n";
}

if ( defined( 'WP_CLI' ) && WP_CLI ) {
	// "wp pocket-coffee boot" prints the events recorded while WordPress booted this very process.
	\WP_CLI::add_command(
		'pocket-coffee boot',
		static function () {
			\WP_CLI::log( rtrim( boot_report() ) );
		}
	);
}

The class that keeps the log is in includes/class-boot-log.php. The file name matters: the WordPress coding standard expects a class file to be called class-<name>.php, and it also expects a file to contain either functions or classes, not both. The first version of this plugin kept the class in the main file, and PHP_CodeSniffer reported both rules, which is why the class has its own file.

<?php
/**
 * In-memory boot log.
 *
 * @package PocketCoffee\Architecture
 */

namespace PocketCoffee\Architecture;

defined( 'ABSPATH' ) || exit;

/**
 * Keeps a list of boot events for the current request.
 */
final class Boot_Log {

	/**
	 * Recorded events, in the order they happened.
	 *
	 * @var string[]
	 */
	private static $events = array();

	/**
	 * Records one event with the state of the WooCommerce bootstrap at that moment.
	 *
	 * @param string $label Event label.
	 */
	public static function add( $label ) {
		self::$events[] = sprintf(
			'%-42s class WooCommerce: %-3s  function WC(): %s',
			$label,
			class_exists( 'WooCommerce', false ) ? 'yes' : 'no',
			function_exists( 'WC' ) ? 'yes' : 'no'
		);
	}

	/**
	 * Returns the recorded events.
	 *
	 * @return string[]
	 */
	public static function all() {
		return self::$events;
	}
}

Activating the plugin and running the command prints the boot of a real request:

wp plugin activate pocket-coffee-architecture
wp pocket-coffee boot
plugin file included                       class WooCommerce: no   function WC(): no
plugins_loaded (priority -10000)           class WooCommerce: yes  function WC(): yes
woocommerce_loaded (priority -10000)       class WooCommerce: yes  function WC(): yes
after_setup_theme (priority -10000)        class WooCommerce: yes  function WC(): yes
init (priority -10000)                     class WooCommerce: yes  function WC(): yes
before_woocommerce_init (priority -10000)  class WooCommerce: yes  function WC(): yes
woocommerce_init (priority -10000)         class WooCommerce: yes  function WC(): yes
wp_loaded (priority -10000)                class WooCommerce: yes  function WC(): yes

The first line is the lesson of the page. WordPress includes active plugins in the order of their folder names, and pocket-coffee-architecture sorts before woocommerce, so when this file runs the WooCommerce class does not exist yet. Any code that runs at include time and calls WC(), wc_get_order() or FeaturesUtil fatals or silently does nothing, depending on whether the call is guarded. That is the reason every extension waits for a WooCommerce hook; the header line Requires Plugins: woocommerce makes WordPress refuse to activate the plugin without WooCommerce but does not change the load order. The six later lines show that the order of the hooks is the order of the figure:

Boot order of one request: plugin files included, plugins_loaded with woocommerce_loaded at minus 1, after_setup_theme, init priority 0 with before_woocommerce_init and woocommerce_init, init priority 5 and 10, wp_loaded priority 20, with what WooCommerce does and what an extension should do at each step

The callbacks behind each step can be listed from the global $wp_filter, the array that stores every hook. The priorities in the figure come from this script, which prints the callbacks WooCommerce attached to the hooks of the boot; the lab shows only the lines that belong to WooCommerce and to the example plugin.

<?php
global $wp_filter;
foreach ( array( 'plugins_loaded', 'init', 'after_setup_theme' ) as $hook ) {
	echo "== $hook\n";
	foreach ( $wp_filter[ $hook ]->callbacks as $priority => $callbacks ) {
		foreach ( $callbacks as $callback ) {
			$function = $callback['function'];
			if ( is_array( $function ) ) {
				$name = ( is_object( $function[0] ) ? get_class( $function[0] ) : $function[0] ) . '::' . $function[1];
			} elseif ( $function instanceof Closure ) {
				$name = 'Closure';
			} else {
				$name = (string) $function;
			}
			printf( "%6d  %s\n", $priority, $name );
		}
	}
}
== plugins_loaded
  -100  Automattic\WooCommerce\Packages::prepare_packages
    -1  WooCommerce::on_plugins_loaded
     1  WooCommerce::init_jetpack_connection_config
    10  Automattic\WooCommerce\Packages::on_init
    10  WooCommerce::init_customizer
    11  WC_Brands::register_hooks
== init
     0  WC_AJAX::define_ajax
     0  WC_Auth::add_endpoint
     0  WooCommerce::init
     0  Automattic\WooCommerce\Internal\Caches\ProductCacheController::on_init
     1  WooCommerce::maybe_init_order_reviews
     4  Automattic\WooCommerce\Admin\Features\Features::load_features
     5  WC_Post_Types::register_taxonomies
     5  WC_Post_Types::register_post_types
     5  WC_Install::check_version
     9  WC_Post_Types::register_post_status
    10  WC_Query::add_endpoints
    10  WC_Shortcodes::init
    10  WC_Emails::init_transactional_emails
    10  WooCommerce::load_rest_api
== after_setup_theme
    10  WooCommerce::setup_environment
    11  WooCommerce::include_template_functions

The output is an excerpt of the real listing, which has about sixty entries on init alone (the Closures of the example plugin and of other plugins on the lab store are omitted). The important entries are WooCommerce::on_plugins_loaded at priority -1, which fires woocommerce_loaded after every other plugin has been included (the code comment explains the reason: the plugin directory name changes the load order, and woocommerce_loaded makes the signal independent of it), and WooCommerce::init at init priority 0, which is the method that makes the WooCommerce objects exist.

WooCommerce::init() runs these steps in order, and each is a place an extension can hook:

  1. FeaturesController::register_additional_features() registers the features that come from other classes, so that compatibility declarations find them.

  2. do_action( 'before_woocommerce_init' ): the only moment declare_compatibility() is accepted.

  3. The text domain is loaded; WC_Product_Factory, WC_Order_Factory, WC_Countries, WC_Integrations, WC_Structured_Data and the two deprecated-hook handlers are created. This is why wc_get_product() works only after this point.

  4. On a frontend request wc_load_cart() creates the session, the customer and the cart.

  5. Webhooks are loaded, and do_action( 'woocommerce_init' ) fires.

The pair of actions before_woocommerce_init and woocommerce_init is the one to remember. Declaring compatibility belongs to the first, and code that needs the product factory, the order factory or the cart belongs to the second or later. The woocommerce_loaded action is the right place to check that WooCommerce is present and to create your own plugin’s objects; it is early enough for classes and functions but too early for the factories.

Declaring work for the right request

One more property of the bootstrap shapes extension code: the same files serve every kind of request, so a hook callback must check what kind of request it is in. WooCommerce::is_request() is private, but WordPress and WooCommerce expose enough to ask the same questions: is_admin(), wp_doing_ajax(), wp_doing_cron(), wp_is_serving_rest_request() and defined( 'WP_CLI' ). WooCommerce’s own check for a REST request sniffs the request URI (the wp-json prefix or a rest_route parameter), because core’s REST_REQUEST constant is defined only after parse_request, long after the plugin has to decide what to load; the filter woocommerce_is_rest_api_request lets a site with an unusual REST path correct the answer, and the source comment tells code that runs after parse_request to prefer wp_is_serving_rest_request(). The Store API request of the Checkout block is a REST request and is not an admin request, so code guarded with is_admin() is skipped there, which is correct for admin-only features and wrong for anything that should run during checkout.

The dependency injection container

The src/ classes are not constructed with new at the call sites. They are fetched from the container, and the container is a small, WooCommerce-specific piece of code rather than a library.

How wc_get_container resolves a class: cache, allowed namespace, delegation of StoreApi and Blocks classes to the Blocks container, construction by reflection, injection of the init method arguments, caching, and the ContainerException cases

Automattic\WooCommerce\Container is a final class that implements the two PSR-11 methods get() and has() and wraps a RuntimeContainer. The runtime container has no registration step. Any class whose name starts with Automattic\WooCommerce\ is resolvable and is treated as a single instance, built the first time it is asked for and cached afterwards. The classes it builds follow three conventions that are checked at runtime:

  • the constructor must be public and must not have required arguments, because the container calls newInstance() with none;

  • dependencies are not passed to the constructor. They are passed to a public, non-static method called init(), whose parameters need class type hints; the container resolves each one the same way and calls init() once;

  • a class that needs something that is not in src/ (a WordPress function, a class from includes/) gets it through LegacyProxy (call_function(), call_static(), get_instance_of(), get_global()) or ActionsProxy. The proxies exist so that a test can replace them.

FeaturesController shows all three: it has an empty-argument constructor that registers its hooks, and final public function init( LegacyProxy $proxy, PluginUtil $plugin_util ) receives its two services. The container refuses anything else with a ContainerException, which the code documents as a bug to be fixed in the code, not a condition to recover from. The script below asks the real container what it thinks.

<?php
use Automattic\WooCommerce\Internal\Features\FeaturesController;

$a = wc_get_container()->get( FeaturesController::class );
$b = wc_get_container()->get( FeaturesController::class );
echo "same instance: ", var_export( $a === $b, true ), "\n";
echo "container has FeaturesController: ", var_export( wc_get_container()->has( FeaturesController::class ), true ), "\n";
echo "container has stdClass: ", var_export( wc_get_container()->has( stdClass::class ), true ), "\n";
try {
	wc_get_container()->get( stdClass::class );
} catch ( Throwable $e ) {
	echo get_class( $e ), ': ', $e->getMessage(), "\n";
}
try {
	wc_get_container()->get( 'Automattic\\WooCommerce\\DoesNotExist' );
} catch ( Throwable $e ) {
	echo get_class( $e ), ': ', $e->getMessage(), "\n";
}
same instance: true
container has FeaturesController: true
container has stdClass: false
Automattic\WooCommerce\Internal\DependencyManagement\ContainerException: Attempt to get an instance of class 'stdClass', which is not in the Automattic\WooCommerce\ namespace. Did you forget to add a namespace import?
Automattic\WooCommerce\Internal\DependencyManagement\ContainerException: Attempt to get an instance of class 'Automattic\WooCommerce\DoesNotExist', which doesn't exist.

Three practical rules follow from the run:

  1. An extension cannot register its own classes in the WooCommerce container. The namespace check rejects them. An extension builds its own objects, with a container of its own if it wants one, and uses the WooCommerce one only to read WooCommerce services. Even then, the services behind Internal are not API, so the container is not the front door of the plugin: the public functions (wc_get_order(), wc_get_product(), wc_get_logger()), the CRUD objects and the hooks are.

  2. wc_get_container() is for code in includes/, according to the comment on the function; inside src/ the same services arrive through init().

  3. Some classes are served by other containers. Class names that start with Automattic\WooCommerce\StoreApi\ or Automattic\WooCommerce\Blocks\ are handed to the container of the Blocks package, which is an explicit registry (a closure per class, register() for a shared instance and factory() for a new one on every call) and is also what StoreApi::container() returns. The one container call that extensions are expected to make is the Store API one:

<?php
use Automattic\WooCommerce\StoreApi\StoreApi;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;

// Always take the shared instance; never write "new ExtendSchema()".
$extend = StoreApi::container()->get( ExtendSchema::class );

The lab check that the Store API container returns one shared instance printed true; the Store API page uses the instance to extend the cart schema.

Classes that attach themselves

Two patterns recur in init_hooks(). A service whose constructor or init() attaches hooks is simply fetched ($container->get( OrderCountCacheService::class )), which is why the bootstrap list looks like a list of unused values. A service that implements RegisterHooksInterface (since 8.5.0; 25 classes in 11.2.1) has a register() method and is fetched and registered explicitly, so that the construction of an object and the attachment of its hooks are separate steps. Extensions are free to copy the second pattern, because it keeps constructors free of side effects and makes the class testable without WordPress: construct the object, then call register() from woocommerce_loaded. The plugin used on the hooks page does exactly that.

A consequence for debugging: the hook callbacks of WooCommerce are methods of container objects, so to remove one with remove_action() you must hold the same object, which the container gives you (wc_get_container()->get( Class::class )) at the cost of depending on an Internal class. The hooks page shows the safer alternatives.

Packages

WooCommerce bundles code that used to be, or still is, developed in separate repositories. The loader is the class Automattic\WooCommerce\Packages, which keeps three lists, and the lists record the history of the plugin:

List Entries in 11.2.1 Meaning

$packages

email-editor

A package that lives in packages/ and is initialised by its own class if its directory exists. A missing directory shows an admin notice (and a log line with WP_DEBUG) instead of a fatal error.

$base_packages

woocommerce-admin, woocommerce-gutenberg-products-block

Always loaded. The admin package lives in packages/woocommerce-admin; the Blocks code lives in src/Blocks and its entry class is Automattic\WooCommerce\Blocks\Package.

$merged_packages

woocommerce-brands, woocommerce-additional-variation-images

Former feature plugins now in core (brands in src/Internal/Brands.php). Loaded unless the option wc_feature_<name>_enabled is no; when the option is unset the package’s own is_enabled() decides. The feature plugin of the same slug cannot be activated and is shown with a notice recommending that the merchant delete it.

The bundled third-party packages are in the same packages/ directory: Action Scheduler (version 4.1.0 in both 11.2.1 and the 11.3.0-beta.1 download, although 4.2.0 exists upstream) and the Blueprint package (woocommerce/blueprint, which exports and imports store settings as a JSON document and ships with the blueprint feature enabled). The loader can be queried:

<?php
use Automattic\WooCommerce\Packages;

foreach ( array( 'woocommerce-admin', 'woocommerce-gutenberg-products-block', 'woocommerce-brands', 'email-editor', 'action-scheduler' ) as $name ) {
	printf( "%-42s directory: %-3s enabled package: %s\n", $name, Packages::package_exists( $name ) ? 'yes' : 'no', Packages::is_package_enabled( $name ) ? 'yes' : 'no' );
}
echo 'Action Scheduler version: ', ActionScheduler_Versions::instance()->latest_version(), "\n";
woocommerce-admin                          directory: yes enabled package: yes
woocommerce-gutenberg-products-block       directory: no  enabled package: yes
woocommerce-brands                         directory: no  enabled package: yes
email-editor                               directory: yes enabled package: no
action-scheduler                           directory: yes enabled package: no
Action Scheduler version: 4.1.0

Two rows look contradictory and are not. email-editor and action-scheduler report "enabled package: no" because is_package_enabled() answers only for the base and merged lists; the email editor is initialised through the first list and Action Scheduler is not a package in this sense at all: WooCommerce::includes() includes its main file directly. The Blocks and brands rows have no directory because their code is in src/. Action Scheduler is shared: any other plugin on the site that bundles Action Scheduler competes with this copy, and the newest version registered wins; the Action Scheduler page deals with that.

Feature flags and FeaturesController

WooCommerce ships many features before they are stable, some that merchants may turn off, and some that old plugins break. One class, Automattic\WooCommerce\Internal\Features\FeaturesController, owns all of it. It is instantiated at boot (it is in the list of init_hooks()), it holds the definitions of the features, it adds the Features section to the Advanced tab of the WooCommerce settings, and it keeps the compatibility information that plugins declare. Extensions never talk to it directly; they use the public wrapper Automattic\WooCommerce\Utilities\FeaturesUtil, whose static methods are get_features(), feature_is_enabled(), declare_compatibility(), get_compatible_features_for_plugin(), get_compatible_plugins_for_feature() and two methods that let a test setup enable a feature, or activate a plugin, despite declared incompatibilities.

The features of 11.2.1

Asking the controller for every feature, including the experimental ones, prints the list of the installation:

<?php
use Automattic\WooCommerce\Utilities\FeaturesUtil;

foreach ( FeaturesUtil::get_features( true, true ) as $id => $feature ) {
	printf(
		"%-30s experimental=%-5s enabled=%-5s deprecated_since=%s\n",
		$id,
		var_export( $feature['is_experimental'], true ),
		var_export( $feature['is_enabled'], true ),
		$feature['deprecated_since'] ?? '-'
	);
}
analytics                      experimental=false enabled=true  deprecated_since=-
product_gallery_videos         experimental=true  enabled=false deprecated_since=-
cart_checkout_blocks           experimental=false enabled=false deprecated_since=-
rate_limit_checkout            experimental=false enabled=false deprecated_since=-
marketplace                    experimental=false enabled=true  deprecated_since=10.5.0
order_withdrawal               experimental=false enabled=false deprecated_since=-
order_attribution              experimental=false enabled=true  deprecated_since=-
site_visibility_badge          experimental=false enabled=true  deprecated_since=-
hpos_fts_indexes               experimental=true  enabled=false deprecated_since=-
hpos_datastore_caching         experimental=false enabled=false deprecated_since=-
remote_logging                 experimental=false enabled=true  deprecated_since=-
deferred_transactional_emails  experimental=false enabled=false deprecated_since=-
customer_review_request        experimental=false enabled=false deprecated_since=-
email_improvements             experimental=false enabled=true  deprecated_since=-
blueprint                      experimental=false enabled=true  deprecated_since=-
block_email_editor             experimental=true  enabled=false deprecated_since=-
variation_gallery              experimental=false enabled=true  deprecated_since=11.1.0
wc-visual-attribute            experimental=true  enabled=false deprecated_since=-
point_of_sale                  experimental=false enabled=true  deprecated_since=11.0.0
point_of_sale_staff            experimental=true  enabled=false deprecated_since=-
fulfillments                   experimental=false enabled=true  deprecated_since=-
mcp_integration                experimental=true  enabled=false deprecated_since=-
destroy-empty-sessions         experimental=true  enabled=false deprecated_since=-
push_notifications             experimental=false enabled=true  deprecated_since=10.9.2
rest_api_caching               experimental=true  enabled=false deprecated_since=-
cart_save_for_later            experimental=true  enabled=false deprecated_since=-
product_wishlist               experimental=true  enabled=false deprecated_since=-
product_instance_caching       experimental=true  enabled=true  deprecated_since=-
customer_stock_notifications   experimental=true  enabled=false deprecated_since=-
block_editor_unified_assets    experimental=true  enabled=false deprecated_since=-
custom_order_tables            experimental=false enabled=true  deprecated_since=-
cost_of_goods_sold             experimental=false enabled=false deprecated_since=-

The list has 32 entries on 11.2.1. The enabled column is the state of this store on the day of the run (several of the values, such as fulfillments and email_improvements, are rollout flags that differ from store to store and from day to day), not a default. Reading the list requires four distinctions:

  • A feature has an on/off value stored in an option, woocommerce_feature_<id>_enabled by default (yes or no), or in the option named by the definition’s option_key (HPOS uses woocommerce_custom_orders_table_enabled). feature_is_enabled() reads that option and applies the default of the definition (enabled_by_default).

  • cart_checkout_blocks is a feature whose option is not the switch of the Checkout block. It exists so that plugins can declare whether they work with the block checkout, which is what the Plugins screen reports. On this store it reads enabled=false, yet the Cart and Checkout pages use the blocks: the feature value and the content of the pages are independent.

  • experimental features are left out of FeaturesUtil::get_features() unless the first argument is true, and the Features screen lists them in a second section whose description says they are "either experimental or incomplete, enable them at your own risk"; they carry no stability promise.

  • deprecated_since marks a feature that is permanently on or off: the controller returns the deprecated_value of the definition without reading the option, and FeaturesUtil::feature_is_enabled() logs a deprecation notice the first time per request. point_of_sale (11.0.0), variation_gallery (11.1.0) and marketplace (10.5.0) are in that state in 11.2.1.

A second, older family of flags was retired in 11.1.0. The WooCommerce Admin features (analytics, coupons, onboarding, homescreen, marketing, core-profiler, launch-your-store and about twenty more) used to be queried with Features::is_enabled() and toggled with Features::enable() and Features::disable(). In 11.1.0 enable() and disable() were deprecated, and the legacy slugs became a compatibility shim that always answers true (analytics and remote inbox notifications still read their options); calling Features::is_enabled( 'analytics' ) now logs a deprecation notice that says the shim "will be removed in" a later version. Code written for the old API, and any plugin that checks one of those slugs, should stop checking the flag and rely on the feature itself. The woocommerce_admin_features filter, which used to let a site drop admin features, still runs for the shim and carries a @since 11.1.0 note.

Defining a feature (core only)

A feature is defined by FeaturesController::add_feature_definition( $slug, $name, $args ). The arguments are the interface between the feature and the Features screen, and they are worth knowing when you read the settings page or the source:

Argument Effect

default_plugin_compatibility

compatible or incompatible: how to treat a plugin that declares nothing. Required since 10.3.0 (omitting it logs a "doing it wrong" notice and assumes compatible). The filter woocommerce_plugins_are_incompatible_with_feature_by_default can still flip it.

enabled_by_default

true for opt-out features. Default false.

is_experimental

List the feature in the separate "experimental" section of the Features screen and leave it out of get_features() by default. Default true.

option_key

The option that holds the value, when it is not woocommerce_feature_<id>_enabled.

description, learn_more_url, order, setting, additional_settings

What the Features screen shows and where.

disabled, disable_ui

Show the control but lock it, or hide it.

skip_compatibility_checks

Do not produce plugin-compatibility warnings for this feature.

deprecated_since, deprecated_value

Turn the feature into a constant, as above.

Each value may also be a callback that returns the value, which is how the descriptions are translated lazily. The definitions themselves are in the source of the controller and of two controllers that add their own (HPOS and cost of goods sold), added inside get_feature_definitions() to avoid circular container lookups. Third-party code cannot register a feature: the older action woocommerce_register_feature_definitions (8.3.0) was deprecated in 9.9.0 in favour of definitions inside the controller, and the source comment says the action should only be used for testing. The extension point that is public is the other half of the system.

Declaring compatibility

A plugin that touches orders, checkout or the product editor declares what it supports, with FeaturesUtil::declare_compatibility() during before_woocommerce_init. The first declaration of the example plugin, HPOS, is the one the Marketplace requires of every new submission; the second states that the plugin works with the Cart and Checkout blocks. The call is idempotent for the same value, throws an Exception when a plugin declares the same feature both compatible and incompatible, returns false for an unknown feature, and accepts either the full path of the plugin file (__FILE__) or the directory/file.php form.

The sequence diagram follows one declaration from the plugin to the Plugins screen:

sequenceDiagram participant WC as WooCommerce::init participant FC as FeaturesController participant P as Your plugin participant M as Merchant WC->>FC: register_additional_features() WC->>P: do_action( before_woocommerce_init ) P->>FC: FeaturesUtil::declare_compatibility( custom_order_tables, __FILE__, true ) FC-->>P: true (queued, normalised later) Note over FC: a call outside the action returns false and logs a doing-it-wrong notice M->>FC: Settings, Advanced, Features: enable HPOS FC-->>M: warning when an active plugin is incompatible or has not declared P->>FC: FeaturesUtil::feature_is_enabled( custom_order_tables ) FC-->>P: reads option woocommerce_custom_orders_table_enabled

The queued declaration is normalised lazily: the first query of the compatibility information resolves plugin files to plugin IDs and logs an error for a file that WordPress does not know. The result for the example plugin, and the proof that a late call fails:

<?php
use Automattic\WooCommerce\Utilities\FeaturesUtil;

$info = FeaturesUtil::get_compatible_features_for_plugin( 'pocket-coffee-architecture/pocket-coffee-architecture.php' );
echo 'compatible: ', implode( ', ', $info['compatible'] ), "\n";
echo 'incompatible: ', implode( ', ', $info['incompatible'] ), "\n";
echo 'uncertain: ', count( $info['uncertain'] ), " features\n";

// Outside before_woocommerce_init the declaration is refused.
$result = FeaturesUtil::declare_compatibility( 'custom_order_tables', 'pocket-coffee-architecture/pocket-coffee-architecture.php', true );
echo 'late declaration returned: ', var_export( $result, true ), "\n";
compatible: cart_checkout_blocks, custom_order_tables
incompatible:
uncertain: 30 features
late declaration returned: false

Run with WP_DEBUG on, the late call also prints Function FeaturesController::declare_compatibility was called incorrectly. FeaturesController::declare_compatibility should be called inside the before_woocommerce_init action, a message added in WooCommerce 7.0. The 30 "uncertain" features are those the plugin said nothing about; what WooCommerce does with them depends on the default_plugin_compatibility of each definition. On the merchant’s side, the controller adds plugin-row warnings on the Plugins screen, filters the plugin list by feature (the views_plugins filter), and can stop a plugin that is incompatible with an enabled feature from being activated from the Plugins screen; FeaturesUtil::allow_enabling_features_with_incompatible_plugins() and allow_activating_plugins_with_incompatible_features() are the escape hatches for test setups.

The command wp wc hpos compatibility-info prints the same information from the HPOS side. On the lab store it listed the example plugin among the compatible ones.

When to use a feature flag, and when not

The feature system answers "may the merchant turn this part of WooCommerce on". It is the wrong tool for an extension’s own switches. WooCommerce’s own contribution guide asks contributors not to use filters as feature flags and to prefer an option, because options persist in the database and are already filterable; an extension should follow the same rule and store its switches in options registered on its settings page, as the settings page shows. What an extension does take from the system is the read side: if a behaviour depends on HPOS, ask OrderUtil::custom_orders_table_usage_is_enabled() (a public helper) instead of reading the option; if it depends on the Checkout block, test the page content rather than the cart_checkout_blocks value, for the reason in the previous subsection.

Where an extension fits

The structure above gives an extension author a short list of rules:

  1. Keep the main file free of WooCommerce calls: header, defined( 'ABSPATH' ), require_once, add_action.

  2. Declare feature compatibility on before_woocommerce_init.

  3. Create the plugin object on woocommerce_loaded (classes and functions exist) or woocommerce_init (factories and cart exist), and give it a register() method that adds the hooks.

  4. Use public helpers, CRUD objects and hooks; use StoreApi::container() for the Store API and nothing from Internal.

  5. Check the kind of request before doing work that only makes sense for one of them, and never assume WC()->cart or WC()->session exist.

  6. Put each class in its own class-*.php file and let the code standards check it.

The example plugin follows rules 1, 2 and 6 and passes the WordPress and WooCommerce coding standards with no findings:

phpcs --standard=WooCommerce-Core --runtime-set testVersion 7.4- --extensions=php pocket-coffee-architecture
phpcs --standard=WordPress-Extra --runtime-set testVersion 7.4- --extensions=php pocket-coffee-architecture
phpcs --standard=PHPCompatibilityWP --runtime-set testVersion 7.4- --extensions=php pocket-coffee-architecture

All three commands exit with status 0 and print nothing. The coding standards themselves (woocommerce/woocommerce-sniffs 2.0.0 with WordPress Coding Standards 3.4.1) are part of the Marketplace review described on the testing and Marketplace page.

Since the books

The three WooCommerce books of the 2015 to 2018 era (Rauland’s WooCommerce Cookbook, Ravensbergen’s Building E-Commerce Solutions with WooCommerce, Sims’s Building Your Online Store With WordPress and WooCommerce) describe a plugin that is recognisable and much smaller in concept. What still holds: WooCommerce is a plugin, WC() and the global $woocommerce are the entry point, the hooks are the extension mechanism, the shop pages hold shortcodes, and the includes/ classes with the WC_ prefix are the API. The files they tell the reader to copy into a child theme are still the classic templates.

What the books cannot show, because it did not exist or was not the default:

  • the src/ tree, the container, Internal and the packages: the books treat WooCommerce as one folder of WC_ classes;

  • the Blocks and Store API code, so the Cart and Checkout of the books are shortcode pages and the hooks around them are the classic hooks (see the hooks page for the ones that no longer reach the Checkout block);

  • HPOS: the books read and write orders as shop_order posts with post meta, and a plugin built from them must be rewritten to use the CRUD objects and to declare compatibility;

  • the feature screen and the compatibility declarations, because there was nothing to declare;

  • the minimum versions: PHP 5.x and WordPress 4.x in the books against WordPress 7.0 and PHP 7.4 (8.1 from 11.6) today, which is what lets the plugin use namespaces, closures and typed code throughout.

The WooThemes-era URLs and the Simplify Commerce gateway of the cookbook belong to the same past; the payment gateways page covers what replaced them.

Pocket Coffee thread

The Pocket Coffee shop of this section runs the extensions that the following pages build: a roast date badge on the product page and an order-placed log (the hooks page), an HPOS-aware order reader, an additional checkout field and a Store API extension. On this page the thread is the foundation: pocket-coffee-architecture is the template for each of them, with the three rules that matter: nothing runs before plugins_loaded, compatibility is declared on before_woocommerce_init, and the logic sits in classes attached from woocommerce_loaded. The lab store runs the extensions of many pages at the same time, which is a small demonstration of the architecture: unrelated plugins coexist because each attaches to hooks with its own priorities, and the only shared resource is the hook list that the hooks page teaches how to read.

Pitfalls

  • Calling WooCommerce at include time. The class does not exist yet when the plugin folder sorts before woocommerce. Move the call to woocommerce_loaded, woocommerce_init or later.

  • Declaring compatibility too late or not at all. declare_compatibility() outside before_woocommerce_init returns false and, with WP_DEBUG, a notice. A plugin that never declares is "uncertain" and, depending on the default_plugin_compatibility of the feature, may be reported as incompatible on the Plugins screen.

  • Using an Internal class. It works until a release moves it; the 11.1.0 retirement of the WooCommerce Admin feature flags is an example of an internal system that changed under plugins that called it.

  • Expecting WC()->cart in the admin, in cron or in an Action Scheduler job. It is null there; call wc_load_cart() first or work with the order instead of the cart.

  • Checking a feature option directly. Read FeaturesUtil::feature_is_enabled() (or the specific helper such as OrderUtil), so defaults, deprecated values and the analytics legacy filters are applied.

  • Registering a feature or a class in the container from a plugin. The container only builds classes in the WooCommerce namespace and only the Store API container is meant to be called.

  • Removing a hook from a container-built object without the object. remove_action() needs the same object; see the hooks page.

  • Two copies of Action Scheduler. Another plugin that bundles an older copy can load first and the whole store runs on that version; check the version with ActionScheduler_Versions as in the example.

What was and was not run

Run on the local lab store (WordPress 7.1.3, WooCommerce 11.2.1, PHP 8.3.36, MariaDB 11.8.9, WP-CLI 2.12.0 in Docker): the pocket-coffee-architecture plugin (activated, its wp pocket-coffee boot command, php -l and PHP_CodeSniffer with the WooCommerce-Core, WordPress-Extra and PHPCompatibilityWP standards, all clean); the listing of the callbacks WooCommerce attached to plugins_loaded, init and after_setup_theme; the container probe, with its two ContainerException messages; the Store API container instance check; the packages probe and the Action Scheduler version; the feature listing, the compatibility query and the late declare_compatibility() call, with and without WP_DEBUG; the admin-request simulation that shows WC()->cart as null; wp post-type list, wp taxonomy list, wp role list, wp db tables and the REST index; and a read of woocommerce.php, includes/class-woocommerce.php, src/Container.php, src/Packages.php, the runtime container and the feature controller of the 11.2.1 download.

Not run: the browser setup wizard, the Marketplace and QIT review of the example plugin, WooPayments and other paid extensions, a multisite installation, and the 11.3.0-beta.1 download (only the figures of its plugin header, the Action Scheduler version and the changelog were consulted). File counts were made with find on the downloaded plugin. The counts of services in init_hooks() are read from the source of 11.2.1 and will change with every release.