diff --git a/CHANGELOG b/CHANGELOG index 66db8fff221..71eb108199b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,6 @@ # 3.19.0 (2025-XX-XX) - * n/a + * Add `LastModifiedExtensionInterface` and implementation in `AbstractExtension` to track modification of runtime classes # 3.18.0 (2024-12-29) diff --git a/doc/advanced.rst b/doc/advanced.rst index e07764b8770..bc7e5d376f4 100644 --- a/doc/advanced.rst +++ b/doc/advanced.rst @@ -868,7 +868,7 @@ must be autoload-able):: // implement the logic to create an instance of $class // and inject its dependencies // most of the time, it means using your dependency injection container - if ('CustomRuntimeExtension' === $class) { + if ('CustomTwigRuntime' === $class) { return new $class(new Rot13Provider()); } else { // ... @@ -884,9 +884,9 @@ must be autoload-able):: (``\Twig\RuntimeLoader\ContainerRuntimeLoader``). It is now possible to move the runtime logic to a new -``CustomRuntimeExtension`` class and use it directly in the extension:: +``CustomTwigRuntime`` class and use it directly in the extension:: - class CustomRuntimeExtension + class CustomTwigRuntime { private $rot13Provider; @@ -906,13 +906,21 @@ It is now possible to move the runtime logic to a new public function getFunctions() { return [ - new \Twig\TwigFunction('rot13', ['CustomRuntimeExtension', 'rot13']), + new \Twig\TwigFunction('rot13', ['CustomTwigRuntime', 'rot13']), // or - new \Twig\TwigFunction('rot13', 'CustomRuntimeExtension::rot13'), + new \Twig\TwigFunction('rot13', 'CustomTwigRuntime::rot13'), ]; } } +.. note:: + + The extension class should implement the ``Twig\Extension\LastModifiedExtensionInterface`` + interface to invalidate the template cache when the runtime class is modified. + The ``AbstractExtension`` class implements this interface and tracks the + runtime class if its name is the same as the extension class but ends with + ``Runtime`` instead of ``Extension``. + Testing an Extension -------------------- diff --git a/src/Extension/AbstractExtension.php b/src/Extension/AbstractExtension.php index a1b083b6884..4234df087f6 100644 --- a/src/Extension/AbstractExtension.php +++ b/src/Extension/AbstractExtension.php @@ -11,7 +11,7 @@ namespace Twig\Extension; -abstract class AbstractExtension implements ExtensionInterface +abstract class AbstractExtension implements LastModifiedExtensionInterface { public function getTokenParsers() { @@ -42,4 +42,21 @@ public function getOperators() { return [[], []]; } + + public function getLastModified(): int + { + $filename = (new \ReflectionClass($this))->getFileName(); + if (!is_file($filename)) { + return 0; + } + + $lastModified = filemtime($filename); + + // Track modifications of the runtime class if it exists and follows the naming convention + if (str_ends_with($filename, 'Extension.php') && is_file($filename = substr($filename, 0, -13) . 'Runtime.php')) { + $lastModified = max($lastModified, filemtime($filename)); + } + + return $lastModified; + } } diff --git a/src/Extension/EscaperExtension.php b/src/Extension/EscaperExtension.php index 52531c436b5..9a05e4c7c65 100644 --- a/src/Extension/EscaperExtension.php +++ b/src/Extension/EscaperExtension.php @@ -57,6 +57,14 @@ public function getFilters(): array ]; } + public function getLastModified(): int + { + return max( + parent::getLastModified(), + filemtime((new \ReflectionClass(EscaperRuntime::class))->getFileName()), + ); + } + /** * @deprecated since Twig 3.10 */ diff --git a/src/Extension/LastModifiedExtensionInterface.php b/src/Extension/LastModifiedExtensionInterface.php new file mode 100644 index 00000000000..4bab0c07cd3 --- /dev/null +++ b/src/Extension/LastModifiedExtensionInterface.php @@ -0,0 +1,23 @@ +lastModified; } + $lastModified = 0; foreach ($this->extensions as $extension) { - $r = new \ReflectionObject($extension); - if (is_file($r->getFileName()) && ($extensionTime = filemtime($r->getFileName())) > $this->lastModified) { - $this->lastModified = $extensionTime; + if ($extension instanceof LastModifiedExtensionInterface) { + $lastModified = max($extension->getLastModified(), $lastModified); + } else { + $r = new \ReflectionObject($extension); + if (is_file($r->getFileName())) { + $lastModified = max(filemtime($r->getFileName()), $lastModified); + } } } - return $this->lastModified; + return $this->lastModified = $lastModified; } public function addExtension(ExtensionInterface $extension): void diff --git a/tests/Extension/CoreTest.php b/tests/Extension/CoreTest.php index 6bb74807f86..edce0d3eede 100644 --- a/tests/Extension/CoreTest.php +++ b/tests/Extension/CoreTest.php @@ -393,6 +393,11 @@ public function testSandboxedIncludeWithPreloadedTemplate() $this->expectException(SecurityError::class); $twig->render('index'); } + + public function testLastModified() + { + $this->assertGreaterThan(1000000000, (new CoreExtension())->getLastModified()); + } } final class CoreTestIteratorAggregate implements \IteratorAggregate diff --git a/tests/Extension/EscaperTest.php b/tests/Extension/EscaperTest.php index 436d1790f52..28df82db570 100644 --- a/tests/Extension/EscaperTest.php +++ b/tests/Extension/EscaperTest.php @@ -70,6 +70,11 @@ public function testCustomEscapersOnMultipleEnvs() $this->assertSame('foo**ISO-8859-1**UTF-8', $env1->getRuntime(EscaperRuntime::class)->escape('foo', 'foo', 'ISO-8859-1')); $this->assertSame('foo**ISO-8859-1**UTF-8**again', $env2->getRuntime(EscaperRuntime::class)->escape('foo', 'foo', 'ISO-8859-1')); } + + public function testLastModified() + { + $this->assertGreaterThan(1000000000, (new EscaperExtension())->getLastModified()); + } } function legacy_escaper(Environment $twig, $string, $charset)