Commit fedbc15d5c8 for woocommerce

commit fedbc15d5c8f8bf4a1f0ced99838fb5fefc67f8c
Author: Alba Rincón <albarin@users.noreply.github.com>
Date:   Wed May 13 15:42:27 2026 +0200

    Add OPcache-backed caching for parsed GraphQL queries (#64596)

    * Add OPcache-backed caching for parsed GraphQL queries

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Address lint warnings in QueryCacheTest

    * Replace error suppression with WP_Filesystem in QueryCache

    * Use WP_Filesystem methods for is_writable and file deletion

    * Reject stream wrappers in OPcache cache directory filter

    * Make OPcache fallback test deterministic across runners

    * Always write APQ registrations to the object cache

    * Silence opcache_get_status() warning under opcache.restrict_api

    * Treat malformed object-cache entries as a cache miss

    * Fix catch binding and test fixture alignment

    * Reject malformed APQ hashes to prevent path traversal

    * Fix alignment of test fixture assignments

    * Add scheduled cleanup for expired OPcache cache files

    * Fix lint warnings in OpcacheFileExpiry and its test

    * Reuse QueryCache::get_cache_ttl() for OPcache file expiry

    * Extract duplicated wp_filesystem helper into Utils

    * Cache ensure_scheduled() result to avoid per-write DB lookup

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/64596-64166-graphql-opcache-cache b/plugins/woocommerce/changelog/64596-64166-graphql-opcache-cache
new file mode 100644
index 00000000000..104bec7754b
--- /dev/null
+++ b/plugins/woocommerce/changelog/64596-64166-graphql-opcache-cache
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add OPcache-backed caching for parsed GraphQL queries, with fallback to the WP object cache.
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/Api/Main.php b/plugins/woocommerce/src/Internal/Api/Main.php
index 92303abdbd5..8a249e7253c 100644
--- a/plugins/woocommerce/src/Internal/Api/Main.php
+++ b/plugins/woocommerce/src/Internal/Api/Main.php
@@ -57,6 +57,14 @@ class Main {
 	 */
 	public const OPTION_MAX_QUERY_COMPLEXITY = 'woocommerce_graphql_max_query_complexity';

+	/**
+	 * Option name for the "OPcache-based caching" setting.
+	 *
+	 * When enabled, parsed query ASTs are written to disk as PHP files so
+	 * that OPcache serves them from shared memory on subsequent requests.
+	 */
+	public const OPTION_OPCACHE_ENABLED = 'woocommerce_graphql_opcache_enabled';
+
 	/**
 	 * Option name for the "ObjectCache-based caching" setting.
 	 */
@@ -103,6 +111,17 @@ class Main {
 		return wc_string_to_bool( get_option( self::OPTION_APQ_ENABLED, 'yes' ) );
 	}

+	/**
+	 * Whether the OPcache-backed query cache is enabled.
+	 *
+	 * Defaults to true. Activation also depends on OPcache being loaded and
+	 * the cache directory being writable; see {@see QueryCache} for the
+	 * runtime capability check.
+	 */
+	public static function is_opcache_enabled(): bool {
+		return wc_string_to_bool( get_option( self::OPTION_OPCACHE_ENABLED, 'yes' ) );
+	}
+
 	/**
 	 * Whether the ObjectCache-backed query cache is enabled.
 	 *
@@ -154,6 +173,10 @@ class Main {

 		$settings = wc_get_container()->get( Settings::class );
 		$settings->register();
+
+		if ( self::is_enabled() ) {
+			add_action( OpcacheFileExpiry::ACTION_HOOK, array( OpcacheFileExpiry::class, 'handle_cleanup_action' ) );
+		}
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/Api/OpcacheFileExpiry.php b/plugins/woocommerce/src/Internal/Api/OpcacheFileExpiry.php
new file mode 100644
index 00000000000..228933fbaa8
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/OpcacheFileExpiry.php
@@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Api;
+
+/**
+ * Deletes expired OPcache cache files via Action Scheduler.
+ */
+class OpcacheFileExpiry {
+
+	/**
+	 * Action Scheduler hook name for the cleanup job.
+	 */
+	public const ACTION_HOOK = 'woocommerce_graphql_opcache_cleanup';
+
+	/**
+	 * Action Scheduler group for the cleanup job.
+	 */
+	public const ACTION_GROUP = 'woocommerce-graphql';
+
+	/**
+	 * Object-cache key used to short-circuit {@see self::ensure_scheduled()}.
+	 */
+	private const SCHEDULED_CACHE_KEY = 'graphql_opcache_cleanup_scheduled';
+
+	/**
+	 * Delete OPcache cache files older than {@see QueryCache::get_cache_ttl()}.
+	 *
+	 * AST contents are a pure function of the query, so this is a disk-usage
+	 * bound, not a correctness concern. Returns the count.
+	 */
+	public static function delete_expired_files(): int {
+		$dir = QueryCache::get_opcache_cache_dir();
+		if ( '' === $dir || ! is_dir( $dir ) ) {
+			return 0;
+		}
+
+		$fs = Utils::wp_filesystem();
+		if ( ! $fs ) {
+			return 0;
+		}
+
+		$files = glob( $dir . '/*.php' );
+		if ( false === $files ) {
+			return 0;
+		}
+
+		$cutoff = time() - QueryCache::get_cache_ttl();
+		$count  = 0;
+		foreach ( $files as $path ) {
+			$mtime = $fs->mtime( $path );
+			if ( false !== $mtime && $mtime < $cutoff && $fs->delete( $path ) ) {
+				++$count;
+			}
+		}
+
+		return $count;
+	}
+
+	/**
+	 * Action Scheduler callback: delete expired files and reschedule.
+	 *
+	 * Immediate reschedule when files were deleted (drain the backlog), 24h
+	 * otherwise.
+	 *
+	 * @internal
+	 */
+	public static function handle_cleanup_action(): void {
+		$interval = self::delete_expired_files() > 0 ? 1 : DAY_IN_SECONDS;
+
+		if ( function_exists( 'as_schedule_single_action' ) ) {
+			as_schedule_single_action( time() + $interval, self::ACTION_HOOK, array(), self::ACTION_GROUP );
+		}
+	}
+
+	/**
+	 * Schedule the cleanup if it isn't already scheduled.
+	 *
+	 * Called from {@see QueryCache::write_to_opcache()} so the first run is
+	 * triggered by the first write — no separate bootstrap step.
+	 */
+	public static function ensure_scheduled(): void {
+		if ( wp_cache_get( self::SCHEDULED_CACHE_KEY, QueryCache::CACHE_GROUP ) ) {
+			return;
+		}
+
+		if ( ! function_exists( 'as_has_scheduled_action' ) || ! function_exists( 'as_schedule_single_action' ) ) {
+			return;
+		}
+
+		if ( ! as_has_scheduled_action( self::ACTION_HOOK, array(), self::ACTION_GROUP ) ) {
+			as_schedule_single_action( time() + DAY_IN_SECONDS, self::ACTION_HOOK, array(), self::ACTION_GROUP );
+		}
+
+		wp_cache_set( self::SCHEDULED_CACHE_KEY, true, QueryCache::CACHE_GROUP, HOUR_IN_SECONDS );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/QueryCache.php b/plugins/woocommerce/src/Internal/Api/QueryCache.php
index f92c8fd22df..72805547aad 100644
--- a/plugins/woocommerce/src/Internal/Api/QueryCache.php
+++ b/plugins/woocommerce/src/Internal/Api/QueryCache.php
@@ -9,14 +9,19 @@ use Automattic\WooCommerce\Vendor\GraphQL\Language\Parser;
 use Automattic\WooCommerce\Vendor\GraphQL\Utils\AST;

 /**
- * Caches parsed GraphQL ASTs in the WP object cache and implements the
- * Apollo Automatic Persisted Queries (APQ) protocol.
+ * Caches parsed GraphQL ASTs and implements the Apollo Automatic Persisted
+ * Queries (APQ) protocol.
+ *
+ * Two backends are supported. OPcache (filesystem) is preferred: parsed ASTs
+ * are written as PHP files so that OPcache serves them from shared memory.
+ * The WP object cache is used as a fallback when OPcache isn't available or
+ * the cache directory isn't writable.
  */
 class QueryCache {
 	/**
 	 * WP object-cache group.
 	 */
-	private const CACHE_GROUP = 'wc-graphql';
+	public const CACHE_GROUP = 'wc-graphql';

 	/**
 	 * Cache key prefix. Includes the library major version so that upgrading
@@ -26,6 +31,20 @@ class QueryCache {
 	 */
 	private const CACHE_KEY_PREFIX = 'graphql_ast_v15_';

+	/**
+	 * Subdirectory (under wp-uploads) for the OPcache-backed file cache.
+	 * The version segment matches {@see self::CACHE_KEY_PREFIX} so a major
+	 * webonyx upgrade naturally orphans the previous version's files.
+	 */
+	private const OPCACHE_DIR_RELATIVE = 'wc-graphql-cache/v15';
+
+	/**
+	 * Cached result of {@see self::is_opcache_usable()} for the current request.
+	 *
+	 * @var ?bool
+	 */
+	private ?bool $opcache_usable = null;
+
 	/**
 	 * Default time-to-live (in seconds) applied when the option is unset or non-positive.
 	 *
@@ -62,7 +81,7 @@ class QueryCache {
 			&& is_array( $apq )
 			&& 1 === ( $apq['version'] ?? null )
 			&& is_string( $apq_hash )
-			&& '' !== $apq_hash ) {
+			&& 1 === preg_match( '/^[a-f0-9]{64}$/', $apq_hash ) ) {
 			return $this->resolve_apq( $query, $apq_hash );
 		}

@@ -72,7 +91,7 @@ class QueryCache {
 		}

 		// APQ keeps using the cache; it has its own settings toggle.
-		if ( ! Main::is_object_cache_enabled() ) {
+		if ( ! $this->is_caching_enabled() ) {
 			return $this->parse( $query );
 		}

@@ -102,16 +121,16 @@ class QueryCache {
 				);
 			}

-			$doc = $this->get_cached_document( $apq_hash );
+			$doc = $this->get_cached_document( $apq_hash, true );
 			if ( false !== $doc ) {
 				return $doc;
 			}

-			return $this->parse_and_cache( $query, $apq_hash );
+			return $this->parse_and_cache( $query, $apq_hash, true );
 		}

 		// Hash-only lookup.
-		$doc = $this->get_cached_document( $apq_hash );
+		$doc = $this->get_cached_document( $apq_hash, true );
 		if ( false !== $doc ) {
 			return $doc;
 		}
@@ -119,19 +138,49 @@ class QueryCache {
 		return $this->error_response( 'PersistedQueryNotFound', 'PERSISTED_QUERY_NOT_FOUND' );
 	}

+	/**
+	 * Whether at least one cache backend is enabled (and, for OPcache, usable).
+	 *
+	 * Used to short-circuit the standard-query path when neither backend is
+	 * available, so the request is parsed once with no cache lookup overhead.
+	 */
+	private function is_caching_enabled(): bool {
+		return ( Main::is_opcache_enabled() && $this->is_opcache_usable() )
+			|| Main::is_object_cache_enabled();
+	}
+
 	/**
 	 * Retrieve a cached DocumentNode by hash.
 	 *
-	 * @param string $hash The SHA-256 hash.
+	 * Tries OPcache first when enabled and usable, then falls back to the
+	 * WP object cache. APQ requests pass $for_apq=true so the object cache
+	 * is consulted regardless of the standard-query toggle, matching the
+	 * pre-OPcache behaviour where APQ always persisted via the object cache.
+	 *
+	 * @param string $hash    The SHA-256 hash.
+	 * @param bool   $for_apq Whether the lookup is for an APQ request.
 	 * @return DocumentNode|false
 	 */
-	private function get_cached_document( string $hash ) {
-		$cached = wp_cache_get( $this->build_cache_key( $hash ), self::CACHE_GROUP );
-		if ( false === $cached || ! is_array( $cached ) ) {
-			return false;
+	private function get_cached_document( string $hash, bool $for_apq = false ) {
+		if ( Main::is_opcache_enabled() && $this->is_opcache_usable() ) {
+			$doc = $this->read_from_opcache( $hash );
+			if ( false !== $doc ) {
+				return $doc;
+			}
+		}
+
+		if ( $for_apq || Main::is_object_cache_enabled() ) {
+			$cached = wp_cache_get( $this->build_cache_key( $hash ), self::CACHE_GROUP );
+			if ( is_array( $cached ) ) {
+				try {
+					return AST::fromArray( $cached );
+				} catch ( \Throwable $e ) {
+					return false;
+				}
+			}
 		}

-		return AST::fromArray( $cached );
+		return false;
 	}

 	/**
@@ -152,19 +201,32 @@ class QueryCache {
 	/**
 	 * Parse a query, cache the resulting AST, and return the DocumentNode.
 	 *
+	 * Writes to OPcache when enabled and usable. APQ registrations always
+	 * also write to the object cache so hash-only lookups still resolve if
+	 * OPcache later becomes unavailable (toggle off, dir unwritable, files
+	 * cleaned up, or a silent write_to_opcache failure).
+	 *
 	 * Returns an error array if the query has a syntax error.
 	 *
-	 * @param string $query The GraphQL query string.
-	 * @param string $hash  The SHA-256 hash to cache under.
+	 * @param string $query   The GraphQL query string.
+	 * @param string $hash    The SHA-256 hash to cache under.
+	 * @param bool   $for_apq Whether the request is an APQ registration.
 	 * @return DocumentNode|array
 	 */
-	private function parse_and_cache( string $query, string $hash ) {
+	private function parse_and_cache( string $query, string $hash, bool $for_apq = false ) {
 		$document = $this->parse( $query );
 		if ( ! $document instanceof DocumentNode ) {
 			return $document;
 		}

-		wp_cache_set( $this->build_cache_key( $hash ), $document->toArray(), self::CACHE_GROUP, self::get_cache_ttl() );
+		$used_opcache = Main::is_opcache_enabled() && $this->is_opcache_usable();
+		if ( $used_opcache ) {
+			$this->write_to_opcache( $hash, $document );
+		}
+
+		if ( $for_apq || ( Main::is_object_cache_enabled() && ! $used_opcache ) ) {
+			wp_cache_set( $this->build_cache_key( $hash ), $document->toArray(), self::CACHE_GROUP, self::get_cache_ttl() );
+		}

 		return $document;
 	}
@@ -179,6 +241,184 @@ class QueryCache {
 		return self::CACHE_KEY_PREFIX . $hash;
 	}

+	/**
+	 * Whether the OPcache file backend can be used for this request.
+	 *
+	 * Memoised per request: the underlying checks (opcache_get_status,
+	 * filesystem writability) don't change mid-request and are wasteful
+	 * to repeat across the read and write paths.
+	 */
+	private function is_opcache_usable(): bool {
+		if ( null !== $this->opcache_usable ) {
+			return $this->opcache_usable;
+		}
+
+		$this->opcache_usable = $this->compute_is_opcache_usable();
+		return $this->opcache_usable;
+	}
+
+	/**
+	 * Underlying capability check for the OPcache file backend.
+	 *
+	 * Requires the OPcache extension to be loaded and enabled, and the cache
+	 * directory to exist (or be creatable) and be writable.
+	 */
+	private function compute_is_opcache_usable(): bool {
+		if ( ! function_exists( 'opcache_get_status' ) || ! ini_get( 'opcache.enable' ) ) {
+			return false;
+		}
+
+		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- opcache.restrict_api raises E_WARNING when the calling path is disallowed; the false return is handled below.
+		$status = @opcache_get_status( false );
+		if ( ! is_array( $status ) || empty( $status['opcache_enabled'] ) ) {
+			return false;
+		}
+
+		return $this->ensure_opcache_dir_writable();
+	}
+
+	/**
+	 * Resolve the directory where OPcache cache files are written.
+	 *
+	 * Defaults to a versioned subdirectory under wp-uploads so it inherits
+	 * the writability guarantees WordPress places on uploads. Filterable
+	 * for tests and unusual hosting layouts.
+	 *
+	 * @internal Public for {@see OpcacheFileExpiry}; not part of the plugin's external API.
+	 */
+	public static function get_opcache_cache_dir(): string {
+		$upload_dir = wp_get_upload_dir();
+		$default    = trailingslashit( $upload_dir['basedir'] ) . self::OPCACHE_DIR_RELATIVE;
+
+		/**
+		 * Filters the directory where parsed GraphQL ASTs are written for OPcache.
+		 *
+		 * @since 10.9.0
+		 *
+		 * @param string $dir Default cache directory under wp-uploads.
+		 */
+		$dir = (string) apply_filters( 'woocommerce_graphql_opcache_cache_dir', $default );
+
+		// Reject stream wrappers (e.g. phar://, http://) to keep file_put_contents,
+		// rename, and include constrained to local filesystem paths.
+		if ( '' === $dir || wp_is_stream( $dir ) ) {
+			return '';
+		}
+
+		return $dir;
+	}
+
+	/**
+	 * Ensure the OPcache cache directory exists and is writable.
+	 *
+	 * Creates the directory on first use and drops a deny-all .htaccess and
+	 * an empty index.html alongside it. Returns false if creation fails or
+	 * the directory ends up non-writable.
+	 */
+	private function ensure_opcache_dir_writable(): bool {
+		$dir = self::get_opcache_cache_dir();
+
+		if ( '' === $dir ) {
+			return false;
+		}
+
+		if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
+			return false;
+		}
+
+		$fs = Utils::wp_filesystem();
+		if ( ! $fs || ! $fs->is_writable( $dir ) ) {
+			return false;
+		}
+
+		// Best-effort hardening; ignore failures (e.g. read-only permissions).
+		$htaccess = $dir . '/.htaccess';
+		$index    = $dir . '/index.html';
+		if ( ! file_exists( $htaccess ) ) {
+			$fs->put_contents( $htaccess, "Deny from all\n" );
+		}
+		if ( ! file_exists( $index ) ) {
+			$fs->put_contents( $index, '' );
+		}
+
+		return true;
+	}
+
+	/**
+	 * Read a cached DocumentNode from the OPcache file backend.
+	 *
+	 * @param string $hash The SHA-256 hash.
+	 * @return DocumentNode|false
+	 */
+	private function read_from_opcache( string $hash ) {
+		$path = self::get_opcache_cache_dir() . '/' . $hash . '.php';
+
+		if ( ! is_file( $path ) ) {
+			return false;
+		}
+
+		// File contents are produced by self::write_to_opcache() and only
+		// ever return a primitive array. The caller falls back to parsing
+		// when the include returns a non-array.
+		$data = include $path;
+
+		if ( ! is_array( $data ) ) {
+			return false;
+		}
+
+		try {
+			return AST::fromArray( $data );
+		} catch ( \Throwable $e ) {
+			return false;
+		}
+	}
+
+	/**
+	 * Persist a parsed AST to the OPcache file backend.
+	 *
+	 * Writes atomically (temp file + rename) so concurrent readers never see
+	 * a partial file, and explicitly invalidates OPcache for the destination
+	 * path so installs running with opcache.validate_timestamps=0 still see
+	 * the new version.
+	 *
+	 * Failures are intentionally silent: the caller already holds a valid
+	 * DocumentNode, and a failed cache write only forfeits the optimisation
+	 * for one request.
+	 *
+	 * @param string       $hash     The SHA-256 hash to cache under.
+	 * @param DocumentNode $document The parsed AST.
+	 */
+	private function write_to_opcache( string $hash, DocumentNode $document ): void {
+		$dir  = self::get_opcache_cache_dir();
+		$path = $dir . '/' . $hash . '.php';
+		$tmp  = $path . '.' . bin2hex( random_bytes( 8 ) ) . '.tmp';
+
+		$contents = "<?php\nreturn " . var_export( $document->toArray(), true ) . ";\n"; // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export
+
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+		if ( false === file_put_contents( $tmp, $contents, LOCK_EX ) ) {
+			return;
+		}
+
+		$fs = Utils::wp_filesystem();
+		if ( ! $fs || ! $fs->move( $tmp, $path, true ) ) {
+			if ( $fs ) {
+				$fs->delete( $tmp );
+			}
+			return;
+		}
+
+		if ( function_exists( 'opcache_invalidate' ) ) {
+			opcache_invalidate( $path, true );
+		}
+		if ( function_exists( 'opcache_compile_file' ) ) {
+			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+			@opcache_compile_file( $path );
+		}
+
+		OpcacheFileExpiry::ensure_scheduled();
+	}
+
 	/**
 	 * Build a GraphQL-shaped error response array.
 	 *
diff --git a/plugins/woocommerce/src/Internal/Api/Settings.php b/plugins/woocommerce/src/Internal/Api/Settings.php
index 812ad644b9e..30f4a597089 100644
--- a/plugins/woocommerce/src/Internal/Api/Settings.php
+++ b/plugins/woocommerce/src/Internal/Api/Settings.php
@@ -94,6 +94,13 @@ class Settings {
 				'type'              => 'number',
 				'custom_attributes' => array( 'min' => '1' ),
 			),
+			array(
+				'title'   => __( 'Enable OPcache-based caching', 'woocommerce' ),
+				'desc'    => __( 'Cache parsed queries on disk as PHP files so OPcache can serve them from shared memory. Falls back to the object cache when the filesystem is not writable.', 'woocommerce' ),
+				'id'      => Main::OPTION_OPCACHE_ENABLED,
+				'default' => 'yes',
+				'type'    => 'checkbox',
+			),
 			array(
 				'title'   => __( 'Enable ObjectCache-based caching', 'woocommerce' ),
 				'desc'    => __( 'Cache parsed queries in the WP object cache', 'woocommerce' ),
diff --git a/plugins/woocommerce/src/Internal/Api/Utils.php b/plugins/woocommerce/src/Internal/Api/Utils.php
index 734a16692f2..57e6e8c05c4 100644
--- a/plugins/woocommerce/src/Internal/Api/Utils.php
+++ b/plugins/woocommerce/src/Internal/Api/Utils.php
@@ -323,4 +323,19 @@ class Utils {
 		}//end try
 		// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
 	}
+
+	/**
+	 * Lazy-initialize and return the WP_Filesystem global, or null when the
+	 * direct method isn't available (e.g. credentials prompt would be needed).
+	 */
+	public static function wp_filesystem(): ?\WP_Filesystem_Base {
+		global $wp_filesystem;
+		if ( ! $wp_filesystem ) {
+			require_once ABSPATH . 'wp-admin/includes/file.php';
+			if ( ! WP_Filesystem() ) {
+				return null;
+			}
+		}
+		return $wp_filesystem;
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/OpcacheFileExpiryTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/OpcacheFileExpiryTest.php
new file mode 100644
index 00000000000..797dcf66d76
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/OpcacheFileExpiryTest.php
@@ -0,0 +1,125 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Api;
+
+use Automattic\WooCommerce\Internal\Api\OpcacheFileExpiry;
+use Automattic\WooCommerce\Internal\Api\QueryCache;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for {@see OpcacheFileExpiry} — TTL-based deletion of cached files.
+ */
+class OpcacheFileExpiryTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Track temp dirs for removal in tearDown.
+	 *
+	 * @var string[]
+	 */
+	private array $temp_dirs_to_clean = array();
+
+	/**
+	 * Skip on PHP < 8.1 because OpcacheFileExpiry imports from the GraphQL
+	 * stack autoloaded only on PHP 8.1+.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		if ( PHP_VERSION_ID < 80100 ) {
+			$this->markTestSkipped( 'OpcacheFileExpiry tests require PHP 8.1+.' );
+		}
+	}
+
+	/**
+	 * Clean up filters, temp dirs, and scheduled actions between tests.
+	 */
+	public function tearDown(): void {
+		remove_all_filters( 'woocommerce_graphql_opcache_cache_dir' );
+		foreach ( $this->temp_dirs_to_clean as $dir ) {
+			$this->rrmdir( $dir );
+		}
+		$this->temp_dirs_to_clean = array();
+		if ( function_exists( 'as_unschedule_all_actions' ) ) {
+			as_unschedule_all_actions( OpcacheFileExpiry::ACTION_HOOK );
+		}
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox delete_expired_files removes only files whose mtime is older than the TTL.
+	 */
+	public function test_delete_expired_files_removes_only_expired(): void {
+		$dir = $this->register_temp_cache_dir();
+
+		$fresh   = $dir . '/' . str_repeat( 'a', 64 ) . '.php';
+		$expired = $dir . '/' . str_repeat( 'b', 64 ) . '.php';
+		file_put_contents( $fresh, '<?php return array();' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+		file_put_contents( $expired, '<?php return array();' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+		touch( $expired, time() - QueryCache::get_cache_ttl() - 1 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch
+
+		$deleted = OpcacheFileExpiry::delete_expired_files();
+
+		$this->assertSame( 1, $deleted );
+		$this->assertFileExists( $fresh );
+		$this->assertFileDoesNotExist( $expired );
+	}
+
+	/**
+	 * @testdox delete_expired_files returns 0 when the cache directory does not exist.
+	 */
+	public function test_delete_expired_files_returns_zero_when_dir_missing(): void {
+		add_filter(
+			'woocommerce_graphql_opcache_cache_dir',
+			static function () {
+				return '/nonexistent/path/that/does/not/exist';
+			}
+		);
+
+		$this->assertSame( 0, OpcacheFileExpiry::delete_expired_files() );
+	}
+
+	/**
+	 * Create a per-test cache directory, point the OPcache filter at it, and
+	 * register it for cleanup in tearDown.
+	 */
+	private function register_temp_cache_dir(): string {
+		$dir = sys_get_temp_dir() . '/wc-graphql-cleanup-test-' . bin2hex( random_bytes( 6 ) );
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
+		mkdir( $dir, 0700, true );
+
+		add_filter(
+			'woocommerce_graphql_opcache_cache_dir',
+			static function () use ( $dir ) {
+				return $dir;
+			}
+		);
+
+		$this->temp_dirs_to_clean[] = $dir;
+		return $dir;
+	}
+
+	/**
+	 * Recursively remove a directory tree.
+	 *
+	 * @param string $dir Path to remove.
+	 */
+	private function rrmdir( string $dir ): void {
+		if ( ! is_dir( $dir ) ) {
+			return;
+		}
+		// phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
+		foreach ( scandir( $dir ) as $entry ) {
+			if ( '.' === $entry || '..' === $entry ) {
+				continue;
+			}
+			$path = $dir . '/' . $entry;
+			if ( is_dir( $path ) ) {
+				$this->rrmdir( $path );
+			} else {
+				wp_delete_file( $path );
+			}
+		}
+		rmdir( $dir );
+		// phpcs:enable
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/QueryCacheTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/QueryCacheTest.php
index b3ba2b912a9..51fb1d8c681 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/QueryCacheTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/QueryCacheTest.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Api;

 use Automattic\WooCommerce\Internal\Api\Main;
 use Automattic\WooCommerce\Internal\Api\QueryCache;
+use Automattic\WooCommerce\Internal\Api\OpcacheFileExpiry;
 use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\DocumentNode;
 use WC_Unit_Test_Case;

@@ -36,6 +37,11 @@ class QueryCacheTest extends WC_Unit_Test_Case {
 			$this->markTestSkipped( 'QueryCache tests require PHP 8.1+.' );
 		}

+		// OPcache caching defaults to 'yes'; turn it off so the existing
+		// object-cache assertions aren't bypassed by a writable filesystem.
+		// Individual tests may opt back in.
+		update_option( Main::OPTION_OPCACHE_ENABLED, 'no' );
+
 		wp_cache_flush();
 		$this->sut = new QueryCache();
 	}
@@ -45,6 +51,21 @@ class QueryCacheTest extends WC_Unit_Test_Case {
 	 */
 	public function tearDown(): void {
 		delete_option( Main::OPTION_OBJECT_CACHE_ENABLED );
+		delete_option( Main::OPTION_OPCACHE_ENABLED );
+		remove_all_filters( 'woocommerce_graphql_opcache_cache_dir' );
+		foreach ( $this->temp_dirs_to_clean as $dir ) {
+			$this->rrmdir( $dir );
+		}
+		$this->temp_dirs_to_clean = array();
+		foreach ( $this->temp_files_to_clean as $file ) {
+			if ( file_exists( $file ) ) {
+				wp_delete_file( $file );
+			}
+		}
+		$this->temp_files_to_clean = array();
+		if ( function_exists( 'as_unschedule_all_actions' ) ) {
+			as_unschedule_all_actions( OpcacheFileExpiry::ACTION_HOOK );
+		}
 		wp_cache_flush();
 		parent::tearDown();
 	}
@@ -164,6 +185,26 @@ class QueryCacheTest extends WC_Unit_Test_Case {
 		$this->assertInstanceOf( DocumentNode::class, $result );
 	}

+	/**
+	 * @testdox apq is ignored when the hash is not 64-char lowercase hex — guards the OPcache include path against traversal.
+	 */
+	public function test_apq_rejects_malformed_hash(): void {
+		$extensions = array(
+			'persistedQuery' => array(
+				'version'    => 1,
+				'sha256Hash' => 'not-a-sha256-hash',
+			),
+		);
+
+		$result = $this->sut->resolve( '{ widget { id } }', $extensions );
+
+		$this->assertInstanceOf(
+			DocumentNode::class,
+			$result,
+			'A malformed APQ hash must bypass APQ dispatch so it never reaches the OPcache include path.'
+		);
+	}
+
 	/**
 	 * @testdox get_cache_ttl exposes the configured TTL.
 	 */
@@ -201,6 +242,247 @@ class QueryCacheTest extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox resolve treats a malformed object-cache entry as a cache miss and reparses.
+	 */
+	public function test_resolve_treats_malformed_object_cache_entry_as_miss(): void {
+		update_option( Main::OPTION_OBJECT_CACHE_ENABLED, 'yes' );
+
+		$query = '{ __typename }';
+		wp_cache_set( $this->cache_key_for( $query ), array( 'not' => 'a valid AST' ), 'wc-graphql' );
+
+		$result = $this->sut->resolve( $query, array() );
+
+		$this->assertInstanceOf(
+			DocumentNode::class,
+			$result,
+			'A corrupted cache payload must be treated as a miss and the query reparsed.'
+		);
+	}
+
+	/**
+	 * @testdox resolve writes a parsed AST as a PHP file when OPcache is enabled and the dir is writable.
+	 */
+	public function test_resolve_writes_to_opcache_file_when_toggle_on(): void {
+		$dir = $this->use_temp_opcache_dir();
+		update_option( Main::OPTION_OPCACHE_ENABLED, 'yes' );
+
+		$query  = '{ widget { id } }';
+		$result = $this->sut->resolve( $query, array() );
+
+		$this->assertInstanceOf( DocumentNode::class, $result );
+		$this->assertFileExists( $dir . '/' . hash( 'sha256', $query ) . '.php' );
+	}
+
+	/**
+	 * @testdox resolve does not write to the OPcache dir when the toggle is off.
+	 */
+	public function test_resolve_does_not_write_to_opcache_file_when_toggle_off(): void {
+		$dir = $this->use_temp_opcache_dir();
+		update_option( Main::OPTION_OPCACHE_ENABLED, 'no' );
+
+		$query = '{ widget { id } }';
+		$this->sut->resolve( $query, array() );
+
+		$this->assertFileDoesNotExist( $dir . '/' . hash( 'sha256', $query ) . '.php' );
+	}
+
+	/**
+	 * @testdox resolve returns the AST from the OPcache file on the second call.
+	 */
+	public function test_resolve_returns_document_from_opcache_on_second_call(): void {
+		$this->use_temp_opcache_dir();
+		update_option( Main::OPTION_OPCACHE_ENABLED, 'yes' );
+
+		$query  = '{ widget { id } }';
+		$first  = $this->sut->resolve( $query, array() );
+		$second = $this->sut->resolve( $query, array() );
+
+		$this->assertInstanceOf( DocumentNode::class, $first );
+		$this->assertInstanceOf( DocumentNode::class, $second );
+		$this->assertEquals( $first->toArray(), $second->toArray() );
+	}
+
+	/**
+	 * @testdox apq registration persists across the OPcache file backend.
+	 */
+	public function test_apq_round_trip_via_opcache(): void {
+		$this->use_temp_opcache_dir();
+		update_option( Main::OPTION_OPCACHE_ENABLED, 'yes' );
+
+		$query      = '{ widget { id } }';
+		$hash       = hash( 'sha256', $query );
+		$extensions = array(
+			'persistedQuery' => array(
+				'version'    => 1,
+				'sha256Hash' => $hash,
+			),
+		);
+
+		$register = $this->sut->resolve( $query, $extensions );
+		$this->assertInstanceOf( DocumentNode::class, $register );
+
+		$lookup = $this->sut->resolve( null, $extensions );
+		$this->assertInstanceOf( DocumentNode::class, $lookup );
+	}
+
+	/**
+	 * @testdox apq hash-only lookup resolves from the object cache when OPcache becomes unavailable after registration.
+	 */
+	public function test_apq_lookup_falls_back_to_object_cache_when_opcache_disabled(): void {
+		$this->use_temp_opcache_dir();
+		update_option( Main::OPTION_OPCACHE_ENABLED, 'yes' );
+
+		$query      = '{ widget { id } }';
+		$hash       = hash( 'sha256', $query );
+		$extensions = array(
+			'persistedQuery' => array(
+				'version'    => 1,
+				'sha256Hash' => $hash,
+			),
+		);
+
+		$register = $this->sut->resolve( $query, $extensions );
+		$this->assertInstanceOf( DocumentNode::class, $register );
+
+		update_option( Main::OPTION_OPCACHE_ENABLED, 'no' );
+		$this->sut = new QueryCache();
+
+		$lookup = $this->sut->resolve( null, $extensions );
+		$this->assertInstanceOf(
+			DocumentNode::class,
+			$lookup,
+			'APQ hash-only lookup must still resolve from the object cache after OPcache is disabled.'
+		);
+	}
+
+	/**
+	 * @testdox resolve falls back to the object cache when the OPcache dir is not writable.
+	 */
+	public function test_resolve_falls_back_to_object_cache_when_opcache_dir_unwritable(): void {
+		$this->skip_if_opcache_disabled();
+
+		$not_a_dir                   = tempnam( sys_get_temp_dir(), 'wc-graphql-cache-' );
+		$this->temp_files_to_clean[] = $not_a_dir;
+
+		add_filter(
+			'woocommerce_graphql_opcache_cache_dir',
+			static function () use ( $not_a_dir ) {
+				return $not_a_dir;
+			}
+		);
+
+		update_option( Main::OPTION_OPCACHE_ENABLED, 'yes' );
+		update_option( Main::OPTION_OBJECT_CACHE_ENABLED, 'yes' );
+
+		$result = $this->sut->resolve( '{ __typename }', array() );
+
+		$this->assertInstanceOf( DocumentNode::class, $result );
+		$this->assertNotFalse(
+			wp_cache_get( $this->cache_key_for( '{ __typename }' ), 'wc-graphql' ),
+			'Should have fallen back to the object cache when the OPcache dir is unwritable.'
+		);
+	}
+
+	/**
+	 * @testdox writing to the OPcache backend schedules the cleanup sweep on first write.
+	 */
+	public function test_first_write_schedules_cleanup(): void {
+		$this->use_temp_opcache_dir();
+		update_option( Main::OPTION_OPCACHE_ENABLED, 'yes' );
+
+		$this->assertFalse(
+			as_has_scheduled_action( OpcacheFileExpiry::ACTION_HOOK ),
+			'Pre-condition: no cleanup action should be scheduled before the first write.'
+		);
+
+		$this->sut->resolve( '{ __typename }', array() );
+
+		$this->assertTrue(
+			as_has_scheduled_action( OpcacheFileExpiry::ACTION_HOOK ),
+			'A successful OPcache write must schedule the cleanup sweep.'
+		);
+	}
+
+	/**
+	 * Skip the calling test if OPcache is not enabled in this environment.
+	 *
+	 * Typical for PHP CLI without opcache.enable_cli=1 — the file-backend
+	 * capability check requires opcache_get_status to report enabled, so
+	 * tests that exercise that path are not meaningful without it.
+	 */
+	private function skip_if_opcache_disabled(): void {
+		if ( ! function_exists( 'opcache_get_status' ) || ! ini_get( 'opcache.enable' ) ) {
+			$this->markTestSkipped( 'OPcache is not enabled in this environment.' );
+		}
+		$status = opcache_get_status( false );
+		if ( ! is_array( $status ) || empty( $status['opcache_enabled'] ) ) {
+			$this->markTestSkipped( 'OPcache is not enabled in this environment.' );
+		}
+	}
+
+	/**
+	 * Point the OPcache backend at a per-test temp dir, return the path, and
+	 * register a teardown hook to remove it.
+	 */
+	private function use_temp_opcache_dir(): string {
+		$this->skip_if_opcache_disabled();
+
+		$dir = sys_get_temp_dir() . '/wc-graphql-test-' . bin2hex( random_bytes( 6 ) );
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
+		mkdir( $dir, 0700, true );
+
+		add_filter(
+			'woocommerce_graphql_opcache_cache_dir',
+			static function () use ( $dir ) {
+				return $dir;
+			}
+		);
+
+		$this->temp_dirs_to_clean[] = $dir;
+
+		return $dir;
+	}
+
+	/**
+	 * Track temp dirs for removal in tearDown.
+	 *
+	 * @var string[]
+	 */
+	private array $temp_dirs_to_clean = array();
+
+	/**
+	 * Track temp files for removal in tearDown.
+	 *
+	 * @var string[]
+	 */
+	private array $temp_files_to_clean = array();
+
+	/**
+	 * Recursively remove a directory tree.
+	 *
+	 * @param string $dir Path to remove.
+	 */
+	private function rrmdir( string $dir ): void {
+		if ( ! is_dir( $dir ) ) {
+			return;
+		}
+		// phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
+		foreach ( scandir( $dir ) as $entry ) {
+			if ( '.' === $entry || '..' === $entry ) {
+				continue;
+			}
+			$path = $dir . '/' . $entry;
+			if ( is_dir( $path ) ) {
+				$this->rrmdir( $path );
+			} else {
+				wp_delete_file( $path );
+			}
+		}
+		rmdir( $dir );
+		// phpcs:enable
+	}
+
 	/**
 	 * Build the QueryCache cache key for a query string. Prefix kept in sync
 	 * with QueryCache::CACHE_KEY_PREFIX.
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php
index 8864d59a5f8..de1d856a787 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php
@@ -221,6 +221,18 @@ class SettingsTest extends WC_Unit_Test_Case {
 		$this->assertSame( 'yes', $by_id[ Main::OPTION_OBJECT_CACHE_ENABLED ]['default'] );
 	}

+	/**
+	 * @testdox add_settings defines the OPcache checkbox with a 'yes' default.
+	 */
+	public function test_add_settings_defines_opcache_checkbox(): void {
+		$fields = $this->sut->add_settings( array(), Settings::SECTION_ID );
+		$by_id  = array_column( $fields, null, 'id' );
+
+		$this->assertArrayHasKey( Main::OPTION_OPCACHE_ENABLED, $by_id );
+		$this->assertSame( 'checkbox', $by_id[ Main::OPTION_OPCACHE_ENABLED ]['type'] );
+		$this->assertSame( 'yes', $by_id[ Main::OPTION_OPCACHE_ENABLED ]['default'] );
+	}
+
 	/**
 	 * @testdox add_settings defines the max query depth field with min=1 and the default constant as default.
 	 */