* This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController),
* but we wish to handle compatibility checking in a similar fashion to legacy features. The
* rational for setting legacy to true is therefore similar to that of the 'order_attribution'
* @see https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'is_experimental' => true,
'name' => __( 'Order Fulfillments', 'woocommerce' ),
'Enable the Order Fulfillments feature to manage order fulfillment and shipping.',
'enabled_by_default' => false,
'is_experimental' => false,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'mcp_integration' => array(
'name' => __( 'WooCommerce MCP', 'woocommerce' ),
'description' => $this->get_mcp_integration_description(),
'enabled_by_default' => false,
'is_experimental' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'destroy-empty-sessions' => array(
'name' => __( 'Clear Customer Sessions When Empty', 'woocommerce' ),
'[Performance] Removes session cookies for non-logged in customers when session data is empty, improving page caching performance. May cause compatibility issues with extensions that depend on the session cookie without using session data.',
'enabled_by_default' => false,
'is_experimental' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'agentic_checkout' => array(
'name' => __( 'Agentic Checkout API', 'woocommerce' ),
'Enable the Agentic Checkout API for AI-powered checkout experiences (e.g., ChatGPT). This adds REST API endpoints that allow AI agents to create and manage checkout sessions.',
'enabled_by_default' => false,
'is_experimental' => true,
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
PushNotifications::FEATURE_NAME => array(
'name' => __( 'Push Notifications', 'woocommerce' ),
'Enable push notifications for the WooCommerce mobile apps to receive order notifications and store updates.',
'enabled_by_default' => false,
'is_experimental' => true,
'skip_compatibility_checks' => false,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'rest_api_caching' => array(
'name' => __( 'REST API Caching', 'woocommerce' ),
'description' => sprintf(
/* translators: %1$s and %2$s are opening and closing <a> tags */
__( 'Enable backend caching and cache control headers for REST API responses via the <code>RestApiCache</code> trait. ⚙️ %1$sConfiguration%2$s', 'woocommerce' ),
'<a href="' . admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=rest_api_caching' ) . '">',
'enabled_by_default' => false,
'is_experimental' => true,
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
ProductCacheController::FEATURE_NAME => array(
'name' => __( 'Cache Product Objects', 'woocommerce' ),
'[Performance] Speeds up your store by caching product objects during each request, preventing duplicate product loads. Can improve page load times on product-heavy pages.',
'default_plugin_compatibility' => FeaturePluginCompatibility::INCOMPATIBLE,
'enabled_by_default' => false,
'is_experimental' => true,
'fraud_protection' => array(
'name' => __( 'Fraud protection', 'woocommerce' ),
'Enable fraud protection features for your store.',
'enabled_by_default' => false,
'is_experimental' => true,
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
if ( ! $tracking_enabled ) {
// Uncheck the remote logging feature when usage tracking is disabled.
$legacy_features['remote_logging']['setting']['value'] = 'no';
foreach ( $legacy_features as $slug => $definition ) {
$this->add_feature_definition( $slug, $definition['name'], $definition );
$this->init_compatibility_info_by_feature();
* Initialize the compatibility_info_by_feature property after all the features have been added.
private function init_compatibility_info_by_feature() {
foreach ( array_keys( $this->features ) as $feature_id ) {
if ( ! isset( $this->compatibility_info_by_feature[ $feature_id ] ) ) {
$this->compatibility_info_by_feature[ $feature_id ] = array(
FeaturePluginCompatibility::COMPATIBLE => array(),
FeaturePluginCompatibility::INCOMPATIBLE => array(),
* Generate the description for the MCP integration feature.
* @return string The feature description with conditional permalink warning and documentation link.
private function get_mcp_integration_description() {
$base_description = __( 'Enable WooCommerce MCP (Model Context Protocol) for AI-powered store operations. AI-generated results and actions can be unpredictable - please review before executing in your store.', 'woocommerce' );
// Check permalink structure requirement.
$permalink_structure = get_option( 'permalink_structure' );
if ( empty( $permalink_structure ) ) {
$permalinks_url = admin_url( 'options-permalink.php' );
$permalink_warning = sprintf(
'<br><br><strong>%s:</strong> %s <a href="%s">%s</a>',
__( 'Configuration Required', 'woocommerce' ),
__( 'WordPress permalinks must be set to anything other than "Plain" for MCP to work.', 'woocommerce' ),
__( 'Configure Permalinks', 'woocommerce' )
// Add documentation link to permalink warning.
$documentation_link = sprintf(
' <a href="%s" target="_blank">%s</a>',
'https://github.com/woocommerce/woocommerce/blob/trunk/docs/features/mcp/README.md',
__( 'Learn more', 'woocommerce' )
return $base_description . $permalink_warning . $documentation_link;
// Add documentation link.
$documentation_link = sprintf(
' <a href="%s" target="_blank">%s</a>',
'https://github.com/woocommerce/woocommerce/blob/trunk/docs/features/mcp/README.md',
__( 'Learn more', 'woocommerce' )
return $base_description . $documentation_link;
* Function to trigger the (now deprecated) 'woocommerce_register_feature_definitions' hook.
* This function must execute immediately before the 'before_woocommerce_init'
* action is fired, so that feature compatibility declarations happening
* in that action find all the features properly declared already.
public function register_additional_features() {
if ( $this->registered_additional_features_via_action ) {
if ( empty( $this->features ) ) {
$this->init_feature_definitions();
* The action for registering features.
* @param FeaturesController $features_controller The instance of FeaturesController.
* @deprecated 9.9.0 Features should be defined directly in get_feature_definitions.
do_action( 'woocommerce_register_feature_definitions', $this );
$this->init_compatibility_info_by_feature();
$this->registered_additional_features_via_action = true;
* Initialize the class instance.
* @param LegacyProxy $proxy The instance of LegacyProxy to use.
* @param PluginUtil $plugin_util The instance of PluginUtil to use.
final public function init( LegacyProxy $proxy, PluginUtil $plugin_util ) {
$this->plugin_util = $plugin_util;
$this->plugins_excluded_from_compatibility_ui = $plugin_util->get_plugins_excluded_from_compatibility_ui();
* Get all the existing WooCommerce features.
* Returns an associative array where keys are unique feature ids
* and values are arrays with these keys:
* - is_experimental (bool)
* - is_enabled (bool) (only if $include_enabled_info is passed as true)
* @param bool $include_experimental Include also experimental/work in progress features in the list.
* @param bool $include_enabled_info True to include the 'is_enabled' field in the returned features info.
* @returns array An array of information about existing features.
public function get_features( bool $include_experimental = false, bool $include_enabled_info = false ): array {
$features = $this->get_feature_definitions();
if ( ! $include_experimental ) {
$features = array_filter(
return ! $feature['is_experimental'];
if ( $include_enabled_info ) {
foreach ( array_keys( $features ) as $feature_id ) {
// For deprecated features, use the deprecated_value directly without triggering the deprecation notice.
// The deprecation notice should only fire for external code checking feature status, not for internal listing.
if ( ! empty( $features[ $feature_id ]['deprecated_since'] ) ) {
$is_enabled = (bool) ( $features[ $feature_id ]['deprecated_value'] ?? false );
$is_enabled = $this->feature_is_enabled( $feature_id );
$features[ $feature_id ]['is_enabled'] = $is_enabled;
// We're deprecating the product block editor feature in favor of a v3 coming out.
// We want to hide this setting in the UI for users that don't have it enabled.
// If users have it enabled, we won't hide it until they explicitly disable it.
if ( isset( $features['product_block_editor'] )
&& ! $this->feature_is_enabled( 'product_block_editor' ) ) {
$features['product_block_editor']['disable_ui'] = true;
* Get the default plugin compatibility for a given feature.
* @param string $feature_id Feature id to check.
* @return string Either 'compatible' or 'incompatible'.
* @throws \InvalidArgumentException If the feature doesn't exist.
public function get_default_plugin_compatibility( string $feature_id ): string {
$feature = $this->get_feature_definition( $feature_id );
if ( null === $feature ) {
throw new \InvalidArgumentException( esc_html( "The WooCommerce feature '$feature_id' doesn't exist" ) );
$default_plugin_compatibility = $feature['default_plugin_compatibility'] ?? FeaturePluginCompatibility::COMPATIBLE;
// Filter below is only fired for backwards compatibility with (now removed) get_plugins_are_incompatible_by_default().
* Filter to determine if plugins that don't declare compatibility nor incompatibility with a given feature
* are to be considered incompatible with that feature.
* @param bool $incompatible_by_default Default value, true if plugins are to be considered incompatible by default with the feature.
* @param string $feature_id The feature to check.
$incompatible_by_default = (bool) apply_filters( 'woocommerce_plugins_are_incompatible_with_feature_by_default', FeaturePluginCompatibility::INCOMPATIBLE === $default_plugin_compatibility, $feature_id );
return $incompatible_by_default ? FeaturePluginCompatibility::INCOMPATIBLE : FeaturePluginCompatibility::COMPATIBLE;
* Get the definition array for a specific feature.
* @param string $feature_id Unique feature id.
* @return array|null The feature definition array, or null if the feature doesn't exist.
public function get_feature_definition( string $feature_id ): ?array {
return $this->get_feature_definitions()[ $feature_id ] ?? null;
* Check if a given feature is currently enabled.
* Note: This method does not log deprecation notices for deprecated features.
* Deprecation logging is handled by FeaturesUtil::feature_is_enabled() which is the public API.
* @param string $feature_id Unique feature id.
* @return bool True if the feature is enabled, false if not or if the feature doesn't exist.
public function feature_is_enabled( string $feature_id ): bool {
$feature = $this->get_feature_definition( $feature_id );
if ( null === $feature ) {
// Handle deprecated features - return the backwards-compatible value.
if ( ! empty( $feature['deprecated_since'] ) ) {
return (bool) ( $feature['deprecated_value'] ?? false );
if ( $this->is_preview_email_improvements_enabled( $feature_id ) ) {
$default_value = $this->feature_is_enabled_by_default( $feature_id ) ? 'yes' : 'no';
$value = 'yes' === get_option( $this->feature_enable_option_name( $feature_id ), $default_value );
* Check if a given feature is enabled by default.
* @param string $feature_id Unique feature id.
* @return boolean TRUE if the feature is enabled by default, FALSE otherwise.
private function feature_is_enabled_by_default( string $feature_id ): bool {
$features = $this->get_feature_definitions();
return ! empty( $features[ $feature_id ]['enabled_by_default'] );
* Change the enabled/disabled status of a feature.
* @param string $feature_id Unique feature id.
* @param bool $enable True to enable the feature, false to disable it.
* @return bool True on success, false if feature doesn't exist or the new value is the same as the old value.
public function change_feature_enable( string $feature_id, bool $enable ): bool {
if ( ! $this->feature_exists( $feature_id ) ) {
return update_option( $this->feature_enable_option_name( $feature_id ), $enable ? 'yes' : 'no', 'on' );
* Declare (in)compatibility with a given feature for a given plugin.
* This method MUST be executed from inside a handler for the 'before_woocommerce_init' hook.
* The plugin name is expected to be in the form 'directory/file.php' and be one of the keys
* of the array returned by 'get_plugins', but this won't be checked. Plugins are expected to use
* FeaturesUtil::declare_compatibility instead, passing the full plugin file path instead of the plugin name.
* @param string $feature_id Unique feature id.
* @param string $plugin_file Plugin file path, either full or in the form 'directory/file.php'.
* @param bool $positive_compatibility True if the plugin declares being compatible with the feature, false if it declares being incompatible.
* @return bool True on success, false on error (feature doesn't exist or not inside the required hook).
* @throws \Exception A plugin attempted to declare itself as compatible and incompatible with a given feature at the same time.
public function declare_compatibility( string $feature_id, string $plugin_file, bool $positive_compatibility = true ): bool {
if ( ! $this->proxy->call_function( 'doing_action', 'before_woocommerce_init' ) ) {
$class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__;
/* translators: 1: class::method 2: before_woocommerce_init */
$this->proxy->call_function( 'wc_doing_it_wrong', $class_and_method, sprintf( __( '%1$s should be called inside the %2$s action.', 'woocommerce' ), $class_and_method, 'before_woocommerce_init' ), '7.0' );
if ( ! $this->feature_exists( $feature_id ) ) {
// Lazy mode: Queue to be normalized later.
$this->pending_declarations[] = array( $feature_id, $plugin_file, $positive_compatibility );
// Late call: Normalize and register immediately.
return $this->register_compatibility_internal( $feature_id, $plugin_file, $positive_compatibility );
* Registers compatibility information internally for a given feature and plugin file.
* This method normalizes the plugin file path to a plugin ID, handles validation and logging for invalid plugins,
* and registers the compatibility data if valid.
* It updates the internal compatibility arrays, checks for conflicts (e.g., a plugin declaring both
* compatible and incompatible with the same feature), and throws an exception if a conflict is detected.
* Duplicate declarations (same compatibility type) are ignored.
* This is an internal helper method and should not be called directly.
* @internal For usage by WooCommerce core only. Backwards compatibility not guaranteed.
* @param string $feature_id Unique feature ID.
* @param string $plugin_file Raw plugin file path (full or 'directory/file.php').
* @param bool $positive_compatibility True if declaring compatibility, false if declaring incompatibility.
* @return bool True on successful registration, false if the feature does not exist.
* @throws \Exception If the plugin attempts to declare both compatibility and incompatibility for the same feature.
private function register_compatibility_internal( string $feature_id, string $plugin_file, bool $positive_compatibility ): bool {
if ( ! $this->feature_exists( $feature_id ) ) {
// Normalize and validate plugin file.
$plugin_id = $this->plugin_util->get_wp_plugin_id( $plugin_file );
$logger = $this->proxy->call_function( 'wc_get_logger' );
$logger->error( "FeaturesController: Invalid plugin file '{$plugin_file}' for feature '{$feature_id}'." );
// Register compatibility by plugin.
ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin, $plugin_id );
$key = $positive_compatibility ? FeaturePluginCompatibility::COMPATIBLE : FeaturePluginCompatibility::INCOMPATIBLE;
$opposite_key = $positive_compatibility ? FeaturePluginCompatibility::INCOMPATIBLE : FeaturePluginCompatibility::COMPATIBLE;
ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin[ $plugin_id ], $key );
ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin[ $plugin_id ], $opposite_key );
if ( in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_id ][ $opposite_key ], true ) ) {
throw new \Exception( esc_html( "Plugin $plugin_id is trying to declare itself as $key with the '$feature_id' feature, but it already declared itself as $opposite_key" ) );
if ( ! in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_id ][ $key ], true ) ) {
$this->compatibility_info_by_plugin[ $plugin_id ][ $key ][] = $feature_id;
// Register compatibility by feature.
$key = $positive_compatibility ? FeaturePluginCompatibility::COMPATIBLE : FeaturePluginCompatibility::INCOMPATIBLE;
if ( ! in_array( $plugin_id, $this->compatibility_info_by_feature[ $feature_id ][ $key ], true ) ) {
$this->compatibility_info_by_feature[ $feature_id ][ $key ][] = $plugin_id;
* Processes any pending compatibility declarations by normalizing plugin file paths
* and registering them internally.
* This method is called lazily when compatibility information is queried (via
* get_compatible_features_for_plugin() or get_compatible_plugins_for_feature()).
* It resolves plugin IDs using PluginUtil and logs errors for unrecognized plugins.
* Pending declarations are cleared after processing to avoid redundant work.
* @internal For usage by WooCommerce core only. Backwards compatibility not guaranteed.
private function process_pending_declarations(): void {
if ( empty( $this->pending_declarations ) ) {
foreach ( $this->pending_declarations as $declaration ) {
list( $feature_id, $plugin_file, $positive_compatibility ) = $declaration;
$this->register_compatibility_internal( $feature_id, $plugin_file, $positive_compatibility );
$this->pending_declarations = array();
* Check whether a feature exists with a given id.
* @param string $feature_id The feature id to check.
* @return bool True if the feature exists.