private function feature_exists( string $feature_id ): bool {
$features = $this->get_feature_definitions();
return isset( $features[ $feature_id ] );
* Get the ids of the features that a certain plugin has declared compatibility for.
* This method can't be called before the 'woocommerce_init' hook is fired.
* @param string $plugin_name Plugin name, in the form 'directory/file.php'.
* @param bool $enabled_features_only True to return only names of enabled plugins.
* @param bool $resolve_uncertain True to resolve the uncertain features to compatible or incompatible.
* @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of feature ids.
public function get_compatible_features_for_plugin( string $plugin_name, bool $enabled_features_only = false, bool $resolve_uncertain = false ): array {
$this->process_pending_declarations();
$this->verify_did_woocommerce_init( __FUNCTION__ );
$features = $this->get_feature_definitions();
if ( $enabled_features_only ) {
$features = array_filter(
array( $this, 'feature_is_enabled' ),
if ( ! isset( $this->compatibility_info_by_plugin[ $plugin_name ] ) ) {
FeaturePluginCompatibility::COMPATIBLE => array(),
FeaturePluginCompatibility::INCOMPATIBLE => array(),
FeaturePluginCompatibility::UNCERTAIN => array_keys( $features ),
$info = $this->compatibility_info_by_plugin[ $plugin_name ];
$info[ FeaturePluginCompatibility::COMPATIBLE ] = array_values( array_intersect( array_keys( $features ), $info[ FeaturePluginCompatibility::COMPATIBLE ] ) );
$info[ FeaturePluginCompatibility::INCOMPATIBLE ] = array_values( array_intersect( array_keys( $features ), $info[ FeaturePluginCompatibility::INCOMPATIBLE ] ) );
$info[ FeaturePluginCompatibility::UNCERTAIN ] = array_values( array_diff( array_keys( $features ), $info[ FeaturePluginCompatibility::COMPATIBLE ], $info[ FeaturePluginCompatibility::INCOMPATIBLE ] ) );
if ( $resolve_uncertain ) {
foreach ( $info[ FeaturePluginCompatibility::UNCERTAIN ] as $feature_id ) {
$key = $this->get_default_plugin_compatibility( $feature_id );
$info[ $key ][] = $feature_id;
$info[ FeaturePluginCompatibility::UNCERTAIN ] = array();
* Get the names of the plugins that have been declared compatible or incompatible with a given feature.
* @param string $feature_id Feature id.
* @param bool $active_only True to return only active plugins.
* @param bool $resolve_uncertain True to resolve the uncertain plugins to compatible or incompatible.
* @return array An array having a 'compatible', an 'incompatible' and an 'uncertain' key, each holding an array of plugin names.
public function get_compatible_plugins_for_feature( string $feature_id, bool $active_only = false, bool $resolve_uncertain = false ): array {
$this->process_pending_declarations();
$this->verify_did_woocommerce_init( __FUNCTION__ );
$woo_aware_plugins = $this->plugin_util->get_woocommerce_aware_plugins( $active_only );
if ( ! $this->feature_exists( $feature_id ) ) {
FeaturePluginCompatibility::COMPATIBLE => array(),
FeaturePluginCompatibility::INCOMPATIBLE => array(),
FeaturePluginCompatibility::UNCERTAIN => $woo_aware_plugins,
$info = $this->compatibility_info_by_feature[ $feature_id ];
ArrayUtil::ensure_key_is_array( $info, FeaturePluginCompatibility::UNCERTAIN );
// Resolve uncertain plugin compatibility?
$uncertain_plugins = array_values( array_diff( $woo_aware_plugins, $info[ FeaturePluginCompatibility::COMPATIBLE ], $info[ FeaturePluginCompatibility::INCOMPATIBLE ] ) );
$key = $resolve_uncertain ? $this->get_default_plugin_compatibility( $feature_id ) : FeaturePluginCompatibility::UNCERTAIN;
$info[ $key ] = array_merge( $info[ $key ], $uncertain_plugins );
* Check if the 'woocommerce_init' has run or is running, do a 'wc_doing_it_wrong' if not.
* @param string|null $function_name Name of the invoking method, if not null, 'wc_doing_it_wrong' will be invoked if 'woocommerce_init' has not run and is not running.
* @return bool True if 'woocommerce_init' has run or is running, false otherwise.
private function verify_did_woocommerce_init( ?string $function_name = null ): bool {
if ( ! $this->proxy->call_function( 'did_action', 'woocommerce_init' ) &&
! $this->proxy->call_function( 'doing_action', 'woocommerce_init' ) ) {
if ( ! is_null( $function_name ) ) {
$class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . $function_name;
/* translators: 1: class::method 2: plugins_loaded */
$this->proxy->call_function( 'wc_doing_it_wrong', $class_and_method, sprintf( __( '%1$s should not be called before the %2$s action.', 'woocommerce' ), $class_and_method, 'woocommerce_init' ), '7.0' );
* Get the name of the option that enables/disables a given feature.
* Note that it doesn't check if the feature actually exists. Instead it
* defaults to "woocommerce_feature_{$feature_id}_enabled" if a different
* name isn't specified in the feature registration.
* @param string $feature_id The id of the feature.
* @return string The option that enables or disables the feature.
public function feature_enable_option_name( string $feature_id ): string {
$features = $this->get_feature_definitions();
if ( ! empty( $features[ $feature_id ]['option_key'] ) ) {
return $features[ $feature_id ]['option_key'];
return "woocommerce_feature_{$feature_id}_enabled";
* Check if the compatibility checks should be skipped for a given feature.
* @param string $feature_id The feature id to check.
* @return bool TRUE if the compatibility checks should be skipped.
public function should_skip_compatibility_checks( string $feature_id ): bool {
$features = $this->get_feature_definitions();
return ! empty( $features[ $feature_id ]['skip_compatibility_checks'] );
* Sets a flag indicating that it's allowed to enable features for which incompatible plugins are active
* from the WooCommerce feature settings page.
public function allow_enabling_features_with_incompatible_plugins(): void {
$this->force_allow_enabling_features = true;
* Sets a flag indicating that it's allowed to activate plugins for which incompatible features are enabled
* from the WordPress plugins page.
public function allow_activating_plugins_with_incompatible_features(): void {
$this->force_allow_enabling_plugins = true;
* Adds our callbacks for the `updated_option` and `added_option` filter hooks.
* We delay adding these hooks until `init`, because both callbacks need to load our list of feature definitions,
* and building that list requires translating various strings (which should not be done earlier than `init`).
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
public function start_listening_for_option_changes(): void {
add_filter( 'updated_option', array( $this, 'process_updated_option' ), 999, 3 );
add_filter( 'added_option', array( $this, 'process_added_option' ), 999, 3 );
* Handler for the 'added_option' hook.
* It fires FEATURE_ENABLED_CHANGED_ACTION when a feature is enabled or disabled.
* @param string $option The option that has been created.
* @param mixed $value The value of the option.
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
public function process_added_option( string $option, $value ) {
$this->process_updated_option( $option, false, $value );
* Handler for the 'updated_option' hook.
* It fires FEATURE_ENABLED_CHANGED_ACTION when a feature is enabled or disabled.
* @param string $option The option that has been modified.
* @param mixed $old_value The old value of the option.
* @param mixed $value The new value of the option.
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
public function process_updated_option( string $option, $old_value, $value ) {
$is_default_key = preg_match( '/^woocommerce_feature_([a-zA-Z0-9_]+)_enabled$/', $option, $matches );
$features_with_custom_keys = array_filter(
$this->get_feature_definitions(),
return ! empty( $feature['option_key'] );
$custom_keys = wp_list_pluck( $features_with_custom_keys, 'option_key' );
if ( ! $is_default_key && ! in_array( $option, $custom_keys, true ) ) {
if ( $value === $old_value ) {
$feature_id = $matches[1];
} elseif ( in_array( $option, $custom_keys, true ) ) {
$feature_id = array_search( $option, $custom_keys, true );
self::FEATURE_ENABLED_CHANGED_ACTION,
'feature_id' => $feature_id,
* Action triggered when a feature is enabled or disabled (the value of the corresponding setting option is changed).
* @param string $feature_id The id of the feature.
* @param bool $enabled True if the feature has been enabled, false if it has been disabled.
do_action( self::FEATURE_ENABLED_CHANGED_ACTION, $feature_id, 'yes' === $value );
* Handler for the 'woocommerce_get_sections_advanced' hook,
* it adds the "Features" section to the advanced settings page.
* @param array $sections The original sections array.
* @return array The updated sections array.
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
public function add_features_section( $sections ) {
if ( ! isset( $sections['features'] ) ) {
$sections['features'] = __( 'Features', 'woocommerce' );
* Handler for the 'woocommerce_get_settings_advanced' hook,
* it adds the settings UI for all the existing features.
* Note that the settings added via the 'woocommerce_settings_features' hook will be
* displayed in the non-experimental features section.
* @param array $settings The existing settings for the corresponding settings section.
* @param string $current_section The section to get the settings for.
* @return array The updated settings array.
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
public function add_feature_settings( $settings, $current_section ): array {
if ( 'features' !== $current_section ) {
$feature_settings = array(
'title' => __( 'Features', 'woocommerce' ),
'desc' => __( 'Start using new features that are being progressively rolled out to improve the store management experience.', 'woocommerce' ),
'id' => 'features_options',
$features = $this->get_features( true );
$feature_ids = array_keys( $features );
function ( $feature_id_a, $feature_id_b ) use ( $features ) {
return ( $features[ $feature_id_b ]['order'] ?? 0 ) <=> ( $features[ $feature_id_a ]['order'] ?? 0 );
$experimental_feature_ids = array_filter(
function ( $feature_id ) use ( $features ) {
return $features[ $feature_id ]['is_experimental'] ?? false;
$mature_feature_ids = array_diff( $feature_ids, $experimental_feature_ids );
$feature_ids = array_merge( $mature_feature_ids, array( 'mature_features_end' ), $experimental_feature_ids );
foreach ( $feature_ids as $id ) {
if ( 'mature_features_end' === $id ) {
// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
* Filter allowing to add additional settings to the WooCommerce Advanced - Features settings page.
* @param bool $disabled False.
$feature_settings = apply_filters( 'woocommerce_settings_features', $feature_settings );
// phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment
if ( ! empty( $experimental_feature_ids ) ) {
$feature_settings[] = array(
'id' => 'features_options',
$feature_settings[] = array(
'title' => __( 'Experimental features', 'woocommerce' ),
'desc' => __( 'These features are either experimental or incomplete, enable them at your own risk!', 'woocommerce' ),
'id' => 'experimental_features_options',
if ( 'new_navigation' === $id && 'yes' !== get_option( $this->feature_enable_option_name( $id ), 'no' ) ) {
if ( isset( $features[ $id ]['disable_ui'] ) && $features[ $id ]['disable_ui'] ) {
$feature_settings[] = $this->get_setting_for_feature( $id, $features[ $id ] );
$additional_settings = $features[ $id ]['additional_settings'] ?? array();
if ( count( $additional_settings ) > 0 ) {
$feature_settings = array_merge( $feature_settings, $additional_settings );
$feature_settings[] = array(
'id' => empty( $experimental_feature_ids ) ? 'features_options' : 'experimental_features_options',
if ( $this->verify_did_woocommerce_init() ) {
// Allow feature setting properties to be determined dynamically just before being rendered.
$feature_settings = array_map(
function ( $feature_setting ) {
foreach ( $feature_setting as $prop => $value ) {
if ( is_callable( $value ) ) {
$feature_setting[ $prop ] = call_user_func( $value );
return $feature_settings;
* Get the parameters to display the setting enable/disable UI for a given feature.
* @param string $feature_id The feature id.
* @param array $feature The feature parameters, as returned by get_features.
* @return array The parameters to add to the settings array.
private function get_setting_for_feature( string $feature_id, array $feature ): array {
$description = $feature['description'] ?? '';
$tooltip = $feature['tooltip'] ?? '';
$type = $feature['type'] ?? 'checkbox';
$setting_definition = $feature['setting'] ?? array();
// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
* Filter allowing WooCommerce Admin to be disabled.
* @param bool $disabled False.
$admin_features_disabled = apply_filters( 'woocommerce_admin_disabled', false );
// phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment
if ( ( 'analytics' === $feature_id || 'new_navigation' === $feature_id ) && $admin_features_disabled ) {
$desc_tip = __( 'WooCommerce Admin has been disabled', 'woocommerce' );
} elseif ( 'new_navigation' === $feature_id ) {
// translators: 1: line break tag.
'%1$s This navigation will soon become unavailable while we make necessary improvements.
If you turn it off now, you will not be able to turn it back on.',
$needs_update = version_compare( get_bloginfo( 'version' ), '5.6', '<' );
if ( $needs_update && current_user_can( 'update_core' ) && current_user_can( 'update_php' ) ) {
// translators: 1: line break tag, 2: open link to WordPress update link, 3: close link tag.
__( '%1$s %2$sUpdate WordPress to enable the new navigation%3$s', 'woocommerce' ),
'<a href="' . self_admin_url( 'update-core.php' ) . '" target="_blank">',
if ( ! empty( $update_text ) ) {
$description .= $update_text;
if ( ! $this->should_skip_compatibility_checks( $feature_id ) && ! $disabled && $this->verify_did_woocommerce_init() ) {
$plugin_info_for_feature = $this->get_compatible_plugins_for_feature( $feature_id, true );
$desc_tip = $this->plugin_util->generate_incompatible_plugin_feature_warning( $feature_id, $plugin_info_for_feature );
* Filter to customize the description tip that appears under the description of each feature in the features settings page.
* @param string $desc_tip The original description tip.
* @param string $feature_id The id of the feature for which the description tip is being customized.
* @param bool $disabled True if the UI currently prevents changing the enable/disable status of the feature.
* @return string The new description tip to use.
$desc_tip = apply_filters( 'woocommerce_feature_description_tip', $desc_tip, $feature_id, $disabled );
$feature_setting_defaults = array(
'title' => $feature['name'],
'id' => $this->feature_enable_option_name( $feature_id ),
'disabled' => $disabled && ! $this->force_allow_enabling_features,
'default' => $this->feature_is_enabled_by_default( $feature_id ) ? 'yes' : 'no',
$feature_setting = wp_parse_args( $setting_definition, $feature_setting_defaults );
if ( ! empty( $feature['learn_more_url'] ) ) {
$feature_setting['desc'] .= sprintf(
'<span class="learn-more-link"><a href="%s" target="_blank">%s</a></span>',
esc_attr( $feature['learn_more_url'] ),
esc_html__( 'Learn more', 'woocommerce' )
* Allows to modify feature setting that will be used to render in the feature page.
* @param array $feature_setting The feature setting. Describes the feature:
* - title: The title of the feature.
* - desc: The description of the feature. Will be displayed under the title.
* - type: The type of the feature. Could be any of supported settings types from `WC_Admin_Settings::output_fields`, but if it's anything other than checkbox or radio, it will need custom handling.
* - id: The id of the feature. Will be used as the name of the setting.
* - disabled: Whether the feature is disabled or not.
* - desc_tip: The description tip of the feature. Will be displayed as a tooltip next to the description.
* - tooltip: The tooltip of the feature. Will be displayed as a tooltip next to the name.
* - default: The default value of the feature.
* @param string $feature_id The id of the feature.
return apply_filters( 'woocommerce_feature_setting', $feature_setting, $feature_id );
* Handle the plugin deactivation hook.
* @param string $plugin_name Name of the plugin that has been deactivated.
* @internal For exclusive usage of WooCommerce core, backwards compatibility not guaranteed.
public function handle_plugin_deactivation( $plugin_name ): void {
unset( $this->compatibility_info_by_plugin[ $plugin_name ] );
foreach ( array_keys( $this->compatibility_info_by_feature ) as $feature ) {