Repository: twigphp/Twig (php)

Issue:
Simplify refreshing a template’s cache
https://twig.symfony.com/doc/3.x/recipes.html#using-a-database-to-store-templates shows how to load templates from a database, but following this recipe will make templates’ source update inoperant by default when debug mode is disabled.

You can enable `auto_reload` to work around the issue, but this will cause every loader to check for cache freshness every time a template is rendered.

I think it would be more efficient to refresh a template’s cache after its source is updated, but the corresponding code is somewhat complex and needs the `@internal` `Environment::getTemplateClass` method:

```php
$twigCache = $twig->getCache(false);

$twigCache->write(
    $twigCache->generateKey($templateName, $twig->getTemplateClass($templateName)),
    $twig->compileSource($twig->getLoader()->getSourceContext($templateName))
);
```

Would it make sense for `Environment` to expose something like a `refreshCache(string $templateName)` method?

Pull request description:
Add template cache hot reload
Closes #3880

This PR achieves 2 things in the minimum amount of code (in 2 commits):

* It adds a new `RemovableCacheInterface` that allows cache to be removed.
* It adds a memory cache to allow updating existing templates in the same process (think FrakenPHP for instance) without polluting the permanent cache.


Patch:
diff --git a/CHANGELOG b/CHANGELOG
index ea986469..bb7d956a 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,5 +1,6 @@
 # 3.15.0 (2024-XX-XX)
 
+ * Add template cache hot reload
  * Allow Twig callable argument names to be free-form (snake-case or camelCase) independently of the PHP callable signature
    They were automatically converted to snake-cased before
  * Deprecate the `attribute` function; use the `.` notation and wrap the name with parenthesis instead
diff --git a/src/Cache/ChainCache.php b/src/Cache/ChainCache.php
index c94afdb4..1c2098f1 100644
--- a/src/Cache/ChainCache.php
+++ b/src/Cache/ChainCache.php
@@ -19,7 +19,7 @@ namespace Twig\Cache;
  *
  * @author Quentin Devos <quentin@devos.pm>
  */
-final class ChainCache implements CacheInterface
+final class ChainCache implements CacheInterface, RemovableCacheInterface
 {
     /**
      * @param iterable<CacheInterface> $caches The ordered list of caches used to store and fetch cached items
@@ -69,6 +69,15 @@ final class ChainCache implements CacheInterface
         return 0;
     }
 
+    public function remove(string $name, string $cls): void
+    {
+        foreach ($this->caches as $cache) {
+            if ($cache instanceof RemovableCacheInterface) {
+                $cache->remove($name, $cls);
+            }
+        }
+    }
+
     /**
      * @return string[]
      */
diff --git a/src/Cache/FilesystemCache.php b/src/Cache/FilesystemCache.php
index 2e79fac0..5840585e 100644
--- a/src/Cache/FilesystemCache.php
+++ b/src/Cache/FilesystemCache.php
@@ -16,7 +16,7 @@ namespace Twig\Cache;
  *
  * @author Andrew Tch <andrew@noop.lv>
  */
-class FilesystemCache implements CacheInterface
+class FilesystemCache implements CacheInterface, RemovableCacheInterface
 {
     public const FORCE_BYTECODE_INVALIDATION = 1;
 
@@ -76,6 +76,14 @@ class FilesystemCache implements CacheInterface
         throw new \RuntimeException(\sprintf('Failed to write cache file "%s".', $key));
     }
 
+    public function remove(string $name, string $cls): void
+    {
+        $key = $this->generateKey($name, $cls);
+        if (!@unlink($key) && file_exists($key)) {
+            throw new \RuntimeException(\sprintf('Failed to delete cache file "%s".', $key));
+        }
+    }
+
     public function getTimestamp(string $key): int
     {
         if (!is_file($key)) {
diff --git a/src/Cache/NullCache.php b/src/Cache/NullCache.php
index 8d20d59d..1ae21692 100644
--- a/src/Cache/NullCache.php
+++ b/src/Cache/NullCache.php
@@ -16,7 +16,7 @@ namespace Twig\Cache;
  *
  * @author Fabien Potencier <fabien@symfony.com>
  */
-final class NullCache implements CacheInterface
+final class NullCache implements CacheInterface, RemovableCacheInterface
 {
     public function generateKey(string $name, string $className): string
     {
@@ -35,4 +35,8 @@ final class NullCache implements CacheInterface
     {
         return 0;
     }
+
+    public function remove(string $name, string $cls): void
+    {
+    }
 }
diff --git a/src/Cache/RemovableCacheInterface.php b/src/Cache/RemovableCacheInterface.php
new file mode 100644
index 00000000..05da5691
--- /dev/null
+++ b/src/Cache/RemovableCacheInterface.php
@@ -0,0 +1,20 @@
+<?php
+
+/*
+ * This file is part of Twig.
+ *
+ * (c) Fabien Potencier
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Twig\Cache;
+
+/**
+ * @author Fabien Potencier <fabien@symfony.com>
+ */
+interface RemovableCacheInterface
+{
+    public function remove(string $name, string $cls): void;
+}
diff --git a/src/Environment.php b/src/Environment.php
index fe95adc5..c8862af3 100644
--- a/src/Environment.php
+++ b/src/Environment.php
@@ -14,6 +14,7 @@ namespace Twig;
 use Twig\Cache\CacheInterface;
 use Twig\Cache\FilesystemCache;
 use Twig\Cache\NullCache;
+use Twig\Cache\RemovableCacheInterface;
 use Twig\Error\Error;
 use Twig\Error\LoaderError;
 use Twig\Error\RuntimeError;
@@ -71,6 +72,7 @@ class Environment
     /** @var bool */
     private $useYield;
     private $defaultRuntimeLoader;
+    private array $hotCache = [];
 
     /**
      * Constructor.
@@ -233,6 +235,18 @@ class Environment
         return $this->strictVariables;
     }
 
+    public function removeCache(string $name): void
+    {
+        $cls = $this->getTemplateClass($name);
+        $this->hotCache[$name] = $cls.'_'.bin2hex(random_bytes(16));
+
+        if ($this->cache instanceof RemovableCacheInterface) {
+            $this->cache->remove($name, $cls);
+        } else {
+            throw new \LogicException(\sprintf('The "%s" cache class does not support removing template cache as it does not implement the "RemovableCacheInterface" interface.', \get_class($this->cache)));
+        }
+    }
+
     /**
      * Gets the current cache implementation.
      *
@@ -287,7 +301,7 @@ class Environment
      */
     public function getTemplateClass(string $name, ?int $index = null): string
     {
-        $key = $this->getLoader()->getCacheKey($name).$this->optionsHash;
+        $key = ($this->hotCache[$name] ?? $this->getLoader()->getCacheKey($name)).$this->optionsHash;
 
         return '__TwigTemplate_'.hash(\PHP_VERSION_ID < 80100 ? 'sha256' : 'xxh128', $key).(null === $index ? '' : '___'.$index);
     }
@@ -379,8 +393,10 @@ class Environment
             if (!class_exists($cls, false)) {
                 $source = $this->getLoader()->getSourceContext($name);
                 $content = $this->compileSource($source);
-                $this->cache->write($key, $content);
-                $this->cache->load($key);
+                if (!isset($this->hotCache[$name])) {
+                    $this->cache->write($key, $content);
+                    $this->cache->load($key);
+                }
 
                 if (!class_exists($mainCls, false)) {
                     /* Last line of defense if either $this->bcWriteCacheFile was used,


Test patch:
diff --git a/tests/EnvironmentTest.php b/tests/EnvironmentTest.php
index b5100a0a..7bbc61ce 100644
--- a/tests/EnvironmentTest.php
+++ b/tests/EnvironmentTest.php
@@ -22,6 +22,7 @@ use Twig\Extension\AbstractExtension;
 use Twig\Extension\ExtensionInterface;
 use Twig\Extension\GlobalsInterface;
 use Twig\Loader\ArrayLoader;
+use Twig\Loader\FilesystemLoader;
 use Twig\Loader\LoaderInterface;
 use Twig\Node\Node;
 use Twig\NodeVisitor\NodeVisitorInterface;
@@ -497,6 +498,53 @@ EOF
         $g3 = $twig->getGlobals();
         $this->assertNotSame($g3['global_ext'], $g2['global_ext']);
     }
+
+    public function testHotCache()
+    {
+        $dir = sys_get_temp_dir().'/twig-hot-cache-test';
+        if (is_dir($dir)) {
+            FilesystemHelper::removeDir($dir);
+        }
+        mkdir($dir);
+        file_put_contents($dir.'/index.twig', 'x');
+        try {
+            $twig = new Environment(new FilesystemLoader($dir), [
+                'debug' => false,
+                'auto_reload' => false,
+                'cache' => $dir.'/cache',
+            ]);
+
+            // prime the cache
+            $this->assertSame('x', $twig->load('index.twig')->render([]));
+
+            // update the template
+            file_put_contents($dir.'/index.twig', 'y');
+
+            // re-render, should use the cached version
+            $this->assertSame('x', $twig->load('index.twig')->render([]));
+
+            // clear the cache
+            $twig->removeCache('index.twig');
+
+            // re-render, should use the updated template
+            $this->assertSame('y', $twig->load('index.twig')->render([]));
+
+            // the new template should not be cached
+            $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir.'/cache', \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST);
+            $count = 0;
+            foreach ($iterator as $fileInfo) {
+                if (!$fileInfo->isDir()) {
+                    ++$count;
+                }
+            }
+            $this->assertSame(0, $count);
+
+            // re-render, should use the updated template
+            $this->assertSame('y', $twig->load('index.twig')->render([]));
+        } finally {
+            FilesystemHelper::removeDir($dir);
+        }
+    }
 }
 
 class EnvironmentTest_Extension_WithGlobals extends AbstractExtension
diff --git a/tests/Fixtures/tags/inheritance/extends_as_array_with_nested_blocks.test b/tests/Fixtures/tags/inheritance/extends_as_array_with_nested_blocks.test
deleted file mode 100644
index 01d23fce..00000000
--- a/tests/Fixtures/tags/inheritance/extends_as_array_with_nested_blocks.test
+++ /dev/null
@@ -1,31 +0,0 @@
---TEST--
-"extends" tag
---TEMPLATE--
-{% extends ["parent.twig"] %}
-
-{% block outer %}
-    outer wrap start
-    {{~ parent() }}
-    outer wrap end
-{% endblock %}
-
-{% block inner -%}
-    inner actual
-{% endblock %}
---TEMPLATE(parent.twig)--
-{% block outer %}
-    outer start
-    {% block inner %}
-        inner default
-    {% endblock %}
-    outer end
-{% endblock %}
---DATA--
-return []
---EXPECT--
-    outer wrap start
-    outer start
-    inner actual
-    outer end
-
-    outer wrap end
diff --git a/tests/Fixtures/tags/inheritance/extends_with_nested_blocks.test b/tests/Fixtures/tags/inheritance/extends_with_nested_blocks.test
deleted file mode 100644
index 496f278c..00000000
--- a/tests/Fixtures/tags/inheritance/extends_with_nested_blocks.test
+++ /dev/null
@@ -1,31 +0,0 @@
---TEST--
-"extends" tag
---TEMPLATE--
-{% extends "parent.twig" %}
-
-{% block outer %}
-    outer wrap start
-    {{~ parent() }}
-    outer wrap end
-{% endblock %}
-
-{% block inner -%}
-    inner actual
-{% endblock %}
---TEMPLATE(parent.twig)--
-{% block outer %}
-    outer start
-    {% block inner %}
-        inner default
-    {% endblock %}
-    outer end
-{% endblock %}
---DATA--
-return []
---EXPECT--
-    outer wrap start
-    outer start
-    inner actual
-    outer end
-
-    outer wrap end