feature #4513 Add getLastModified() to extensions (GromNaN)

This PR was merged into the 3.x branch.

Discussion
----------

Add `getLastModified()` to extensions

Give to extensions the ability to set a last modification date for cache invalidation.

### Runtime

Currently, the cache is not invalidated when the signature of a runtime method is modified. This is an issue for templates that use named arguments, as argument names have an impact on the generated class.

With this change, extensions using runtime classes can compute a modification date by including the files on which they depend.

By default, the `AbstractExtension` checks if there is a file for the runtime class with the same name of the
This is the convention applied in [Symfony](https://github.com/symfony/symfony/tree/7.3/src/Symfony/Bridge/Twig/Extension) and Twig Extra: `MarkdownExtension` has `MarkdownRuntime`.

### Attribute

Contributing to https://github.com/twigphp/Twig/pull/3916.

The extension class that will get the configuration from attributes will be able to track the classes having attributes to find the last modification date of all this classes.

### ~BC break~

~In Twig 4.0, the method `getLastModified` will be added to `ExtensionInterface`. It is extremely rare to implement this interface without extending `AbstractExtension`. So adding this method to the interface shouldn't be a problem as the base class has an implementation.~

Commits
-------

d8fe3bd1ec Add `LastModifiedExtensionInterface` and implementation in `AbstractExtension` to track modification of runtime classes
This commit is contained in:
Fabien Potencier
2025-01-03 09:34:59 +01:00
8 changed files with 83 additions and 11 deletions
+1 -1
View File
@@ -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)
+13 -5
View File
@@ -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
--------------------
+18 -1
View File
@@ -11,7 +11,7 @@
namespace Twig\Extension;
abstract class AbstractExtension implements ExtensionInterface
abstract class AbstractExtension implements LastModifiedExtensionInterface
{
public function getTokenParsers()
{
@@ -42,4 +42,21 @@ abstract class AbstractExtension implements ExtensionInterface
{
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;
}
}
+8
View File
@@ -57,6 +57,14 @@ final class EscaperExtension extends AbstractExtension
];
}
public function getLastModified(): int
{
return max(
parent::getLastModified(),
filemtime((new \ReflectionClass(EscaperRuntime::class))->getFileName()),
);
}
/**
* @deprecated since Twig 3.10
*/
@@ -0,0 +1,23 @@
<?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\Extension;
interface LastModifiedExtensionInterface extends ExtensionInterface
{
/**
* Returns the last modification time of the extension for cache invalidation.
*
* This timestamp should be the last time the source code of the extension class
* and all its dependencies were modified (including the Runtime class).
*/
public function getLastModified(): int;
}
+10 -4
View File
@@ -14,6 +14,7 @@ namespace Twig;
use Twig\Error\RuntimeError;
use Twig\Extension\ExtensionInterface;
use Twig\Extension\GlobalsInterface;
use Twig\Extension\LastModifiedExtensionInterface;
use Twig\Extension\StagingExtension;
use Twig\Node\Expression\Binary\AbstractBinary;
use Twig\Node\Expression\Unary\AbstractUnary;
@@ -116,14 +117,19 @@ final class ExtensionSet
return $this->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
+5
View File
@@ -393,6 +393,11 @@ class CoreTest extends TestCase
$this->expectException(SecurityError::class);
$twig->render('index');
}
public function testLastModified()
{
$this->assertGreaterThan(1000000000, (new CoreExtension())->getLastModified());
}
}
final class CoreTestIteratorAggregate implements \IteratorAggregate
+5
View File
@@ -70,6 +70,11 @@ class EscaperTest extends TestCase
$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)