* FeaturesController class file
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Features;
use Automattic\WooCommerce\Internal\Admin\EmailPreview\EmailPreview;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Admin\Analytics;
use Automattic\WooCommerce\Internal\Caches\ProductCacheController;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CostOfGoodsSoldController;
use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
use Automattic\WooCommerce\Utilities\PluginUtil;
use Automattic\WooCommerce\Enums\FeaturePluginCompatibility;
defined( 'ABSPATH' ) || exit;
* Class to define the WooCommerce features that can be enabled and disabled by admin users,
* provides also a mechanism for WooCommerce plugins to declare that they are compatible
* (or incompatible) with a given feature.
* Note: the 'woocommerce_register_feature_definitions' hook allows registering new features
* externally. This hook is deprecated, features should be registered from within get_feature_definitions.
* However, in case you use it for testing purposes, keep in mind that the hook is fired from inside 'init';
* therefore, features that need to be queried, enabled, or disabled before 'init' (e.g. during WP CLI initialization)
* can't be registered using the hook.
class FeaturesController {
public const FEATURE_ENABLED_CHANGED_ACTION = 'woocommerce_feature_enabled_changed';
public const PLUGINS_COMPATIBLE_BY_DEFAULT_OPTION = 'woocommerce_plugins_are_compatible_with_features_by_default';
* The existing feature definitions.
private $features = array();
* The registered compatibility info for WooCommerce plugins, with plugin names as keys.
private $compatibility_info_by_plugin = array();
* The registered compatibility info for WooCommerce plugins, with feature ids as keys.
private $compatibility_info_by_feature = array();
* Pending compatibility declarations. Format is [feature_id, plugin_file, positive_compatibility].
private $pending_declarations = array();
* The LegacyProxy instance to use.
* The PluginUtil instance to use.
* Flag indicating that features will be enableable from the settings page
* even when they are incompatible with active plugins.
private $force_allow_enabling_features = false;
* Flag indicating that plugins will be activable from the plugins page
* even when they are incompatible with enabled features.
private $force_allow_enabling_plugins = false;
* List of plugins excluded from feature compatibility warnings in UI.
private $plugins_excluded_from_compatibility_ui;
* Flag indicating if additional features have been registered already
* via woocommerce_register_feature_definitions action.
private bool $registered_additional_features_via_action = false;
* Flag indicating if additional features have been registered already
* via calls to other classes.
private bool $registered_additional_features_via_class_calls = false;
* Flag indicating if we are currently delaying plugin normalization.
private bool $lazy = true;
* Creates a new instance of the class.
public function __construct() {
// In principle, register_additional_features is triggered manually from within class-woocommerce
// right before before_woocommerce_init is fired (this is needed for the features to be visible
// to plugins executing declare_compatibility).
// However we add additional checks/hookings here to support unit tests and possible overlooked/future
// DI container/class instantiation nuances.
if ( ! $this->registered_additional_features_via_action ) {
if ( did_action( 'before_woocommerce_init' ) ) {
// Needed for unit tests, where 'before_woocommerce_init' will have been fired already at this point.
$this->register_additional_features();
// This needs to have a higher $priority than the 'before_woocommerce_init' hooked by plugins that declare compatibility.
add_filter( 'before_woocommerce_init', array( $this, 'register_additional_features' ), -9999, 0 );
if ( did_action( 'init' ) ) {
// Needed for unit tests, where 'init' will have been fired already at this point.
$this->start_listening_for_option_changes();
add_filter( 'init', array( $this, 'start_listening_for_option_changes' ), 10, 0 );
add_filter( 'woocommerce_get_sections_advanced', array( $this, 'add_features_section' ), 10, 1 );
add_filter( 'woocommerce_get_settings_advanced', array( $this, 'add_feature_settings' ), 10, 2 );
add_filter( 'deactivated_plugin', array( $this, 'handle_plugin_deactivation' ), 10, 1 );
add_filter( 'all_plugins', array( $this, 'filter_plugins_list' ), 10, 1 );
add_action( 'admin_notices', array( $this, 'display_notices_in_plugins_page' ), 10, 0 );
add_action( 'load-plugins.php', array( $this, 'maybe_invalidate_cached_plugin_data' ) );
add_action( 'after_plugin_row', array( $this, 'handle_plugin_list_rows' ), 10, 2 );
add_action( 'current_screen', array( $this, 'enqueue_script_to_fix_plugin_list_html' ), 10, 1 );
add_filter( 'views_plugins', array( $this, 'handle_plugins_page_views_list' ), 10, 1 );
add_filter( 'woocommerce_admin_shared_settings', array( $this, 'set_change_feature_enable_nonce' ), 20, 1 );
add_action( 'admin_init', array( $this, 'change_feature_enable_from_query_params' ), 20, 0 );
add_action( self::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'display_email_improvements_feedback_notice' ), 10, 2 );
* This used to be called during the `woocommerce_register_feature_definitions` action hook,
* now it's called directly from get_feature_definitions as needed.
* @param string $slug The ID slug of the feature.
* @param string $name The name of the feature that will appear on the Features screen and elsewhere.
* Properties that make up the feature definition. Each of these properties can also be set as a
* callback function, as long as that function returns the specified type.
* @type string $default_plugin_compatibility The default plugin compatibility for the feature: either 'compatible' or 'incompatible'. Required.
* @type array[] $additional_settings An array of definitions for additional settings controls related to
* the feature that will display on the Features screen. See the Settings API
* for the schema of these props.
* @type string $description A brief description of the feature, used as an input label if the feature
* @type bool $disabled True to disable the setting field for this feature on the Features screen,
* so it can't be changed.
* @type bool $disable_ui Set to true to hide the setting field for this feature on the
* Features screen. Defaults to false.
* @type bool $enabled_by_default Set to true to have this feature by opt-out instead of opt-in.
* @type bool $is_experimental Set to true to display this feature under the "Experimental" heading on
* the Features screen. Features set to experimental are also omitted from
* the features list in some cases. Defaults to true.
* @type bool $skip_compatibility_checks Set to true if the feature should not produce warnings about incompatible plugins.
* @type string $learn_more_url The URL to the learn more page for the feature.
* @type string $option_key The key name for the option that enables/disables the feature.
* @type int $order The order that the feature will appear in the list on the Features screen.
* Higher number = higher in the list. Defaults to 10.
* @type array $setting The properties used by the Settings API to render the setting control on
* the Features screen. See the Settings API for the schema of these props.
* @type string $deprecated_since The WooCommerce version since which this feature is deprecated.
* When set, feature_is_enabled() will force feature value to the deprecated_value
* instead of reading from the database.
* @type bool $deprecated_value The value to return for deprecated features when feature_is_enabled()
* is called. Defaults to false.
public function add_feature_definition( $slug, $name, array $args = array() ) {
'enabled_by_default' => false,
'is_experimental' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'skip_compatibility_checks' => false,
if ( empty( $args['default_plugin_compatibility'] ) ) {
'Assuming positive compatibility by default will be deprecated in the future. Please set \'default_plugin_compatibility\' for feature "%s".',
$args = wp_parse_args( $args, $defaults );
// Sanitize 'default_plugin_compatibility'.
if ( ! in_array( $args['default_plugin_compatibility'], FeaturePluginCompatibility::VALID_REGISTRATION_VALUES, true ) ) {
$args['default_plugin_compatibility'] = wc_string_to_bool( $args['default_plugin_compatibility'] ) ? FeaturePluginCompatibility::COMPATIBLE : FeaturePluginCompatibility::INCOMPATIBLE;
// Support 'is_legacy' flag for backwards compatibility.
if ( ! empty( $args['is_legacy'] ) ) {
$args['skip_compatibility_checks'] = true;
$this->features[ $slug ] = $args;
* Generate and cache the feature definitions.
private function get_feature_definitions() {
if ( empty( $this->features ) ) {
$this->init_feature_definitions();
if ( ! $this->registered_additional_features_via_class_calls ) {
// This needs to be set to true *before* additional feature definition calls are made,
// to prevent infinite loops in case one of these calls ends up calling here again.
$this->registered_additional_features_via_class_calls = true;
// Additional feature definitions.
// These used to be tied to the now deprecated woocommerce_register_feature_definitions action,
// and aren't processed in init_feature_definitions to avoid circular calls in the dependency injection container.
$container = wc_get_container();
$container->get( CustomOrdersTableController::class )->add_feature_definition( $this );
$container->get( CostOfGoodsSoldController::class )->add_feature_definition( $this );
$this->init_compatibility_info_by_feature();
* Initialize the hardcoded feature definitions array.
* - Features that get initialized via the (deprecated) woocommerce_register_feature_definitions.
* - Features whose definition comes from another class. These are initialized directly in get_feature_definitions
* to avoid circular calls in the dependency injection container.
private function init_feature_definitions(): void {
$alpha_feature_testing_is_enabled = Constants::is_true( 'WOOCOMMERCE_ENABLE_ALPHA_FEATURE_TESTING' );
$tracking_enabled = WC_Site_Tracking::is_tracking_enabled();
$legacy_features = array(
'name' => __( 'Analytics', 'woocommerce' ),
'description' => __( 'Enable WooCommerce Analytics', 'woocommerce' ),
'option_key' => Analytics::TOGGLE_OPTION_NAME,
'is_experimental' => false,
'enabled_by_default' => true,
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'product_block_editor' => array(
'name' => __( 'New product editor', 'woocommerce' ),
'description' => __( 'Try the new product editor (Beta)', 'woocommerce' ),
'is_experimental' => true,
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'cart_checkout_blocks' => array(
'name' => __( 'Cart & Checkout Blocks', 'woocommerce' ),
'description' => __( 'Optimize for faster checkout', 'woocommerce' ),
'is_experimental' => false,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'rate_limit_checkout' => array(
'name' => __( 'Rate limit Checkout', 'woocommerce' ),
'description' => sprintf(
// translators: %s is the URL to the rate limiting documentation.
__( 'Enables rate limiting for Checkout place order and Store API /checkout endpoint. To further control this, refer to <a href="%s" target="_blank">rate limiting documentation</a>.', 'woocommerce' ),
'https://developer.woocommerce.com/docs/apis/store-api/rate-limiting/'
'is_experimental' => false,
'enabled_by_default' => false,
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'name' => __( 'Marketplace', 'woocommerce' ),
'New, faster way to find extensions and themes for your WooCommerce store',
'is_experimental' => false,
'enabled_by_default' => true,
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'deprecated_since' => '10.5.0',
'deprecated_value' => true,
// Marked as a legacy feature to avoid compatibility checks, which aren't really relevant to this feature.
// https://github.com/woocommerce/woocommerce/pull/39701#discussion_r1376976959.
'order_attribution' => array(
'name' => __( 'Order Attribution', 'woocommerce' ),
'Enable this feature to track and credit channels and campaigns that contribute to orders on your site',
'enabled_by_default' => true,
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'is_experimental' => false,
'site_visibility_badge' => array(
'name' => __( 'Site visibility badge', 'woocommerce' ),
'Enable the site visibility badge in the WordPress admin bar',
'enabled_by_default' => true,
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'is_experimental' => false,
'hpos_fts_indexes' => array(
'name' => __( 'HPOS Full text search indexes', 'woocommerce' ),
'Create and use full text search indexes for orders. This feature only works with high-performance order storage.',
'is_experimental' => true,
'enabled_by_default' => false,
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'option_key' => CustomOrdersTableController::HPOS_FTS_INDEX_OPTION,
'hpos_datastore_caching' => array(
'name' => __( 'HPOS Data Caching', 'woocommerce' ),
'Enable order data caching in the datastore. This feature only works with high-performance order storage and is recommended for stores using object caching.',
'is_experimental' => false,
'enabled_by_default' => false,
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'option_key' => CustomOrdersTableController::HPOS_DATASTORE_CACHING_ENABLED_OPTION,
'remote_logging' => array(
'name' => __( 'Remote Logging', 'woocommerce' ),
'description' => sprintf(
/* translators: %1$s: opening link tag, %2$s: closing link tag */
__( 'Allow WooCommerce to send error logs and non-sensitive diagnostic data to help improve WooCommerce. This feature requires %1$susage tracking%2$s to be enabled.', 'woocommerce' ),
'<a href="' . admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=woocommerce_com' ) . '">',
'enabled_by_default' => true,
* 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' => false,
'disabled' => function () use ( $tracking_enabled ) {
return ! $tracking_enabled;
'desc_tip' => function () use ( $tracking_enabled ) {
if ( ! $tracking_enabled ) {
return __( '⚠ Usage tracking must be enabled to use remote logging.', 'woocommerce' );
'email_improvements' => array(
'name' => __( 'Email improvements', 'woocommerce' ),
'Enable modern email design for transactional emails',
* This is not truly a legacy feature (it is not a feature that pre-dates the FeaturesController),
* but as this feature doesn't affect all extensions, and the rollout is fairly short,
* we'll skip the compatibility check by marking this as legacy. This is a workaround until
* we can implement a more sophisticated compatibility checking system.
* @see https://github.com/woocommerce/woocommerce/issues/39147
* @see https://github.com/woocommerce/woocommerce/issues/55540
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
'is_experimental' => false,
'name' => __( 'Blueprint (beta)', 'woocommerce' ),
'Enable blueprint to import and export settings in bulk',
'enabled_by_default' => true,
* 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' => false,
'block_email_editor' => array(
'name' => __( 'Block Email Editor (alpha)', 'woocommerce' ),
'Enable the block-based email editor for transactional emails.',
'learn_more_url' => 'https://github.com/woocommerce/woocommerce/discussions/52897#discussioncomment-11630256',
* 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,
'enabled_by_default' => false,
'point_of_sale' => array(
'name' => __( 'Point of Sale', 'woocommerce' ),
'Enable Point of Sale functionality in the WooCommerce mobile apps.',
'enabled_by_default' => true,