Commit c0e7313476f for woocommerce

commit c0e7313476fded7e4b0b0d5a5d4761bdfc98c34a
Author: Néstor Soriano <konamiman@konamiman.com>
Date:   Wed Jul 22 02:33:11 2026 +0200

    Make the dual API usable for plugins without the feature flag enabled (#66706)

diff --git a/docs/apis/dual-api/README.md b/docs/apis/dual-api/README.md
index d2e86757d0d..005cfa21d74 100644
--- a/docs/apis/dual-api/README.md
+++ b/docs/apis/dual-api/README.md
@@ -23,14 +23,14 @@ This dual API, both the infrastructure and the proof of concept code API, has be

 ## Requirements

-- **PHP 8.1+.** The code API uses enums, named arguments, and PHP 8 attributes. On PHP 8.0 or older the GraphQL endpoint is not registered.
-- **The `dual_code_graphql_api` feature flag.** It is hidden (not shown on the Features settings page). Enable it with:
+- **PHP 8.1+.** The code API uses enums, named arguments, and PHP 8 attributes. On PHP 8.0 or older no GraphQL endpoint is registered.
+- **The `dual_code_graphql_api` feature flag, for WooCommerce core's own endpoint only.** It is hidden (not shown on the Features settings page). Enable it with:

     ```bash
     wp option update woocommerce_feature_dual_code_graphql_api_enabled yes
     ```

-When the flag is off, no GraphQL route is registered. This gates **every** dual-API endpoint, the one in WooCommerce core **and** any registered by plugins. Code that touches the code API classes directly should guard on `FeaturesUtil::feature_is_enabled( 'dual_code_graphql_api' )`. The settings and filters are likewise site-wide and shared across all dual-API endpoints (see [Settings and caching](./caching-and-settings.md#scope-what-applies-where)).
+The flag gates **only** WooCommerce core's `/wc/graphql` endpoint (and its proof-of-concept code API). Endpoints registered by plugins via `Main::register_graphql_endpoint()` don't require it: they are registered whenever the required infrastructure is available (see the requirements above), regardless of the flag state. A plugin that wants an on/off switch for its endpoint provides its own mechanism, or checks the flag itself (see [Gating your endpoint](./creating-a-dual-api-in-a-plugin.md#gating-your-endpoint)). Code that touches WooCommerce core's code API classes directly should still guard on `FeaturesUtil::feature_is_enabled( 'dual_code_graphql_api' )`. The settings and filters are site-wide and shared across all dual-API endpoints (see [Settings and caching](./caching-and-settings.md#scope-what-applies-where)).

 ## Which document do I need?

diff --git a/docs/apis/dual-api/caching-and-settings.md b/docs/apis/dual-api/caching-and-settings.md
index cbe663f7b70..2ba22cd772a 100644
--- a/docs/apis/dual-api/caching-and-settings.md
+++ b/docs/apis/dual-api/caching-and-settings.md
@@ -6,7 +6,7 @@ sidebar_position: 6

 # Settings and caching

-WooCommerce core's GraphQL endpoint is configured under **WooCommerce → Settings → Advanced → GraphQL**. The section appears only when the `dual_code_graphql_api` feature flag is on.
+WooCommerce core's GraphQL endpoint is configured under **WooCommerce → Settings → Advanced → GraphQL**. The section appears when some dual-API endpoint is active on the site: WooCommerce core's (the `dual_code_graphql_api` feature flag is on) or at least one registered by a plugin. The **Endpoint URL** setting, which only configures core's endpoint, is shown only when the feature flag is on.

 These settings are **site-wide, not per-endpoint**: every setting below except **Endpoint URL** applies to *every* dual-API endpoint on the site, including those registered by plugins. See [Scope: what applies where](#scope-what-applies-where).

@@ -29,7 +29,7 @@ The depth and complexity metrics are observable on a request by appending `?_deb

 The dual API has one set of switches and filters shared by every endpoint on the site, there is no per-plugin configuration surface. Concretely:

-- **The `dual_code_graphql_api` feature flag gates every dual-API endpoint.** When it's off, neither core's `/wc/graphql` nor any plugin endpoint is registered (`Main::register_graphql_endpoint()` is a no-op). PHP 8.1+ is required the same way.
+- **The `dual_code_graphql_api` feature flag gates WooCommerce core's endpoint only.** When it's off, core's `/wc/graphql` is not registered, but plugin endpoints (registered through `Main::register_graphql_endpoint()`) are unaffected: they only require the dual API infrastructure to be available. See [Gating your endpoint](./creating-a-dual-api-in-a-plugin.md#gating-your-endpoint).
 - **Every setting except Endpoint URL applies to all endpoints.** The GET toggle, max depth, max complexity, the three caching toggles, and the cache TTL are read from the shared infrastructure, so a plugin endpoint honours them exactly as core's does (for example, plugin endpoints reject GET when the GET toggle is off). **Endpoint URL is the exception**: it only configures core's `/wc/graphql`; a plugin chooses its own route at registration.
 - **The filters below are global.** A callback added to any of them affects *every* dual-API endpoint on the site, core and plugins alike. Each filter receives the `\WP_REST_Request`, so a callback that should apply to only one endpoint must branch on the request's route itself.

diff --git a/docs/apis/dual-api/creating-a-dual-api-in-a-plugin.md b/docs/apis/dual-api/creating-a-dual-api-in-a-plugin.md
index da544256523..c362ad4f9ea 100644
--- a/docs/apis/dual-api/creating-a-dual-api-in-a-plugin.md
+++ b/docs/apis/dual-api/creating-a-dual-api-in-a-plugin.md
@@ -12,7 +12,7 @@ A plugin can define its own code API and get a matching GraphQL endpoint using W

 ## Prerequisites

-- WooCommerce installed with the `dual_code_graphql_api` feature flag enabled, on PHP 8.1+.
+- WooCommerce 10.9+ on PHP 8.1+. Since WooCommerce 11.1 the `dual_code_graphql_api` feature flag is **not** required: it gates WooCommerce core's own endpoint, not plugin endpoints (see [Gating your endpoint](#gating-your-endpoint), including the note about earlier versions).
 - The plugin's own Composer autoloader (PSR-4) and a `vendor/autoload.php`.

 The endpoint is **dedicated**: each plugin registers its own REST route. You cannot federate into core's `/wc/graphql`.
@@ -60,13 +60,31 @@ use Automattic\WooCommerce\Api\Infrastructure\Main as WooCommerceApiMain;

 add_action( 'plugins_loaded', static function () {
     if ( ! method_exists( WooCommerceApiMain::class, 'register_graphql_endpoint' ) ) {
-        return; // WooCommerce too old, or feature/PHP unavailable
+        return; // WooCommerce too old (no dual API infrastructure)
     }
     WooCommerceApiMain::register_graphql_endpoint( __DIR__, 'my-plugin', '/graphql' );
 } );
 ```

-The first argument may be your plugin directory (the controller class is resolved by convention) or the fully-qualified controller class name. This is a no-op when the feature flag is off or PHP is < 8.1. Your endpoint goes through the same request pipeline as core's and inherits the core [GraphQL settings](./caching-and-settings.md).
+The first argument may be your plugin directory (the controller class is resolved by convention) or the fully-qualified controller class name. This is a silent no-op when the required infrastructure is not available (see [Prerequisites](#prerequisites)). Your endpoint goes through the same request pipeline as core's and inherits the core [GraphQL settings](./caching-and-settings.md).
+
+**Note:** Call `register_graphql_endpoint()` unconditionally at bootstrap, as shown above: don't wrap it in `rest_api_init`. The actual route registration is already deferred internally, and WooCommerce uses the call itself to know that a plugin endpoint exists on the site (for example, to decide whether to show the shared GraphQL settings section). A call deferred to `rest_api_init` never happens on admin, cron, or CLI requests, so that detection would break there.
+
+### Gating your endpoint
+
+The `dual_code_graphql_api` feature flag gates WooCommerce core's own endpoint only: your endpoint is registered whenever the required infrastructure is available, regardless of the flag state. Choose how (and whether) to gate it:
+
+- **Always on** (shown above): installing your plugin is what enables the endpoint. No extra code.
+- **Your own switch**: check your own option, setting, or constant before calling `register_graphql_endpoint()`.
+- **Follow the core feature flag**: opt back into the pre-11.1 behavior by checking the flag yourself:
+
+    ```php
+    if ( \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( 'dual_code_graphql_api' ) ) {
+        WooCommerceApiMain::register_graphql_endpoint( __DIR__, 'my-plugin', '/graphql' );
+    }
+    ```
+
+> **Before WooCommerce 11.1** (that is, on 10.9 and 11.0) the feature flag gated plugin endpoints too: with the flag off, `register_graphql_endpoint()` was a silent no-op and your endpoint was not registered. If your plugin supports those versions, your endpoint only exists there when the site owner has enabled the flag (see [Requirements](./README.md#requirements)) — regardless of which gating strategy you choose.

 ## 4. Reuse or replace the convention classes

diff --git a/docs/apis/dual-api/reference/infrastructure-classes.md b/docs/apis/dual-api/reference/infrastructure-classes.md
index c41db452272..4f8e297d466 100644
--- a/docs/apis/dual-api/reference/infrastructure-classes.md
+++ b/docs/apis/dual-api/reference/infrastructure-classes.md
@@ -80,8 +80,10 @@ Static helpers used by generated resolvers: exception translation, pagination co

 Bootstrap and registration:

-- `is_enabled(): bool`: checks PHP 8.1+ and the `dual_code_graphql_api` flag.
-- `register_graphql_endpoint( string $plugin_dir_or_controller_class, string $route_namespace, string $route, array $methods = ['GET','POST'] ): void`: register a plugin endpoint. No-op when the feature is off.
+- `is_enabled(): bool`: whether the Dual Code & GraphQL API feature is active: the infrastructure is available and the `dual_code_graphql_api` flag is on. The flag gates WooCommerce core's own endpoint only.
+- `is_infrastructure_available(): bool`: whether the server can run the dual API infrastructure. That's all that plugin endpoint registration requires.
+- `has_registered_plugin_endpoints(): bool`: whether some plugin has registered an endpoint during the current request.
+- `register_graphql_endpoint( string $plugin_dir_or_controller_class, string $route_namespace, string $route, array $methods = ['GET','POST'] ): void`: register a plugin endpoint. Silent no-op when the infrastructure is unavailable; doesn't depend on the feature flag.
 - `instantiate_graphql_controller( string $controller_class_name ): ?GraphQLControllerBase`.

 ### `MetadataController`, `QueryInfoExtractor`
diff --git a/plugins/woocommerce/changelog/make-dual-api-usable-for-plugins-without-feature-enabled b/plugins/woocommerce/changelog/make-dual-api-usable-for-plugins-without-feature-enabled
new file mode 100644
index 00000000000..7ff8876ac82
--- /dev/null
+++ b/plugins/woocommerce/changelog/make-dual-api-usable-for-plugins-without-feature-enabled
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Dual API: endpoints registered by plugins no longer require the dual_code_graphql_api feature flag to be active.
diff --git a/plugins/woocommerce/src/Api/Infrastructure/Main.php b/plugins/woocommerce/src/Api/Infrastructure/Main.php
index 6cce34fea27..b88c3afa39c 100644
--- a/plugins/woocommerce/src/Api/Infrastructure/Main.php
+++ b/plugins/woocommerce/src/Api/Infrastructure/Main.php
@@ -84,18 +84,53 @@ class Main {
 	 */
 	public const OPTION_QUERY_CACHE_TTL = 'woocommerce_graphql_query_cache_ttl';

+	/**
+	 * Whether at least one plugin endpoint has been registered via
+	 * register_graphql_endpoint().
+	 *
+	 * @var bool
+	 */
+	private static $plugin_endpoints_registered = false;
+
 	/**
 	 * Check whether the Dual Code & GraphQL API feature is active.
 	 *
 	 * Requires PHP 8.1+ and the dual_code_graphql_api feature flag to be
 	 * enabled.
 	 *
+	 * The flag gates WooCommerce core's own GraphQL endpoint only. Endpoints
+	 * registered by plugins via register_graphql_endpoint() should check
+	 * is_infrastructure_available() instead.
+	 *
 	 * @return bool
 	 */
 	public static function is_enabled(): bool {
 		return PHP_VERSION_ID >= 80100 && FeaturesUtil::feature_is_enabled( self::FEATURE_SLUG );
 	}

+	/**
+	 * Check whether the dual API infrastructure can run on this server.
+	 *
+	 * @return bool
+	 *
+	 * @since 11.1.0
+	 */
+	public static function is_infrastructure_available(): bool {
+		return PHP_VERSION_ID >= 80100;
+	}
+
+	/**
+	 * Whether at least one plugin endpoint has been registered via
+	 * register_graphql_endpoint() during the current request.
+	 *
+	 * @return bool
+	 *
+	 * @since 11.1.0
+	 */
+	public static function has_registered_plugin_endpoints(): bool {
+		return self::$plugin_endpoints_registered;
+	}
+
 	/**
 	 * Whether the GraphQL endpoint accepts GET requests.
 	 *
@@ -204,16 +239,17 @@ class Main {
 	 * into their own autogenerated namespace). The returned controller is
 	 * ready to have handle_request() attached to a REST route.
 	 *
-	 * Returns null when the feature flag is off or PHP is < 8.1, so callers
-	 * can invoke this unconditionally from inside their own rest_api_init
-	 * handler.
+	 * Returns null when is_infrastructure_available() returns false, so callers
+	 * can invoke this unconditionally from inside their own rest_api_init handler.
+	 * The dual_code_graphql_api feature flag doesn't apply here: it gates
+	 * WooCommerce core's own endpoint only.
 	 *
 	 * @param string $controller_class_name Fully-qualified name of a subclass of GraphQLControllerBase.
 	 *
 	 * @throws \InvalidArgumentException If $controller_class_name does not extend GraphQLControllerBase.
 	 */
 	public static function instantiate_graphql_controller( string $controller_class_name ): ?GraphQLControllerBase {
-		if ( ! self::is_enabled() ) {
+		if ( ! self::is_infrastructure_available() ) {
 			return null;
 		}

@@ -227,14 +263,26 @@ class Main {
 	/**
 	 * Register a GraphQL REST endpoint backed by a plugin-provided controller subclass.
 	 *
-	 * May be called at any time up to and including the `rest_api_init` hook:
-	 * if called earlier, registration is deferred to that hook; if called from
-	 * inside another plugin's `rest_api_init` handler, registration happens
-	 * immediately. Calls made after `rest_api_init` has already completed
-	 * register a REST route that WP_REST_Server won't honour on the current
-	 * request — callers should avoid deferring registration past bootstrap.
-	 *
-	 * When the feature flag is off or PHP is < 8.1 this is a silent no-op.
+	 * Call this unconditionally from the plugin's bootstrap code (e.g. on
+	 * `plugins_loaded` or `init`), in every request context: the actual REST
+	 * route registration is deferred internally to the `rest_api_init` hook,
+	 * so there's no reason to wrap the call in that hook yourself. Deferring
+	 * it means the call never runs in contexts where `rest_api_init` doesn't
+	 * fire (admin screens, cron, Action Scheduler, CLI), so in those contexts
+	 * has_registered_plugin_endpoints() would stay false and, while core's own
+	 * endpoint is disabled, the GraphQL settings section would not be shown.
+	 *
+	 * Late calls still register the endpoint: from inside another plugin's
+	 * `rest_api_init` handler registration happens immediately, while calls
+	 * made after `rest_api_init` has already completed register a REST route
+	 * that WP_REST_Server won't honour on the current request.
+	 *
+	 * When is_infrastructure_available() returns false this is a silent no-op.
+	 * The dual_code_graphql_api feature flag doesn't gate plugin endpoints:
+	 * they are registered regardless of the flag state. A plugin that wants an
+	 * on/off switch for its endpoint should provide its own mechanism, or check
+	 * the flag itself with FeaturesUtil::feature_is_enabled( 'dual_code_graphql_api' )
+	 * before calling this method.
 	 *
 	 * The first argument accepts either of two forms:
 	 *
@@ -269,7 +317,7 @@ class Main {
 		string $route,
 		array $methods = array( 'GET', 'POST' )
 	): void {
-		if ( ! self::is_enabled() ) {
+		if ( ! self::is_infrastructure_available() ) {
 			return;
 		}

@@ -279,6 +327,8 @@ class Main {
 		// deep inside the deferred rest_api_init callback.
 		self::assert_is_controller_subclass( $controller_class_name );

+		self::$plugin_endpoints_registered = true;
+
 		$registrar = new GraphQLEndpointRegistrar( $controller_class_name, $route_namespace, $route, $methods );

 		if ( did_action( 'rest_api_init' ) ) {
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
index a3da661a0ea..33257b0dfd3 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
@@ -1 +1 @@
-2026-05-21T10:29:44+00:00
\ No newline at end of file
+2026-07-16T08:54:12+00:00
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt
index 59f41b2e8ec..a8c80ea1623 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt
@@ -1 +1 @@
-2965921ea12d55aff3aa621ff023f57cf0dde87517b5c2fabf7ad47752f8a128
\ No newline at end of file
+838fede51b1dcec38371ad1789d4907e2bbeedb8fe80152807d4a27928c3e13a
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/Api/OpcacheFileExpiry.php b/plugins/woocommerce/src/Internal/Api/OpcacheFileExpiry.php
index e115cec8d89..9fea2a86fed 100644
--- a/plugins/woocommerce/src/Internal/Api/OpcacheFileExpiry.php
+++ b/plugins/woocommerce/src/Internal/Api/OpcacheFileExpiry.php
@@ -65,14 +65,17 @@ class OpcacheFileExpiry {
 	 * Action Scheduler callback: delete expired files and reschedule.
 	 *
 	 * Immediate reschedule when files were deleted (drain the backlog), 24h
-	 * otherwise. Skipped when the feature is disabled.
+	 * otherwise. Skipped when no dual-API endpoint is active (core's feature
+	 * flag off and no plugin endpoints registered), since then nothing writes
+	 * to the cache; {@see self::ensure_scheduled()} restarts the cycle when
+	 * an endpoint becomes active again.
 	 *
 	 * @internal
 	 */
 	public static function handle_cleanup_action(): void {
 		$interval = self::delete_expired_files() > 0 ? 1 : DAY_IN_SECONDS;

-		if ( ! Main::is_enabled() ) {
+		if ( ! Main::is_enabled() && ! Main::has_registered_plugin_endpoints() ) {
 			return;
 		}

diff --git a/plugins/woocommerce/src/Internal/Api/Settings.php b/plugins/woocommerce/src/Internal/Api/Settings.php
index aa7bb8594de..08965799173 100644
--- a/plugins/woocommerce/src/Internal/Api/Settings.php
+++ b/plugins/woocommerce/src/Internal/Api/Settings.php
@@ -11,8 +11,10 @@ use Automattic\WooCommerce\Api\Infrastructure\Main;
  * Settings handling for the GraphQL API.
  *
  * Registers the "GraphQL" section under WooCommerce - Settings - Advanced.
- * Only active when Main::is_enabled() returns true (feature flag on and
- * PHP 8.1+), so the section is hidden when the feature is disabled.
+ * The section is shown when some dual-API endpoint is active: WooCommerce
+ * core's (Main::is_enabled(), feature flag on and PHP 8.1+) or at least one
+ * registered by a plugin (which doesn't require the feature flag). All the
+ * settings except Endpoint URL apply to plugin endpoints too.
  */
 class Settings {
 	/**
@@ -41,12 +43,23 @@ class Settings {
 	 * @return array
 	 */
 	public function add_section( array $sections ): array {
-		if ( Main::is_enabled() ) {
+		if ( $this->should_display_section() ) {
 			$sections[ self::SECTION_ID ] = __( 'GraphQL', 'woocommerce' );
 		}
 		return $sections;
 	}

+	/**
+	 * Whether the GraphQL settings section should be shown: some dual-API
+	 * endpoint (core's or a plugin's) must be active for these settings to
+	 * have an effect.
+	 *
+	 * @return bool
+	 */
+	private function should_display_section(): bool {
+		return Main::is_enabled() || Main::has_registered_plugin_endpoints();
+	}
+
 	/**
 	 * Provide the settings fields for the GraphQL section.
 	 *
@@ -55,25 +68,34 @@ class Settings {
 	 * @return array
 	 */
 	public function add_settings( array $settings, string $section_id ): array {
-		if ( self::SECTION_ID !== $section_id || ! Main::is_enabled() ) {
+		if ( self::SECTION_ID !== $section_id || ! $this->should_display_section() ) {
 			return $settings;
 		}

-		return array(
+		$settings = array(
 			array(
 				'title' => __( 'GraphQL', 'woocommerce' ),
 				'desc'  => __( 'Configure the WooCommerce GraphQL API.', 'woocommerce' ),
 				'type'  => 'title',
 				'id'    => 'woocommerce_graphql_options',
 			),
-			array(
+		);
+
+		// The Endpoint URL setting only configures core's own endpoint, so it's
+		// meaningless when that endpoint is off (feature flag disabled) and only
+		// plugin-registered endpoints, which choose their own route, are active.
+		if ( Main::is_enabled() ) {
+			$settings[] = array(
 				'title'    => __( 'Endpoint URL', 'woocommerce' ),
 				'desc'     => __( 'Path relative to /wp-json/ where the GraphQL endpoint is exposed. Needs at least two segments (namespace/route), e.g. wc/graphql.', 'woocommerce' ),
 				'desc_tip' => true,
 				'id'       => Main::OPTION_ENDPOINT_URL,
 				'default'  => GraphQLControllerBase::DEFAULT_ENDPOINT_URL,
 				'type'     => 'text',
-			),
+			);
+		}
+
+		$shared_settings = array(
 			array(
 				'title'   => __( 'Enable GET endpoint', 'woocommerce' ),
 				'desc'    => __( 'Allow GraphQL queries over GET in addition to POST', 'woocommerce' ),
@@ -131,6 +153,8 @@ class Settings {
 				'id'   => 'woocommerce_graphql_options',
 			),
 		);
+
+		return array_merge( $settings, $shared_settings );
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index a12b1d51c7a..1e237ab73da 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -637,7 +637,7 @@ class FeaturesController {
 			'dual_code_graphql_api'              => array(
 				'name'                         => __( 'Dual Code & GraphQL API', 'woocommerce' ),
 				'description'                  => __(
-					'Experimental code-first API for WooCommerce with automatic GraphQL endpoint generation. Requires PHP 8.1 or later.',
+					'Enable the WooCommerce core GraphQL endpoint of the experimental code-first dual API. Endpoints registered by plugins are not affected by this flag. Requires PHP 8.1 or later.',
 					'woocommerce'
 				),
 				'enabled_by_default'           => false,
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLEndpointRegistrarTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLEndpointRegistrarTest.php
index 35dcde85878..90e4d23edd3 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLEndpointRegistrarTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLEndpointRegistrarTest.php
@@ -108,9 +108,9 @@ class GraphQLEndpointRegistrarTest extends WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * @testdox handle_rest_api_init skips registration when the feature is disabled.
+	 * @testdox handle_rest_api_init registers the route even when the feature is disabled.
 	 */
-	public function test_handle_rest_api_init_skips_registration_when_feature_is_off(): void {
+	public function test_handle_rest_api_init_registers_route_when_feature_is_off(): void {
 		$this->enable_or_disable_feature( false );

 		$registrar = new GraphQLEndpointRegistrar(
@@ -123,6 +123,10 @@ class GraphQLEndpointRegistrarTest extends WC_REST_Unit_Test_Case {
 		$registrar->handle_rest_api_init();

 		$routes = rest_get_server()->get_routes();
-		$this->assertArrayNotHasKey( '/wc-test/disabled-feature-route', $routes );
+		$this->assertArrayHasKey(
+			'/wc-test/disabled-feature-route',
+			$routes,
+			'Plugin endpoints should be registered regardless of the feature flag state.'
+		);
 	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/MainTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/MainTest.php
index 2eeab68f1b8..50906b581f6 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/MainTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/MainTest.php
@@ -11,9 +11,9 @@ use Automattic\WooCommerce\Internal\Features\FeaturesController;
 use WC_Unit_Test_Case;

 /**
- * Tests for {@see Main} — the entry point that gates registration on PHP/feature
- * flag, exposes the GET-endpoint setting, and validates plugin-supplied
- * controller arguments.
+ * Tests for {@see Main} — the entry point that gates core endpoint registration
+ * on PHP + feature flag and plugin endpoint registration on PHP only, exposes
+ * the GET-endpoint setting, and validates plugin-supplied controller arguments.
  */
 class MainTest extends WC_Unit_Test_Case {
 	/**
@@ -30,9 +30,20 @@ class MainTest extends WC_Unit_Test_Case {
 	public function tearDown(): void {
 		$this->enable_or_disable_feature( false );
 		delete_option( Main::OPTION_GET_ENDPOINT_ENABLED );
+		$this->reset_plugin_endpoints_registered();
 		parent::tearDown();
 	}

+	/**
+	 * Reset the static plugin endpoint registration flag in Main,
+	 * which otherwise persists across tests.
+	 */
+	private function reset_plugin_endpoints_registered(): void {
+		$property = ( new \ReflectionClass( Main::class ) )->getProperty( 'plugin_endpoints_registered' );
+		$property->setAccessible( true );
+		$property->setValue( null, false );
+	}
+
 	/**
 	 * Toggle the dual_code_graphql_api feature flag via its underlying option.
 	 *
@@ -107,11 +118,26 @@ class MainTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox instantiate_graphql_controller returns null when the feature is disabled.
+	 * @testdox is_infrastructure_available returns true on PHP 8.1+ regardless of the feature flag.
 	 */
-	public function test_instantiate_returns_null_when_disabled(): void {
+	public function test_is_infrastructure_available_ignores_the_feature_flag(): void {
+		if ( PHP_VERSION_ID < 80100 ) {
+			$this->markTestSkipped( 'The dual API infrastructure requires PHP 8.1+.' );
+		}
+
 		$this->enable_or_disable_feature( false );
-		$this->assertNull( Main::instantiate_graphql_controller( AutogeneratedGraphQLController::class ) );
+		$this->assertTrue( Main::is_infrastructure_available() );
+	}
+
+	/**
+	 * @testdox instantiate_graphql_controller returns a controller even when the feature is disabled.
+	 */
+	public function test_instantiate_returns_controller_when_feature_is_off(): void {
+		$this->enable_or_disable_feature( false );
+
+		$controller = Main::instantiate_graphql_controller( AutogeneratedGraphQLController::class );
+
+		$this->assertInstanceOf( GraphQLControllerBase::class, $controller );
 	}

 	/**
@@ -140,17 +166,19 @@ class MainTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox register_graphql_endpoint is a silent no-op when the feature is disabled.
+	 * @testdox register_graphql_endpoint accepts registrations even when the feature is disabled.
 	 */
-	public function test_register_graphql_endpoint_is_a_no_op_when_disabled(): void {
+	public function test_register_graphql_endpoint_works_when_feature_is_off(): void {
 		$this->enable_or_disable_feature( false );

-		$routes_before = rest_get_server()->get_routes();
+		$this->assertFalse( Main::has_registered_plugin_endpoints() );

-		// Must not throw and must leave the REST route map unchanged.
-		Main::register_graphql_endpoint( AutogeneratedGraphQLController::class, 'wc-test', '/no-op' );
+		Main::register_graphql_endpoint( AutogeneratedGraphQLController::class, 'wc-test', '/feature-off' );

-		$this->assertSame( $routes_before, rest_get_server()->get_routes() );
+		$this->assertTrue(
+			Main::has_registered_plugin_endpoints(),
+			'The endpoint registration should be recorded regardless of the feature flag state.'
+		);
 	}

 	/**
@@ -247,6 +275,14 @@ class MainTest extends WC_Unit_Test_Case {
 		$prop->setAccessible( true );
 		$original_endpoints = $prop->getValue( $server );

+		// rest_get_server() fires rest_api_init when it creates the server,
+		// which would suppress the expected notice; whether that happens here
+		// depends on which tests ran before this one. Clear the action count
+		// so the notice fires deterministically (the test base class restores
+		// the counts on tear down).
+		// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		unset( $GLOBALS['wp_actions']['rest_api_init'] );
+
 		try {
 			// Force reset so we can observe registration deterministically.
 			$endpoints = $original_endpoints;
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/OpcacheFileExpiryTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/OpcacheFileExpiryTest.php
index 797dcf66d76..d1af99ac951 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/OpcacheFileExpiryTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/OpcacheFileExpiryTest.php
@@ -3,8 +3,10 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Tests\Internal\Api;

+use Automattic\WooCommerce\Api\Infrastructure\Main;
 use Automattic\WooCommerce\Internal\Api\OpcacheFileExpiry;
 use Automattic\WooCommerce\Internal\Api\QueryCache;
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
 use WC_Unit_Test_Case;

 /**
@@ -42,9 +44,35 @@ class OpcacheFileExpiryTest extends WC_Unit_Test_Case {
 		if ( function_exists( 'as_unschedule_all_actions' ) ) {
 			as_unschedule_all_actions( OpcacheFileExpiry::ACTION_HOOK );
 		}
+		$this->enable_or_disable_feature( false );
+		$this->set_plugin_endpoints_registered( false );
 		parent::tearDown();
 	}

+	/**
+	 * Enable or disable the dual_code_graphql_api feature via its underlying option.
+	 *
+	 * @param bool $enable True to enable, false to disable.
+	 */
+	private function enable_or_disable_feature( bool $enable ): void {
+		update_option(
+			wc_get_container()->get( FeaturesController::class )->feature_enable_option_name( 'dual_code_graphql_api' ),
+			$enable ? 'yes' : 'no'
+		);
+	}
+
+	/**
+	 * Set the static plugin endpoint registration flag in Main, normally
+	 * set by Main::register_graphql_endpoint().
+	 *
+	 * @param bool $registered The value to set.
+	 */
+	private function set_plugin_endpoints_registered( bool $registered ): void {
+		$property = ( new \ReflectionClass( Main::class ) )->getProperty( 'plugin_endpoints_registered' );
+		$property->setAccessible( true );
+		$property->setValue( null, $registered );
+	}
+
 	/**
 	 * @testdox delete_expired_files removes only files whose mtime is older than the TTL.
 	 */
@@ -78,6 +106,61 @@ class OpcacheFileExpiryTest extends WC_Unit_Test_Case {
 		$this->assertSame( 0, OpcacheFileExpiry::delete_expired_files() );
 	}

+	/**
+	 * @testdox handle_cleanup_action does not reschedule when no dual-API endpoint is active.
+	 */
+	public function test_handle_cleanup_action_does_not_reschedule_when_no_endpoint_is_active(): void {
+		if ( ! function_exists( 'as_has_scheduled_action' ) ) {
+			$this->markTestSkipped( 'Action Scheduler is not available.' );
+		}
+
+		as_unschedule_all_actions( OpcacheFileExpiry::ACTION_HOOK );
+		$this->enable_or_disable_feature( false );
+
+		OpcacheFileExpiry::handle_cleanup_action();
+
+		$this->assertFalse(
+			as_has_scheduled_action( OpcacheFileExpiry::ACTION_HOOK, array(), OpcacheFileExpiry::ACTION_GROUP ),
+			'The cleanup should not be rescheduled when nothing writes to the cache.'
+		);
+	}
+
+	/**
+	 * @testdox handle_cleanup_action reschedules when the feature is off but a plugin endpoint is registered.
+	 */
+	public function test_handle_cleanup_action_reschedules_when_plugin_endpoints_exist(): void {
+		if ( ! function_exists( 'as_has_scheduled_action' ) ) {
+			$this->markTestSkipped( 'Action Scheduler is not available.' );
+		}
+
+		$this->enable_or_disable_feature( false );
+		$this->set_plugin_endpoints_registered( true );
+
+		OpcacheFileExpiry::handle_cleanup_action();
+
+		$this->assertTrue(
+			as_has_scheduled_action( OpcacheFileExpiry::ACTION_HOOK, array(), OpcacheFileExpiry::ACTION_GROUP ),
+			'The cleanup should be rescheduled while plugin endpoints use the cache, regardless of the feature flag.'
+		);
+	}
+
+	/**
+	 * @testdox handle_cleanup_action reschedules when the feature is enabled.
+	 */
+	public function test_handle_cleanup_action_reschedules_when_feature_is_on(): void {
+		if ( ! function_exists( 'as_has_scheduled_action' ) ) {
+			$this->markTestSkipped( 'Action Scheduler is not available.' );
+		}
+
+		$this->enable_or_disable_feature( true );
+
+		OpcacheFileExpiry::handle_cleanup_action();
+
+		$this->assertTrue(
+			as_has_scheduled_action( OpcacheFileExpiry::ACTION_HOOK, array(), OpcacheFileExpiry::ACTION_GROUP )
+		);
+	}
+
 	/**
 	 * Create a per-test cache directory, point the OPcache filter at it, and
 	 * register it for cleanup in tearDown.
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php
index f8e6569a996..03c328ba3cd 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php
@@ -43,6 +43,7 @@ class SettingsTest extends WC_Unit_Test_Case {
 		);
 		delete_option( Main::OPTION_ENDPOINT_URL );
 		$this->enable_or_disable_feature( false );
+		$this->set_plugin_endpoints_registered( false );
 		parent::tearDown();
 	}

@@ -58,6 +59,18 @@ class SettingsTest extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * Set the static plugin endpoint registration flag in Main, normally
+	 * set by Main::register_graphql_endpoint().
+	 *
+	 * @param bool $registered The value to set.
+	 */
+	private function set_plugin_endpoints_registered( bool $registered ): void {
+		$property = ( new \ReflectionClass( Main::class ) )->getProperty( 'plugin_endpoints_registered' );
+		$property->setAccessible( true );
+		$property->setValue( null, $registered );
+	}
+
 	/**
 	 * @testdox register hooks add_section and add_settings into WooCommerce's advanced settings filters.
 	 */
@@ -277,7 +290,7 @@ class SettingsTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox add_section returns sections unchanged when the feature is disabled.
+	 * @testdox add_section returns sections unchanged when the feature is disabled and no plugin endpoints are registered.
 	 */
 	public function test_add_section_does_not_register_when_feature_is_off(): void {
 		$this->enable_or_disable_feature( false );
@@ -288,7 +301,7 @@ class SettingsTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox add_settings returns settings unchanged when the feature is disabled.
+	 * @testdox add_settings returns settings unchanged when the feature is disabled and no plugin endpoints are registered.
 	 */
 	public function test_add_settings_does_not_register_when_feature_is_off(): void {
 		$this->enable_or_disable_feature( false );
@@ -298,6 +311,41 @@ class SettingsTest extends WC_Unit_Test_Case {
 		$this->assertSame( array(), $result );
 	}

+	/**
+	 * @testdox add_section appends the graphql section when the feature is disabled but a plugin endpoint is registered.
+	 */
+	public function test_add_section_registers_when_feature_is_off_but_plugin_endpoints_exist(): void {
+		$this->enable_or_disable_feature( false );
+		$this->set_plugin_endpoints_registered( true );
+
+		$result = $this->sut->add_section( array( 'features' => 'Features' ) );
+
+		$this->assertArrayHasKey( Settings::SECTION_ID, $result );
+	}
+
+	/**
+	 * @testdox add_settings omits the endpoint URL field but keeps the shared fields when the feature is disabled but a plugin endpoint is registered.
+	 */
+	public function test_add_settings_omits_endpoint_url_when_feature_is_off_but_plugin_endpoints_exist(): void {
+		$this->enable_or_disable_feature( false );
+		$this->set_plugin_endpoints_registered( true );
+
+		$fields = $this->sut->add_settings( array(), Settings::SECTION_ID );
+		$by_id  = array_column( $fields, null, 'id' );
+
+		$this->assertArrayNotHasKey(
+			Main::OPTION_ENDPOINT_URL,
+			$by_id,
+			'The endpoint URL field only configures core\'s endpoint, so it should be hidden when the feature is off.'
+		);
+		$this->assertArrayHasKey(
+			Main::OPTION_GET_ENDPOINT_ENABLED,
+			$by_id,
+			'Shared settings apply to plugin endpoints too, so they should still be shown.'
+		);
+		$this->assertArrayHasKey( Main::OPTION_QUERY_CACHE_TTL, $by_id );
+	}
+
 	/**
 	 * @testdox add_settings defines the parsed query cache TTL field with min=1 and the default constant as default.
 	 */