diff --git a/CHANGELOG b/CHANGELOG index cb2451bbf..9ba632217 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -10,6 +10,7 @@ * Fix the `deprecated` tag generating invalid PHP for an integer message * Add a third argument to `FilesystemLoader::addPath()` and `FilesystemLoader::prependPath()` to skip checking that the directory exists * Speed up traversing nodes with node visitors + * Speed up loading compiled templates that OPcache already holds # 3.30.0 (2026-09-25) diff --git a/src/Cache/FilesystemCache.php b/src/Cache/FilesystemCache.php index 5840585e3..34754b61c 100644 --- a/src/Cache/FilesystemCache.php +++ b/src/Cache/FilesystemCache.php @@ -22,11 +22,18 @@ class FilesystemCache implements CacheInterface, RemovableCacheInterface private $directory; private $options; + private bool $checkOpcache; public function __construct(string $directory, int $options = 0) { $this->directory = rtrim($directory, '\/').'/'; $this->options = $options; + // OPcache can still hold a compiled file removed from disk, which FORCE_BYTECODE_INVALIDATION wants compiled again. + // Its API also warns on each call when restricted, and it checks the file anyway when it validates timestamps on each call. + $this->checkOpcache = !($options & self::FORCE_BYTECODE_INVALIDATION) + && \function_exists('opcache_is_script_cached') + && !\ini_get('opcache.restrict_api') + && (!filter_var(\ini_get('opcache.validate_timestamps'), \FILTER_VALIDATE_BOOLEAN) || \ini_get('opcache.revalidate_freq')); } public function generateKey(string $name, string $className): string @@ -38,7 +45,7 @@ class FilesystemCache implements CacheInterface, RemovableCacheInterface public function load(string $key): void { - if (is_file($key)) { + if (($this->checkOpcache && opcache_is_script_cached($key)) || is_file($key)) { @include_once $key; } } diff --git a/tests/Cache/FilesystemTest.php b/tests/Cache/FilesystemTest.php index a71e54e4a..300fa322d 100644 --- a/tests/Cache/FilesystemTest.php +++ b/tests/Cache/FilesystemTest.php @@ -74,6 +74,21 @@ class FilesystemTest extends TestCase $this->assertFalse(class_exists($this->className, false)); } + public function testLoadSkipsTheFileCheckWhenOpcacheHasTheFile(): void + { + $this->assertSame('loaded', $this->loadInOpcacheProcess(0, true)); + } + + public function testLoadChecksTheFileWhenBytecodeInvalidationIsForced(): void + { + $this->assertSame('', $this->loadInOpcacheProcess(FilesystemCache::FORCE_BYTECODE_INVALIDATION, true)); + } + + public function testLoadDoesNotWarnWhenTheOpcacheApiIsRestricted(): void + { + $this->assertSame('loaded', $this->loadInOpcacheProcess(0, false, '/nonexistent')); + } + public function testWrite(): void { $key = $this->directory.'/cache/cachefile.php'; @@ -202,4 +217,44 @@ class FilesystemTest extends TestCase '{{class_name}}' => $this->className, ]); } + + private function loadInOpcacheProcess(int $options, bool $removeCachedFile, string $restrictApi = ''): string + { + if (!\function_exists('opcache_compile_file')) { + $this->markTestSkipped('OPcache is not loaded.'); + } + + $code = <<<'PHP' + require $argv[1]; + $cache = new Twig\Cache\FilesystemCache($argv[2], (int) $argv[3]); + $key = $cache->generateKey('index', 'Foo'); + $cache->write($key, 'load($key); + PHP; + + $process = proc_open([ + \PHP_BINARY, + '-d', 'opcache.enable=1', + '-d', 'opcache.enable_cli=1', + '-d', 'opcache.file_update_protection=0', + '-d', 'opcache.validate_timestamps=0', + '-d', 'opcache.restrict_api='.$restrictApi, + '-d', 'display_errors=1', + '-d', 'error_reporting=-1', + '-r', $code, + \dirname(__DIR__, 2).'/vendor/autoload.php', + $this->directory, + (string) $options, + $removeCachedFile ? '1' : '0', + ], [1 => ['pipe', 'w'], 2 => ['redirect', 1]], $pipes); + + $output = stream_get_contents($pipes[1]); + proc_close($process); + + return $output; + } }