Repository: twigphp/Twig
Pull request #3916: Create attributes `AsTwigFilter`, `AsTwigFunction` and `AsTwigTest` to ease extension development
Author: login: GromNaN
Created: 2023-11-26T00:15:57Z | Merged: 2025-03-24T21:34:31Z

One drawback to writing extensions at present is that the declaration of functions/filters/tests is not directly adjacent to the methods. It's worse for runtime extensions because they need to be in 2 different classes. See [`SerializerExtension`](https://github.com/symfony/symfony/blob/7.0/src/Symfony/Bridge/Twig/Extension/SerializerExtension.php) and [`SerializerRuntime`](https://github.com/symfony/symfony/blob/7.0/src/Symfony/Bridge/Twig/Extension/SerializerRuntime.php) as an example.

By using attributes for filters, functions and tests definition, we can make writing extensions more expressive, and use reflection to detect particular options (`needs_environment`, `needs_context`, `is_variadic`).

Example if we implemented the `formatDate` filter: https://github.com/twigphp/Twig/blob/aeeec9a5e907a79e50a6bb78979154599401726e/extra/intl-extra/IntlExtension.php#L392-L395

By using the `AsTwigFilter` attribute, it is not necessary to create the `getFilters()` method. The `needs_environment` option is detected from method signature. The name is still required as the method naming convention (camelCase) doesn't match with Twig naming convention (snake_case).

```php
use Twig\Extension\Attribute\AsTwigFilter;

class IntlExtension
{
    #[AsTwigFilter(name: 'format_date')]
    public function formatDate(Environment $env, $date, ?string $dateFormat = 'medium', string $pattern = '', $timezone = null, string $calendar = 'gregorian', string $locale = null): string
    {
        return $this->formatDateTime($env, $date, $dateFormat, 'none', $pattern, $timezone, $calendar, $locale);
    }
}
```

This approach does not totally replace the current definition of extensions, which is still necessary for advanced needs. It does, however, make for more pleasant reading and writing. 

This makes writing lazy-loaded runtime extension the easiest way to create Twig extension in Symfony: https://github.com/symfony/symfony/pull/52748

Related to https://github.com/symfony/symfony/issues/50016

Is there any need to cache the parsing of method attributes? They are only read at compile time, but that can have a performance impact during development or when using dynamic templates.


Diff:
diff --git a/doc/advanced.rst b/doc/advanced.rst
index 432892aefe8..47683c44d69 100644
--- a/doc/advanced.rst
+++ b/doc/advanced.rst
@@ -814,6 +814,107 @@ The ``getTests()`` method lets you add new test functions::
         // ...
     }
 
+Using PHP Attributes to define Extensions
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 3.21
+
+    The attribute classes were added in Twig 3.21.
+
+You can add the ``#[AsTwigFilter]``, ``#[AsTwigFunction]``, and ``#[AsTwigTest]``
+attributes to public methods of any class to define filters, functions, and tests.
+
+Create a class using these attributes::
+
+    use Twig\Attribute\AsTwigFilter;
+    use Twig\Attribute\AsTwigFunction;
+    use Twig\Attribute\AsTwigTest;
+
+    class ProjectExtension
+    {
+        #[AsTwigFilter('rot13')]
+        public static function rot13(string $string): string
+        {
+            // ...
+        }
+
+        #[AsTwigFunction('lipsum')]
+        public static function lipsum(int $count): string
+        {
+            // ...
+        }
+
+        #[AsTwigTest('even')]
+        public static function isEven(int $number): bool
+        {
+            // ...
+        }
+    }
+
+Then register the ``Twig\Extension\AttributeExtension`` with the class name::
+
+    $twig = new \Twig\Environment($loader);
+    $twig->addExtension(new \Twig\Extension\AttributeExtension(ProjectExtension::class));
+
+If all the methods are static, you are done. The ``ProjectExtension`` class will
+never be instantiated and the class attributes will be scanned only when a template
+is compiled.
+
+Otherwise, if some methods are not static, you need to register the class as
+a runtime extension using one of the runtime loaders::
+
+    use Twig\Attribute\AsTwigFunction;
+
+    class ProjectExtension
+    {
+        // Inject hypothetical dependencies
+        public function __construct(private LipsumProvider $lipsumProvider) {}
+
+        #[AsTwigFunction('lipsum')]
+        public function lipsum(int $count): string
+        {
+            return $this->lipsumProvider->lipsum($count);
+        }
+    }
+
+    $twig = new \Twig\Environment($loader);
+    $twig->addExtension(new \Twig\Extension\AttributeExtension(ProjectExtension::class);
+    $twig->addRuntimeLoader(new \Twig\RuntimeLoader\FactoryLoader([
+        ProjectExtension::class => function () use ($lipsumProvider) {
+            return new ProjectExtension($lipsumProvider);
+        },
+    ]));
+
+If you want to access the current environment instance in your filter or function,
+add the ``Twig\Environment`` type to the first argument of the method::
+
+    class ProjectExtension
+    {
+        #[AsTwigFunction('lipsum')]
+        public function lipsum(\Twig\Environment $env, int $count): string
+        {
+            // ...
+        }
+    }
+
+``#[AsTwigFilter]`` and ``#[AsTwigFunction]`` support variadic arguments
+automatically when applied to variadic methods::
+
+    class ProjectExtension
+    {
+        #[AsTwigFilter('thumbnail')]
+        public function thumbnail(string $file, mixed ...$options): string
+        {
+            // ...
+        }
+    }
+
+The attributes support other options used to configure the Twig Callables:
+
+ * ``AsTwigFilter``: ``needsCharset``, ``needsEnvironment``, ``needsContext``, ``isSafe``, ``isSafeCallback``, ``preEscape``, ``preservesSafety``, ``deprecationInfo``
+ * ``AsTwigFunction``: ``needsCharset``, ``needsEnvironment``, ``needsContext``, ``isSafe``, ``isSafeCallback``, ``deprecationInfo``
+ * ``AsTwigTest``: ``needsCharset``, ``needsEnvironment``, ``needsContext``, ``deprecationInfo``
+
 Definition vs Runtime
 ~~~~~~~~~~~~~~~~~~~~~
 
diff --git a/src/Attribute/AsTwigFilter.php b/src/Attribute/AsTwigFilter.php
new file mode 100644
index 00000000000..395531d8650
--- /dev/null
+++ b/src/Attribute/AsTwigFilter.php
@@ -0,0 +1,56 @@
+<?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\Attribute;
+
+use Twig\DeprecatedCallableInfo;
+use Twig\TwigFilter;
+
+/**
+ * Registers a method as template filter.
+ *
+ * If the first argument of the method has Twig\Environment type-hint, the filter will receive the current environment.
+ * Additional arguments of the method come from the filter call.
+ *
+ *     #[AsTwigFilter(name: 'foo')]
+ *     function fooFilter(Environment $env, $string, $arg1 = null, ...) { ... }
+ *
+ *    {{ 'string'|foo(arg1) }}
+ *
+ * @see TwigFilter
+ */
+#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+final class AsTwigFilter
+{
+    /**
+     * @param non-empty-string $name The name of the filter in Twig.
+     * @param bool|null $needsCharset Whether the filter needs the charset passed as the first argument.
+     * @param bool|null $needsEnvironment Whether the filter needs the environment passed as the first argument, or after the charset.
+     * @param bool|null $needsContext Whether the filter needs the context array passed as the first argument, or after the charset and the environment.
+     * @param string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped.
+     * @param string|array|null $isSafeCallback Function called at compilation time to determine if the filter is safe.
+     * @param string|null $preEscape Some filters may need to work on input that is already escaped or safe
+     * @param string[]|null $preservesSafety Preserves the safety of the value that the filter is applied to.
+     * @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation
+     */
+    public function __construct(
+        public string $name,
+        public ?bool $needsCharset = null,
+        public ?bool $needsEnvironment = null,
+        public ?bool $needsContext = null,
+        public ?array $isSafe = null,
+        public string|array|null $isSafeCallback = null,
+        public ?string $preEscape = null,
+        public ?array $preservesSafety = null,
+        public ?DeprecatedCallableInfo $deprecationInfo = null,
+    ) {
+    }
+}
diff --git a/src/Attribute/AsTwigFunction.php b/src/Attribute/AsTwigFunction.php
new file mode 100644
index 00000000000..9229d128ef0
--- /dev/null
+++ b/src/Attribute/AsTwigFunction.php
@@ -0,0 +1,52 @@
+<?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\Attribute;
+
+use Twig\DeprecatedCallableInfo;
+use Twig\TwigFunction;
+
+/**
+ * Registers a method as template function.
+ *
+ * If the first argument of the method has Twig\Environment type-hint, the function will receive the current environment.
+ * Additional arguments of the method come from the function call.
+ *
+ *     #[AsTwigFunction(name: 'foo')]
+ *     function fooFunction(Environment $env, string $string, $arg1 = null, ...) { ... }
+ *
+ *     {{ foo('string', arg1) }}
+ *
+ * @see TwigFunction
+ */
+#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+final class AsTwigFunction
+{
+    /**
+     * @param non-empty-string $name The name of the function in Twig.
+     * @param bool|null $needsCharset Whether the function needs the charset passed as the first argument.
+     * @param bool|null $needsEnvironment Whether the function needs the environment passed as the first argument, or after the charset.
+     * @param bool|null $needsContext Whether the function needs the context array passed as the first argument, or after the charset and the environment.
+     * @param string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped.
+     * @param string|array|null $isSafeCallback Function called at compilation time to determine if the function is safe.
+     * @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation
+     */
+    public function __construct(
+        public string $name,
+        public ?bool $needsCharset = null,
+        public ?bool $needsEnvironment = null,
+        public ?bool $needsContext = null,
+        public ?array $isSafe = null,
+        public string|array|null $isSafeCallback = null,
+        public ?DeprecatedCallableInfo $deprecationInfo = null,
+    ) {
+    }
+}
diff --git a/src/Attribute/AsTwigTest.php b/src/Attribute/AsTwigTest.php
new file mode 100644
index 00000000000..40eddf83e36
--- /dev/null
+++ b/src/Attribute/AsTwigTest.php
@@ -0,0 +1,48 @@
+<?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\Attribute;
+
+use Twig\DeprecatedCallableInfo;
+use Twig\TwigTest;
+
+/**
+ * Registers a method as template test.
+ *
+ * The first argument is the value to test and the other arguments are the
+ * arguments passed to the test in the template.
+ *
+ *     #[AsTwigTest(name: 'foo')]
+ *     public function fooTest($value, $arg1 = null) { ... }
+ *
+ *     {% if value is foo(arg1) %}
+ *
+ * @see TwigTest
+ */
+#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+final class AsTwigTest
+{
+    /**
+     * @param non-empty-string $name The name of the test in Twig.
+     * @param bool|null $needsCharset Whether the test needs the charset passed as the first argument.
+     * @param bool|null $needsEnvironment Whether the test needs the environment passed as the first argument, or after the charset.
+     * @param bool|null $needsContext Whether the test needs the context array passed as the first argument, or after the charset and the environment.
+     * @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation
+     */
+    public function __construct(
+        public string $name,
+        public ?bool $needsCharset = null,
+        public ?bool $needsEnvironment = null,
+        public ?bool $needsContext = null,
+        public ?DeprecatedCallableInfo $deprecationInfo = null,
+    ) {
+    }
+}
diff --git a/src/Extension/AttributeExtension.php b/src/Extension/AttributeExtension.php
new file mode 100644
index 00000000000..44e4f3f6bdd
--- /dev/null
+++ b/src/Extension/AttributeExtension.php
@@ -0,0 +1,174 @@
+<?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;
+
+use Twig\Attribute\AsTwigFilter;
+use Twig\Attribute\AsTwigFunction;
+use Twig\Attribute\AsTwigTest;
+use Twig\Environment;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Define Twig filters, functions, and tests with PHP attributes.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class AttributeExtension extends AbstractExtension
+{
+    private array $filters;
+    private array $functions;
+    private array $tests;
+
+    /**
+     * Use a runtime class using PHP attributes to define filters, functions, and tests.
+     *
+     * @param class-string $class
+     */
+    public function __construct(private string $class)
+    {
+    }
+
+    /**
+     * @return class-string
+     */
+    public function getClass(): string
+    {
+        return $this->class;
+    }
+
+    public function getFilters(): array
+    {
+        if (!isset($this->filters)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->filters;
+    }
+
+    public function getFunctions(): array
+    {
+        if (!isset($this->functions)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->functions;
+    }
+
+    public function getTests(): array
+    {
+        if (!isset($this->tests)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->tests;
+    }
+
+    public function getLastModified(): int
+    {
+        return max(
+            filemtime(__FILE__),
+            is_file($filename = (new \ReflectionClass($this->getClass()))->getFileName()) ? filemtime($filename) : 0,
+        );
+    }
+
+    private function initFromAttributes(): void
+    {
+        $filters = $functions = $tests = [];
+        $reflectionClass = new \ReflectionClass($this->getClass());
+        foreach ($reflectionClass->getMethods() as $method) {
+            foreach ($method->getAttributes(AsTwigFilter::class) as $reflectionAttribute) {
+                /** @var AsTwigFilter $attribute */
+                $attribute = $reflectionAttribute->newInstance();
+
+                $callable = new TwigFilter($attribute->name, [$reflectionClass->name, $method->getName()], [
+                    'needs_context' => $attribute->needsContext ?? false,
+                    'needs_environment' => $attribute->needsEnvironment ?? $this->needsEnvironment($method),
+                    'needs_charset' => $attribute->needsCharset ?? false,
+                    'is_variadic' => $method->isVariadic(),
+                    'is_safe' => $attribute->isSafe,
+                    'is_safe_callback' => $attribute->isSafeCallback,
+                    'pre_escape' => $attribute->preEscape,
+                    'preserves_safety' => $attribute->preservesSafety,
+                    'deprecation_info' => $attribute->deprecationInfo,
+                ]);
+
+                if ($callable->getMinimalNumberOfRequiredArguments() > $method->getNumberOfParameters()) {
+                    throw new \LogicException(sprintf('"%s::%s()" needs at least %d arguments to be used AsTwigFilter, but only %d defined.', $reflectionClass->getName(), $method->getName(), $callable->getMinimalNumberOfRequiredArguments(), $method->getNumberOfParameters()));
+                }
+
+                $filters[$attribute->name] = $callable;
+            }
+
+            foreach ($method->getAttributes(AsTwigFunction::class) as $reflectionAttribute) {
+                /** @var AsTwigFunction $attribute */
+                $attribute = $reflectionAttribute->newInstance();
+
+                $callable = new TwigFunction($attribute->name, [$reflectionClass->name, $method->getName()], [
+                    'needs_context' => $attribute->needsContext ?? false,
+                    'needs_environment' => $attribute->needsEnvironment ?? $this->needsEnvironment($method),
+                    'needs_charset' => $attribute->needsCharset ?? false,
+                    'is_variadic' => $method->isVariadic(),
+                    'is_safe' => $attribute->isSafe,
+                    'is_safe_callback' => $attribute->isSafeCallback,
+                    'deprecation_info' => $attribute->deprecationInfo,
+                ]);
+
+                if ($callable->getMinimalNumberOfRequiredArguments() > $method->getNumberOfParameters()) {
+                    throw new \LogicException(sprintf('"%s::%s()" needs at least %d arguments to be used AsTwigFunction, but only %d defined.', $reflectionClass->getName(), $method->getName(), $callable->getMinimalNumberOfRequiredArguments(), $method->getNumberOfParameters()));
+                }
+
+                $functions[$attribute->name] = $callable;
+            }
+
+            foreach ($method->getAttributes(AsTwigTest::class) as $reflectionAttribute) {
+
+                /** @var AsTwigTest $attribute */
+                $attribute = $reflectionAttribute->newInstance();
+
+                $callable = new TwigTest($attribute->name, [$reflectionClass->name, $method->getName()], [
+                    'needs_context' => $attribute->needsContext ?? false,
+                    'needs_environment' => $attribute->needsEnvironment ?? $this->needsEnvironment($method),
+                    'needs_charset' => $attribute->needsCharset ?? false,
+                    'is_variadic' => $method->isVariadic(),
+                    'deprecation_info' => $attribute->deprecationInfo,
+                ]);
+
+                if ($callable->getMinimalNumberOfRequiredArguments() > $method->getNumberOfParameters()) {
+                    throw new \LogicException(sprintf('"%s::%s()" needs at least %d arguments to be used AsTwigTest, but only %d defined.', $reflectionClass->getName(), $method->getName(), $callable->getMinimalNumberOfRequiredArguments(), $method->getNumberOfParameters()));
+                }
+
+                $tests[$attribute->name] = $callable;
+            }
+        }
+
+        // Assign all at the end to avoid inconsistent state in case of exception
+        $this->filters = array_values($filters);
+        $this->functions = array_values($functions);
+        $this->tests = array_values($tests);
+    }
+
+    /**
+     * Detect if the first argument of the method is the environment.
+     */
+    private function needsEnvironment(\ReflectionFunctionAbstract $function): bool
+    {
+        if (!$parameters = $function->getParameters()) {
+            return false;
+        }
+
+        return $parameters[0]->getType() instanceof \ReflectionNamedType
+            && Environment::class === $parameters[0]->getType()->getName()
+            && !$parameters[0]->isVariadic();
+    }
+}
diff --git a/src/ExtensionSet.php b/src/ExtensionSet.php
index 14b1d3e2958..abca18f14a3 100644
--- a/src/ExtensionSet.php
+++ b/src/ExtensionSet.php
@@ -18,6 +18,7 @@
 use Twig\ExpressionParser\InfixExpressionParserInterface;
 use Twig\ExpressionParser\PrecedenceChange;
 use Twig\ExpressionParser\Prefix\UnaryOperatorExpressionParser;
+use Twig\Extension\AttributeExtension;
 use Twig\Extension\ExtensionInterface;
 use Twig\Extension\GlobalsInterface;
 use Twig\Extension\LastModifiedExtensionInterface;
@@ -142,7 +143,11 @@ public function getLastModified(): int
 
     public function addExtension(ExtensionInterface $extension): void
     {
-        $class = $extension::class;
+        if ($extension instanceof AttributeExtension) {
+            $class = $extension->getClass();
+        } else {
+            $class = $extension::class;
+        }
 
         if ($this->initialized) {
             throw new \LogicException(\sprintf('Unable to register extension "%s" as extensions have already been initialized.', $class));
diff --git a/tests/Extension/AttributeExtensionTest.php b/tests/Extension/AttributeExtensionTest.php
new file mode 100644
index 00000000000..68e3bfeef99
--- /dev/null
+++ b/tests/Extension/AttributeExtensionTest.php
@@ -0,0 +1,160 @@
+<?php
+
+namespace Twig\Tests\Extension;
+
+use PHPUnit\Framework\TestCase;
+use Twig\DeprecatedCallableInfo;
+use Twig\Error\RuntimeError;
+use Twig\Extension\AttributeExtension;
+use Twig\ExtensionSet;
+use Twig\Tests\Extension\Fixtures\FilterWithoutValue;
+use Twig\Tests\Extension\Fixtures\TestWithoutValue;
+use Twig\Tests\Extension\Fixtures\ExtensionWithAttributes;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+class AttributeExtensionTest extends TestCase
+{
+    /**
+     * @dataProvider provideFilters
+     */
+    public function testFilter(string $name, string $method, array $options)
+    {
+        $extension = new AttributeExtension(ExtensionWithAttributes::class);
+        foreach ($extension->getFilters() as $filter) {
+            if ($filter->getName() === $name) {
+                $this->assertEquals(new TwigFilter($name, [ExtensionWithAttributes::class, $method], $options), $filter);
+
+                return;
+            }
+        }
+
+        $this->fail(sprintf('Filter "%s" is not registered.', $name));
+    }
+
+    public static function provideFilters()
+    {
+        yield 'with name' => ['foo', 'fooFilter', ['is_safe' => ['html']]];
+        yield 'with env' => ['with_env_filter', 'withEnvFilter', ['needs_environment' => true]];
+        yield 'with context' => ['with_context_filter', 'withContextFilter', ['needs_context' => true]];
+        yield 'with env and context' => ['with_env_and_context_filter', 'withEnvAndContextFilter', ['needs_environment' => true, 'needs_context' => true]];
+        yield 'variadic' => ['variadic_filter', 'variadicFilter', ['is_variadic' => true]];
+        yield 'deprecated' => ['deprecated_filter', 'deprecatedFilter', ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.2')]];
+        yield 'pattern' => ['pattern_*_filter', 'patternFilter', []];
+    }
+
+    /**
+     * @dataProvider provideFunctions
+     */
+    public function testFunction(string $name, string $method, array $options)
+    {
+        $extension = new AttributeExtension(ExtensionWithAttributes::class);
+        foreach ($extension->getFunctions() as $function) {
+            if ($function->getName() === $name) {
+                $this->assertEquals(new TwigFunction($name, [ExtensionWithAttributes::class, $method], $options), $function);
+
+                return;
+            }
+        }
+
+        $this->fail(sprintf('Function "%s" is not registered.', $name));
+    }
+
+    public static function provideFunctions()
+    {
+        yield 'with name' => ['foo', 'fooFunction', ['is_safe' => ['html']]];
+        yield 'with env' => ['with_env_function', 'withEnvFunction', ['needs_environment' => true]];
+        yield 'with context' => ['with_context_function', 'withContextFunction', ['needs_context' => true]];
+        yield 'with env and context' => ['with_env_and_context_function', 'withEnvAndContextFunction', ['needs_environment' => true, 'needs_context' => true]];
+        yield 'no argument' => ['no_arg_function', 'noArgFunction', []];
+        yield 'variadic' => ['variadic_function', 'variadicFunction', ['is_variadic' => true]];
+        yield 'deprecated' => ['deprecated_function', 'deprecatedFunction', ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.2')]];
+    }
+
+    /**
+     * @dataProvider provideTests
+     */
+    public function testTest(string $name, string $method, array $options)
+    {
+        $extension = new AttributeExtension(ExtensionWithAttributes::class);
+        foreach ($extension->getTests() as $test) {
+            if ($test->getName() === $name) {
+                $this->assertEquals(new TwigTest($name, [ExtensionWithAttributes::class, $method], $options), $test);
+
+                return;
+            }
+        }
+
+        $this->fail(sprintf('Test "%s" is not registered.', $name));
+    }
+
+    public static function provideTests()
+    {
+        yield 'with name' => ['foo', 'fooTest', []];
+        yield 'with env' => ['with_env_test', 'withEnvTest', ['needs_environment' => true]];
+        yield 'with context' => ['with_context_test', 'withContextTest', ['needs_context' => true]];
+        yield 'with env and context' => ['with_env_and_context_test', 'withEnvAndContextTest', ['needs_environment' => true, 'needs_context' => true]];
+        yield 'variadic' => ['variadic_test', 'variadicTest', ['is_variadic' => true]];
+        yield 'deprecated' => ['deprecated_test', 'deprecatedTest', ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.2')]];
+    }
+
+    public function testFilterRequireOneArgument()
+    {
+        $extension = new AttributeExtension(FilterWithoutValue::class);
+
+        $this->expectException(\LogicException::class);
+        $this->expectExceptionMessage('"'.FilterWithoutValue::class.'::myFilter()" needs at least 1 arguments to be used AsTwigFilter, but only 0 defined.');
+
+        $extension->getTests();
+    }
+
+    public function testTestRequireOneArgument()
+    {
+        $extension = new AttributeExtension(TestWithoutValue::class);
+
+        $this->expectException(\LogicException::class);
+        $this->expectExceptionMessage('"'.TestWithoutValue::class.'::myTest()" needs at least 1 arguments to be used AsTwigTest, but only 0 defined.');
+
+        $extension->getTests();
+    }
+
+    public function testLastModifiedWithObject()
+    {
+        $extension = new AttributeExtension(\stdClass::class);
+
+        $this->assertSame(filemtime((new \ReflectionClass(AttributeExtension::class))->getFileName()), $extension->getLastModified());
+    }
+
+    public function testLastModifiedWithClass()
+    {
+        $extension = new AttributeExtension('__CLASS_FOR_TEST_LAST_MODIFIED__');
+
+        $filename = tempnam(sys_get_temp_dir(), 'twig');
+        try {
+            file_put_contents($filename, '<?php class __CLASS_FOR_TEST_LAST_MODIFIED__ {}');
+            require $filename;
+
+            $this->assertSame(filemtime($filename), $extension->getLastModified());
+        } finally {
+            unlink($filename);
+        }
+    }
+
+    public function testMultipleRegistrations()
+    {
+        $extensionSet = new ExtensionSet();
+        $extensionSet->addExtension($extension1 = new AttributeExtension(ExtensionWithAttributes::class));
+        $extensionSet->addExtension($extension2 = new AttributeExtension(\stdClass::class));
+
+        $this->assertCount(2, $extensionSet->getExtensions());
+        $this->assertNotNull($extensionSet->getFilter('foo'));
+
+        $this->assertSame($extension1, $extensionSet->getExtension(ExtensionWithAttributes::class));
+        $this->assertSame($extension2, $extensionSet->getExtension(\stdClass::class));
+
+        $this->expectException(RuntimeError::class);
+        $this->expectExceptionMessage('The "Twig\Extension\AttributeExtension" extension is not enabled.');
+        $extensionSet->getExtension(AttributeExtension::class);
+    }
+}
diff --git a/tests/Extension/Fixtures/ExtensionWithAttributes.php b/tests/Extension/Fixtures/ExtensionWithAttributes.php
new file mode 100644
index 00000000000..2e6b0ee1011
--- /dev/null
+++ b/tests/Extension/Fixtures/ExtensionWithAttributes.php
@@ -0,0 +1,112 @@
+<?php
+
+namespace Twig\Tests\Extension\Fixtures;
+
+use Twig\Attribute\AsTwigFilter;
+use Twig\Attribute\AsTwigFunction;
+use Twig\Attribute\AsTwigTest;
+use Twig\DeprecatedCallableInfo;
+use Twig\Environment;
+
+class ExtensionWithAttributes
+{
+    #[AsTwigFilter(name: 'foo', isSafe: ['html'])]
+    public function fooFilter(string|int $string)
+    {
+    }
+
+    #[AsTwigFilter('with_context_filter', needsContext: true)]
+    public function withContextFilter(array $context, string $string)
+    {
+    }
+
+    #[AsTwigFilter('with_env_filter')]
+    public function withEnvFilter(Environment $env, string $string)
+    {
+    }
+
+    #[AsTwigFilter('with_env_and_context_filter', needsContext: true)]
+    public function withEnvAndContextFilter(Environment $env, array $context, array $data)
+    {
+    }
+
+    #[AsTwigFilter('variadic_filter')]
+    public function variadicFilter(string ...$strings)
+    {
+    }
+
+    #[AsTwigFilter('deprecated_filter', deprecationInfo: new DeprecatedCallableInfo('foo/bar', '1.2'))]
+    public function deprecatedFilter(string $string)
+    {
+    }
+
+    #[AsTwigFilter('pattern_*_filter')]
+    public function patternFilter(string $string)
+    {
+    }
+
+    #[AsTwigFunction(name: 'foo', isSafe: ['html'])]
+    public function fooFunction(string|int $string)
+    {
+    }
+
+    #[AsTwigFunction('with_context_function', needsContext: true)]
+    public function withContextFunction(array $context, string $string)
+    {
+    }
+
+    #[AsTwigFunction('with_env_function')]
+    public function withEnvFunction(Environment $env, string $string)
+    {
+    }
+
+    #[AsTwigFunction('with_env_and_context_function', needsContext: true)]
+    public function withEnvAndContextFunction(Environment $env, array $context, string $string)
+    {
+    }
+
+    #[AsTwigFunction('no_arg_function')]
+    public function noArgFunction()
+    {
+    }
+
+    #[AsTwigFunction('variadic_function')]
+    public function variadicFunction(string ...$strings)
+    {
+    }
+
+    #[AsTwigFunction('deprecated_function', deprecationInfo: new DeprecatedCallableInfo('foo/bar', '1.2'))]
+    public function deprecatedFunction(string $string)
+    {
+    }
+
+    #[AsTwigTest(name: 'foo')]
+    public function fooTest(string|int $value)
+    {
+    }
+
+    #[AsTwigTest('variadic_test')]
+    public function variadicTest(string ...$value)
+    {
+    }
+
+    #[AsTwigTest('with_context_test', needsContext: true)]
+    public function withContextTest(array $context, $argument)
+    {
+    }
+
+    #[AsTwigTest('with_env_test')]
+    public function withEnvTest(Environment $env, $argument)
+    {
+    }
+
+    #[AsTwigTest('with_env_and_context_test', needsContext: true)]
+    public function withEnvAndContextTest(Environment $env, array $context, $argument)
+    {
+    }
+
+    #[AsTwigTest('deprecated_test', deprecationInfo: new DeprecatedCallableInfo('foo/bar', '1.2'))]
+    public function deprecatedTest($value, $argument)
+    {
+    }
+}
diff --git a/tests/Extension/Fixtures/FilterWithoutValue.php b/tests/Extension/Fixtures/FilterWithoutValue.php
new file mode 100644
index 00000000000..a59efb55c1b
--- /dev/null
+++ b/tests/Extension/Fixtures/FilterWithoutValue.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace Twig\Tests\Extension\Fixtures;
+
+use Twig\Attribute\AsTwigFilter;
+
+class FilterWithoutValue
+{
+    #[AsTwigFilter('my_filter')]
+    public function myFilter()
+    {
+    }
+}
diff --git a/tests/Extension/Fixtures/TestWithoutValue.php b/tests/Extension/Fixtures/TestWithoutValue.php
new file mode 100644
index 00000000000..139f7852a06
--- /dev/null
+++ b/tests/Extension/Fixtures/TestWithoutValue.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace Twig\Tests\Extension\Fixtures;
+
+use Twig\Attribute\AsTwigTest;
+
+class TestWithoutValue
+{
+    #[AsTwigTest('my_test')]
+    public function myTest()
+    {
+    }
+}


Reviews:
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-11-26T00:37:27Z
user: login: derrabus
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-11-26T06:58:07Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-11-26T08:30:50Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-11-26T16:23:00Z
user: login: stof
state: COMMENTED
author_association: MEMBER
submitted_at: 2023-11-27T10:11:53Z
user: login: stof
state: COMMENTED
author_association: MEMBER
submitted_at: 2023-11-27T10:19:20Z
user: login: derrabus
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-11-30T06:51:25Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-11-30T08:48:05Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-11-30T08:48:43Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-11-30T08:51:41Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-11-30T10:33:16Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-11-30T13:27:00Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-11-30T13:31:15Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-12-01T08:18:47Z
user: login: smnandre
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-12-02T04:09:15Z
user: login: smnandre
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-12-02T04:21:21Z
user: login: smnandre
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-12-02T04:45:25Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-12-02T10:54:16Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-12-02T10:58:13Z
user: login: smnandre
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-12-02T12:47:03Z
user: login: smnandre
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-12-02T12:52:20Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-12-08T22:28:41Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-12-08T22:30:18Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-12-08T22:36:06Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-12-08T23:30:02Z
user: login: GromNaN
body: Ready for review @fabpot.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-12-10T21:42:33Z
user: login: smnandre
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2023-12-12T17:38:12Z
user: login: stof
state: COMMENTED
author_association: MEMBER
submitted_at: 2023-12-15T14:45:30Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-12-15T14:51:39Z
user: login: nicolas-grekas
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-12-26T15:50:28Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2023-12-26T20:04:09Z
user: login: GromNaN
body: > What is a bit strange is having one single instance of AttributeExtension be responsible for registering many runtimes. Maybe we should instead have one AttributeExtension per runtime? Then it might be easier to use this standalone : `addExtension(new AttributeExtension(new AttributeBasedRuntime)))` (and such runtimes could have a static factory to make this even easier to create). Note that this suggestion might be wrong as I didn't think of how runtimes should be made lazy-instantiated.

In the `ExtensionSet`, extensions are identified by their class name. The same extension class cannot be registered multiple times with distinct configuration.

I updated this PR so that extension can be registered directly using any class name or object that have the `#[AsTwigExtension]` attribute (the attribute becomes required).

```diff
    $twig = new \Twig\Environment($loader);
-    $twig->addExtension(new \Twig\Extension\AttributeExtension([
-        ProjectExtension::class,
-    ]));
+    $twig->addExtension(ProjectExtension::class);
    $twig->addRuntimeLoader(new \Twig\RuntimeLoader\FactoryLoader([
        ProjectExtension::class => function () use ($lipsumProvider) {
            return new ProjectExtension($lipsumProvider);
        },
    ]));
```

To use standalone (non-lazy-loaded):

```php
$twig->addExtension(new ProjectExtension($lipsumProvider));
```

state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-04-05T14:41:15Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-04-05T14:42:59Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2024-04-05T14:44:43Z
user: login: stof
state: COMMENTED
author_association: MEMBER
submitted_at: 2024-04-05T14:55:29Z
user: login: fabpot
body: Not an extensive review, just things I've spotted while reading the code quickly.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-02T11:37:00Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-02T12:02:03Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-03T12:20:25Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-03T12:21:15Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-01-03T21:04:00Z
user: login: GromNaN
body: The state of my reflection is that we would want a different implementation in Symfony TwigBundle that reads the attributes during container compilation and inject a pre-compiled instance of `AttributeExtension`.

This can be done in 2 ways:
- by serializing the `AttributeExtension` (I'm not sure for the performances),
- or by exposing an API to convert attributes to TwigCallable. Maybe creating a public method on the attribute classes `AsTwigFilter::getTwigCallable(\ReflectionFunctionAbstract $reflection): TwigFilter`
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:45:05Z
user: login: fabpot
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T08:56:31Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T11:02:30Z
user: login: fabpot
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T11:15:55Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T11:48:26Z
user: login: kbond
body: Love this!
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T12:47:37Z
user: login: stof
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-06T14:22:50Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T14:27:33Z
user: login: kbond
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T14:33:41Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T14:51:08Z
user: login: stof
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-06T14:53:18Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T15:05:52Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T15:39:57Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T17:19:12Z
user: login: GromNaN
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T17:29:10Z
user: login: fabpot
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T20:58:12Z
user: login: kbond
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T21:13:52Z

Review comments:
diff_hunk: @@ -0,0 +1,100 @@
+<?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;
+
+use Twig\Environment;
+use Twig\Extension\Attribute\AsTwigFilter;
+use Twig\Extension\Attribute\AsTwigFunction;
+use Twig\Extension\Attribute\AsTwigTest;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Abstract class for extension using the new PHP 8 attributes to define filters, functions, and tests.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+abstract class Extension extends AbstractExtension
path: src/Extension/Extension.php
user: login: GromNaN
body: Is it better to modify the existing `AbstractExtension`, create a new abstract class or a new trait? The trait would have to be used with the `AbstractExtension` or `ExtensionInterface`.
created_at: 2023-11-26T00:32:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,100 @@
+<?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;
+
+use Twig\Environment;
+use Twig\Extension\Attribute\AsTwigFilter;
+use Twig\Extension\Attribute\AsTwigFunction;
+use Twig\Extension\Attribute\AsTwigTest;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Abstract class for extension using the new PHP 8 attributes to define filters, functions, and tests.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+abstract class Extension extends AbstractExtension
+{
+    public function getFilters(): \Generator
+    {
+        $reflectionClass = new \ReflectionClass($this);
+        foreach ($reflectionClass->getMethods() as $method) {
+            foreach ($method->getAttributes(AsTwigFilter::class) as $attribute) {
+                $attribute = $attribute->newInstance();
+                $options = $attribute->options;
+                if (!\array_key_exists('needs_environment', $options)) {
+                    $param = $method->getParameters()[0] ?? null;
+                    $options['needs_environment'] = $param && 'env' === $param->getName() && Environment::class === $param->getType()->getName();
+                }
+                $firstParam = $options['needs_environment'] ? 1 : 0;
+                if (!\array_key_exists('needs_context', $options)) {
+                    $param = $method->getParameters()[$firstParam] ?? null;
+                    $options['needs_context'] = $param && 'context' === $param->getName() && 'array' === $param->getType()->getName();
+                }
+                $firstParam += $options['needs_context'] ? 1 : 0;
+                if (!\array_key_exists('is_variadic', $options)) {
+                    $param = $method->getParameters()[$firstParam] ?? null;
+                    $options['is_variadic'] = $param && $param->isVariadic();
+                }
+
+                yield new TwigFilter($attribute->name ?? $method->getName(), [$this, $method->getName()], $options);
path: src/Extension/Extension.php
user: login: GromNaN
body: Should the default name be converted from camelCase to snake_case, to match the Twig convention? This makes it more difficult to find the declaration of a twig function/filter/test in PHP source code. But it does ensure that developers follow the naming convention by default.
created_at: 2023-11-26T00:33:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,29 @@
+<?php
+
+namespace Twig\Extension\Attribute;
+
+use Twig\TwigFilter;
+
+/**
+ * Registers a method as template filter.
+ *
+ * @see TwigFilter
+ */
+#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+class AsTwigFilter
+{
+    public function __construct(
+        /**
+         * The name of the filter in Twig (defaults to the method name).
+         *
+         * @var non-empty-string|null $name
+         */
+        public ?string $name = null,
+
+        /**
+         * @var array{needs_environment?:bool, needs_context?:bool, is_variadic?:bool, is_safe?:array|null, is_safe_callback?:callable|null, pre_escape?:string|null, preserves_safety?:array|null, node_class?:class-string, deprecated?:bool|string, alternative?:string}
path: src/Extension/Attribute/AsTwigFilter.php
user: login: GromNaN
body: Since we can detect automatically if the function is variadic, needs the environment of the context, this option could be undocumented.
I'm not sure for the `node_class` either.
created_at: 2023-11-26T00:35:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,29 @@
+<?php
+
+namespace Twig\Extension\Attribute;
+
+use Twig\TwigFilter;
+
+/**
+ * Registers a method as template filter.
+ *
+ * @see TwigFilter
+ */
+#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+class AsTwigFilter
+{
+    public function __construct(
+        /**
+         * The name of the filter in Twig (defaults to the method name).
+         *
+         * @var non-empty-string|null $name
+         */
+        public ?string $name = null,
+
+        /**
+         * @var array{needs_environment?:bool, needs_context?:bool, is_variadic?:bool, is_safe?:array|null, is_safe_callback?:callable|null, pre_escape?:string|null, preserves_safety?:array|null, node_class?:class-string, deprecated?:bool|string, alternative?:string}
path: src/Extension/Attribute/AsTwigFilter.php
user: login: GromNaN
body: We could flatten the options in the class properties, which would undoubtedly be easier to read and benefit from native typing.
created_at: 2023-11-26T00:36:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,34 @@
+<?php
+
+namespace Twig\Extension\Attribute;
+
+use Twig\TwigTest;
+
+/**
+ * Registers a method as template test.
+ *
+ * @see TwigTest
+ */
+#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+class AsTwigTest
+{
+    public function __construct(
+        /**
+         * The name of the filter in Twig (defaults to the method name).
+         *
+         * @var non-empty-string|null $name
+         */
+        public ?string $name = null,
+
+        /**
+         * @var array{is_variadic?:bool, node_class?:class-string, deprecated?:bool|string, alternative?:string, one_mandatory_argument?:bool}
+         */
+        public array $options = [],
+
+        /**
+         * @var array<int, mixed>
+         */
+        public array $arguments = [],
path: src/Extension/Attribute/AsTwigTest.php
user: login: GromNaN
body: I'm not sure what `$arguments` is for.
created_at: 2023-11-26T00:37:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,29 @@
+<?php
+
+namespace Twig\Extension\Attribute;
+
+use Twig\TwigFilter;
+
+/**
+ * Registers a method as template filter.
+ *
+ * @see TwigFilter
+ */
+#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+class AsTwigFilter
+{
+    public function __construct(
+        /**
+         * The name of the filter in Twig (defaults to the method name).
+         *
+         * @var non-empty-string|null $name
+         */
+        public ?string $name = null,
+
+        /**
+         * @var array{is_safe?:array|null, is_safe_callback?:callable|null, pre_escape?:string|null, preserves_safety?:array|null, deprecated?:bool|string, alternative?:string}
+         */
+        public array $options = [],
path: src/Extension/Attribute/AsTwigFilter.php
user: login: derrabus
body: All those options should be dedicated properties on the attribute.
created_at: 2023-11-26T06:54:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,100 @@
+<?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;
+
+use Twig\Environment;
+use Twig\Extension\Attribute\AsTwigFilter;
+use Twig\Extension\Attribute\AsTwigFunction;
+use Twig\Extension\Attribute\AsTwigTest;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Abstract class for extension using the new PHP 8 attributes to define filters, functions, and tests.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+abstract class Extension extends AbstractExtension
path: src/Extension/Extension.php
user: login: derrabus
body: From a DX perspective: If I used the new attributes, why would I still want to extend `AbstractExtension` directly or though this class? If we introduce those attributes, I believe we should make them work on standalone classes.
created_at: 2023-11-26T06:57:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,100 @@
+<?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;
+
+use Twig\Environment;
+use Twig\Extension\Attribute\AsTwigFilter;
+use Twig\Extension\Attribute\AsTwigFunction;
+use Twig\Extension\Attribute\AsTwigTest;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Abstract class for extension using the new PHP 8 attributes to define filters, functions, and tests.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+abstract class Extension extends AbstractExtension
path: src/Extension/Extension.php
user: login: GromNaN
body: Right. I can create a wrapper class that implements the ExtensionInterface and scan one or many object methods, passed to the constructor, to find filters/functions/tests. That would be much better. 

We need a marker to hint Symfony DI that a class contains Twig extension attributes, to avoid scanning attributes of all methods of all classes in the project.
created_at: 2023-11-26T08:30:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,100 @@
+<?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;
+
+use Twig\Environment;
+use Twig\Extension\Attribute\AsTwigFilter;
+use Twig\Extension\Attribute\AsTwigFunction;
+use Twig\Extension\Attribute\AsTwigTest;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Abstract class for extension using the new PHP 8 attributes to define filters, functions, and tests.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+abstract class Extension extends AbstractExtension
path: src/Extension/Extension.php
user: login: GromNaN
body: The marker will be `RuntimeExtensionInterface`. This interface will soon find its purpose in Twig.
created_at: 2023-11-26T16:23:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,100 @@
+<?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;
+
+use Twig\Environment;
+use Twig\Extension\Attribute\AsTwigFilter;
+use Twig\Extension\Attribute\AsTwigFunction;
+use Twig\Extension\Attribute\AsTwigTest;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Abstract class for extension using the new PHP 8 attributes to define filters, functions, and tests.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+abstract class Extension extends AbstractExtension
+{
+    public function getFilters(): \Generator
+    {
+        $reflectionClass = new \ReflectionClass($this);
+        foreach ($reflectionClass->getMethods() as $method) {
+            foreach ($method->getAttributes(AsTwigFilter::class) as $attribute) {
+                $attribute = $attribute->newInstance();
+                $options = $attribute->options;
+                if (!\array_key_exists('needs_environment', $options)) {
+                    $param = $method->getParameters()[0] ?? null;
+                    $options['needs_environment'] = $param && 'env' === $param->getName() && Environment::class === $param->getType()->getName();
+                }
+                $firstParam = $options['needs_environment'] ? 1 : 0;
+                if (!\array_key_exists('needs_context', $options)) {
+                    $param = $method->getParameters()[$firstParam] ?? null;
+                    $options['needs_context'] = $param && 'context' === $param->getName() && 'array' === $param->getType()->getName();
+                }
+                $firstParam += $options['needs_context'] ? 1 : 0;
+                if (!\array_key_exists('is_variadic', $options)) {
+                    $param = $method->getParameters()[$firstParam] ?? null;
+                    $options['is_variadic'] = $param && $param->isVariadic();
+                }
+
+                yield new TwigFilter($attribute->name ?? $method->getName(), [$this, $method->getName()], $options);
path: src/Extension/Extension.php
user: login: stof
body: Given that Twig function names must be globally unique while method names are unique only to the class, I'm not even sure we want this automatic name. I'd rather make the name mandatory in the attribute.

Thus, this name is a public API of the Twig extension, so it means that any guessing logic we introduce is then set in stone as any change to it as a BC break.
created_at: 2023-11-27T10:11:53Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,179 @@
+<?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;
+
+use Twig\Environment;
+use Twig\Extension\Attribute\AsTwigFilter;
+use Twig\Extension\Attribute\AsTwigFunction;
+use Twig\Extension\Attribute\AsTwigTest;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Define Twig filters, functions, and tests with PHP attributes.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class AttributeExtension extends AbstractExtension implements WithLastModified
+{
+    private array $filters;
+    private array $functions;
+    private array $tests;
+
+    public function __construct(
+        /**
+         * A list of objects or class names defining filters, functions, and tests using PHP attributes.
+         * When passing a class name, it must be available in runtimes.
+         *
+         * @var iterable<object|class-string>
+         */
+        private iterable $objectsOrClasses,
+    ) {
+    }
+
+    public function getLastModified(): int
+    {
+        $lastModified = 0;
+
+        foreach ($this->objectsOrClasses as $objectOrClass) {
+            $r = new \ReflectionClass($objectOrClass);
+            if (is_file($r->getFileName()) && ($extensionTime = filemtime($r->getFileName())) > $lastModified) {
+                $lastModified = $extensionTime;
+            }
+        }
+
+        return $lastModified;
+    }
+
+    public function getFilters(): array
+    {
+        if (!isset($this->filters)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->filters;
+    }
+
+    public function getFunctions(): array
+    {
+        if (!isset($this->functions)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->functions;
+    }
+
+    public function getTests(): array
+    {
+        if (!isset($this->tests)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->tests;
+    }
+
+    private function initFromAttributes()
+    {
+        $filters = $functions = $tests = [];
+
+        foreach ($this->objectsOrClasses as $objectOrClass) {
+            try {
+                $reflectionClass = new \ReflectionClass($objectOrClass);
+            } catch (\ReflectionException $e) {
+                throw new \LogicException(sprintf('"%s" class requires a list of objects or class name, "%s" given.', __CLASS__, get_debug_type($objectOrClass)), 0, $e);
+            }
+
+            foreach ($reflectionClass->getMethods() as $method) {
+                // Filters
+                foreach ($method->getAttributes(AsTwigFilter::class) as $attribute) {
+                    $attribute = $attribute->newInstance();
+
+                    $name = $attribute->name ?? $method->getName();
+                    if (isset($filters[$name])) {
+                        throw new \LogicException(sprintf('Multiple definitions of the "%s" filter.', $name));
+                    }
+
+                    $parameters = $method->getParameters();
+                    $needsEnvironment = isset($parameters[0]) && Environment::class === $parameters[0]->getType()?->getName();
+                    $firstParam = $needsEnvironment ? 1 : 0;
+                    $needsContext = isset($parameters[$firstParam]) && 'context' === $parameters[$firstParam]->getName() && 'array' === $parameters[$firstParam]->getType()?->getName();
path: src/Extension/AttributeExtension.php
user: login: stof
body: this special behavior of parameter named `$context` needs to be documented on the attributes.
created_at: 2023-11-27T10:17:41Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,100 @@
+<?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;
+
+use Twig\Environment;
+use Twig\Extension\Attribute\AsTwigFilter;
+use Twig\Extension\Attribute\AsTwigFunction;
+use Twig\Extension\Attribute\AsTwigTest;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Abstract class for extension using the new PHP 8 attributes to define filters, functions, and tests.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+abstract class Extension extends AbstractExtension
path: src/Extension/Extension.php
user: login: derrabus
body: If we introduce all new attributes in this PR, can't the marker be, you know, an attribute? 🥺 
created_at: 2023-11-30T06:51:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,100 @@
+<?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;
+
+use Twig\Environment;
+use Twig\Extension\Attribute\AsTwigFilter;
+use Twig\Extension\Attribute\AsTwigFunction;
+use Twig\Extension\Attribute\AsTwigTest;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Abstract class for extension using the new PHP 8 attributes to define filters, functions, and tests.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+abstract class Extension extends AbstractExtension
+{
+    public function getFilters(): \Generator
+    {
+        $reflectionClass = new \ReflectionClass($this);
+        foreach ($reflectionClass->getMethods() as $method) {
+            foreach ($method->getAttributes(AsTwigFilter::class) as $attribute) {
+                $attribute = $attribute->newInstance();
+                $options = $attribute->options;
+                if (!\array_key_exists('needs_environment', $options)) {
+                    $param = $method->getParameters()[0] ?? null;
+                    $options['needs_environment'] = $param && 'env' === $param->getName() && Environment::class === $param->getType()->getName();
+                }
+                $firstParam = $options['needs_environment'] ? 1 : 0;
+                if (!\array_key_exists('needs_context', $options)) {
+                    $param = $method->getParameters()[$firstParam] ?? null;
+                    $options['needs_context'] = $param && 'context' === $param->getName() && 'array' === $param->getType()->getName();
+                }
+                $firstParam += $options['needs_context'] ? 1 : 0;
+                if (!\array_key_exists('is_variadic', $options)) {
+                    $param = $method->getParameters()[$firstParam] ?? null;
+                    $options['is_variadic'] = $param && $param->isVariadic();
+                }
+
+                yield new TwigFilter($attribute->name ?? $method->getName(), [$this, $method->getName()], $options);
path: src/Extension/Extension.php
user: login: GromNaN
body: I updated to make the function name required. A default value can always be set later.
created_at: 2023-11-30T08:48:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,179 @@
+<?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;
+
+use Twig\Environment;
+use Twig\Extension\Attribute\AsTwigFilter;
+use Twig\Extension\Attribute\AsTwigFunction;
+use Twig\Extension\Attribute\AsTwigTest;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Define Twig filters, functions, and tests with PHP attributes.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class AttributeExtension extends AbstractExtension implements WithLastModified
+{
+    private array $filters;
+    private array $functions;
+    private array $tests;
+
+    public function __construct(
+        /**
+         * A list of objects or class names defining filters, functions, and tests using PHP attributes.
+         * When passing a class name, it must be available in runtimes.
+         *
+         * @var iterable<object|class-string>
+         */
+        private iterable $objectsOrClasses,
+    ) {
+    }
+
+    public function getLastModified(): int
+    {
+        $lastModified = 0;
+
+        foreach ($this->objectsOrClasses as $objectOrClass) {
+            $r = new \ReflectionClass($objectOrClass);
+            if (is_file($r->getFileName()) && ($extensionTime = filemtime($r->getFileName())) > $lastModified) {
+                $lastModified = $extensionTime;
+            }
+        }
+
+        return $lastModified;
+    }
+
+    public function getFilters(): array
+    {
+        if (!isset($this->filters)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->filters;
+    }
+
+    public function getFunctions(): array
+    {
+        if (!isset($this->functions)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->functions;
+    }
+
+    public function getTests(): array
+    {
+        if (!isset($this->tests)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->tests;
+    }
+
+    private function initFromAttributes()
+    {
+        $filters = $functions = $tests = [];
+
+        foreach ($this->objectsOrClasses as $objectOrClass) {
+            try {
+                $reflectionClass = new \ReflectionClass($objectOrClass);
+            } catch (\ReflectionException $e) {
+                throw new \LogicException(sprintf('"%s" class requires a list of objects or class name, "%s" given.', __CLASS__, get_debug_type($objectOrClass)), 0, $e);
+            }
+
+            foreach ($reflectionClass->getMethods() as $method) {
+                // Filters
+                foreach ($method->getAttributes(AsTwigFilter::class) as $attribute) {
+                    $attribute = $attribute->newInstance();
+
+                    $name = $attribute->name ?? $method->getName();
+                    if (isset($filters[$name])) {
+                        throw new \LogicException(sprintf('Multiple definitions of the "%s" filter.', $name));
+                    }
+
+                    $parameters = $method->getParameters();
+                    $needsEnvironment = isset($parameters[0]) && Environment::class === $parameters[0]->getType()?->getName();
+                    $firstParam = $needsEnvironment ? 1 : 0;
+                    $needsContext = isset($parameters[$firstParam]) && 'context' === $parameters[$firstParam]->getName() && 'array' === $parameters[$firstParam]->getType()?->getName();
path: src/Extension/AttributeExtension.php
user: login: GromNaN
body: I added the doc and example in the attribute class header.
created_at: 2023-11-30T08:48:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,8 @@
+<?php
+
+namespace Twig\Extension;
+
+interface WithLastModified
path: src/Extension/WithLastModified.php
user: login: GromNaN
body: This is also a new feature required to detect when the class that defines attributes have been updated.
created_at: 2023-11-30T08:51:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,100 @@
+<?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;
+
+use Twig\Environment;
+use Twig\Extension\Attribute\AsTwigFilter;
+use Twig\Extension\Attribute\AsTwigFunction;
+use Twig\Extension\Attribute\AsTwigTest;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Abstract class for extension using the new PHP 8 attributes to define filters, functions, and tests.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+abstract class Extension extends AbstractExtension
path: src/Extension/Extension.php
user: login: GromNaN
body: I created the `AsTwigExtension` attribute as marker.
created_at: 2023-11-30T10:33:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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\Extension\Attribute;
path: src/Extension/Attribute/AsTwigExtension.php
user: login: GromNaN
body: Should it move to `Twig\Attribute` namespace? I see other libraries like Monolog and Symfony HttpKernel have the `Attribute` namespace at the root of their namespace.
created_at: 2023-11-30T13:26:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,179 @@
+<?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;
+
+use Twig\Environment;
+use Twig\Extension\Attribute\AsTwigFilter;
+use Twig\Extension\Attribute\AsTwigFunction;
+use Twig\Extension\Attribute\AsTwigTest;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Define Twig filters, functions, and tests with PHP attributes.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class AttributeExtension extends AbstractExtension implements WithLastModified
+{
+    private array $filters;
+    private array $functions;
+    private array $tests;
+
+    public function __construct(
+        /**
+         * A list of objects or class names defining filters, functions, and tests using PHP attributes.
+         * When passing a class name, it must be available in runtimes.
+         *
+         * @var iterable<object|class-string>
+         */
+        private iterable $objectsOrClasses,
+    ) {
+    }
+
+    public function getLastModified(): int
+    {
+        $lastModified = 0;
+
+        foreach ($this->objectsOrClasses as $objectOrClass) {
+            $r = new \ReflectionClass($objectOrClass);
+            if (is_file($r->getFileName()) && ($extensionTime = filemtime($r->getFileName())) > $lastModified) {
+                $lastModified = $extensionTime;
+            }
+        }
+
+        return $lastModified;
+    }
+
+    public function getFilters(): array
+    {
+        if (!isset($this->filters)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->filters;
+    }
+
+    public function getFunctions(): array
+    {
+        if (!isset($this->functions)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->functions;
+    }
+
+    public function getTests(): array
+    {
+        if (!isset($this->tests)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->tests;
+    }
+
+    private function initFromAttributes()
+    {
+        $filters = $functions = $tests = [];
+
+        foreach ($this->objectsOrClasses as $objectOrClass) {
+            try {
+                $reflectionClass = new \ReflectionClass($objectOrClass);
+            } catch (\ReflectionException $e) {
+                throw new \LogicException(sprintf('"%s" class requires a list of objects or class name, "%s" given.', __CLASS__, get_debug_type($objectOrClass)), 0, $e);
+            }
+
+            foreach ($reflectionClass->getMethods() as $method) {
+                // Filters
+                foreach ($method->getAttributes(AsTwigFilter::class) as $attribute) {
+                    $attribute = $attribute->newInstance();
+
+                    $name = $attribute->name ?? $method->getName();
+                    if (isset($filters[$name])) {
+                        throw new \LogicException(sprintf('Multiple definitions of the "%s" filter.', $name));
+                    }
+
+                    $parameters = $method->getParameters();
+                    $needsEnvironment = isset($parameters[0]) && Environment::class === $parameters[0]->getType()?->getName();
+                    $firstParam = $needsEnvironment ? 1 : 0;
+                    $needsContext = isset($parameters[$firstParam]) && 'context' === $parameters[$firstParam]->getName() && 'array' === $parameters[$firstParam]->getType()?->getName();
path: src/Extension/AttributeExtension.php
user: login: GromNaN
body: The [Symfony `serialize` filter](https://github.com/symfony/symfony/blob/2d88c405ba0de17aebcf5df3de79b12f3fdb297a/src/Symfony/Bridge/Twig/Extension/SerializerRuntime.php#L29) accepts an `array $context` as 3rd argument. This will not conflict but this means developers could have an unexpected behavior if they don't know about this special argument handling.
created_at: 2023-11-30T13:31:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,8 @@
+<?php
+
+namespace Twig\Extension;
+
+interface WithLastModified
path: src/Extension/WithLastModified.php
user: login: GromNaN
body: Alternatively, we can make passing a class name to the ExtensionSet, that will be scanned by it. This has slightly more impact on existing classes.
created_at: 2023-12-01T08:18:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,179 @@
+<?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;
+
+use Twig\Environment;
+use Twig\Extension\Attribute\AsTwigFilter;
+use Twig\Extension\Attribute\AsTwigFunction;
+use Twig\Extension\Attribute\AsTwigTest;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Define Twig filters, functions, and tests with PHP attributes.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class AttributeExtension extends AbstractExtension implements WithLastModified
+{
+    private array $filters;
+    private array $functions;
+    private array $tests;
+
+    public function __construct(
+        /**
+         * A list of objects or class names defining filters, functions, and tests using PHP attributes.
+         * When passing a class name, it must be available in runtimes.
+         *
+         * @var iterable<object|class-string>
+         */
+        private iterable $objectsOrClasses,
+    ) {
+    }
+
+    public function getLastModified(): int
+    {
+        $lastModified = 0;
+
+        foreach ($this->objectsOrClasses as $objectOrClass) {
+            $r = new \ReflectionClass($objectOrClass);
+            if (is_file($r->getFileName()) && ($extensionTime = filemtime($r->getFileName())) > $lastModified) {
+                $lastModified = $extensionTime;
+            }
+        }
+
+        return $lastModified;
+    }
+
+    public function getFilters(): array
+    {
+        if (!isset($this->filters)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->filters;
+    }
+
+    public function getFunctions(): array
+    {
+        if (!isset($this->functions)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->functions;
+    }
+
+    public function getTests(): array
+    {
+        if (!isset($this->tests)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->tests;
+    }
+
+    private function initFromAttributes()
+    {
+        $filters = $functions = $tests = [];
+
+        foreach ($this->objectsOrClasses as $objectOrClass) {
+            try {
+                $reflectionClass = new \ReflectionClass($objectOrClass);
+            } catch (\ReflectionException $e) {
+                throw new \LogicException(sprintf('"%s" class requires a list of objects or class name, "%s" given.', __CLASS__, get_debug_type($objectOrClass)), 0, $e);
+            }
+
+            foreach ($reflectionClass->getMethods() as $method) {
+                // Filters
+                foreach ($method->getAttributes(AsTwigFilter::class) as $attribute) {
+                    $attribute = $attribute->newInstance();
+
+                    $name = $attribute->name;
+                    if (isset($filters[$name])) {
+                        throw new \LogicException(sprintf('Multiple definitions of the "%s" filter.', $name));
path: src/Extension/AttributeExtension.php
user: login: smnandre
body: Could we have a unique centralized system for that ? Because not all functions/filters will come from here i guess ?
created_at: 2023-12-02T04:09:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,179 @@
+<?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;
+
+use Twig\Environment;
+use Twig\Extension\Attribute\AsTwigFilter;
+use Twig\Extension\Attribute\AsTwigFunction;
+use Twig\Extension\Attribute\AsTwigTest;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Define Twig filters, functions, and tests with PHP attributes.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class AttributeExtension extends AbstractExtension implements WithLastModified
+{
+    private array $filters;
+    private array $functions;
+    private array $tests;
+
+    public function __construct(
+        /**
+         * A list of objects or class names defining filters, functions, and tests using PHP attributes.
+         * When passing a class name, it must be available in runtimes.
+         *
+         * @var iterable<object|class-string>
+         */
+        private iterable $objectsOrClasses,
+    ) {
+    }
+
+    public function getLastModified(): int
+    {
+        $lastModified = 0;
+
+        foreach ($this->objectsOrClasses as $objectOrClass) {
+            $r = new \ReflectionClass($objectOrClass);
+            if (is_file($r->getFileName()) && ($extensionTime = filemtime($r->getFileName())) > $lastModified) {
+                $lastModified = $extensionTime;
+            }
+        }
+
+        return $lastModified;
+    }
+
+    public function getFilters(): array
+    {
+        if (!isset($this->filters)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->filters;
+    }
+
+    public function getFunctions(): array
+    {
+        if (!isset($this->functions)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->functions;
+    }
+
+    public function getTests(): array
+    {
+        if (!isset($this->tests)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->tests;
+    }
+
+    private function initFromAttributes()
+    {
+        $filters = $functions = $tests = [];
+
+        foreach ($this->objectsOrClasses as $objectOrClass) {
+            try {
+                $reflectionClass = new \ReflectionClass($objectOrClass);
+            } catch (\ReflectionException $e) {
+                throw new \LogicException(sprintf('"%s" class requires a list of objects or class name, "%s" given.', __CLASS__, get_debug_type($objectOrClass)), 0, $e);
+            }
+
+            foreach ($reflectionClass->getMethods() as $method) {
+                // Filters
+                foreach ($method->getAttributes(AsTwigFilter::class) as $attribute) {
+                    $attribute = $attribute->newInstance();
+
+                    $name = $attribute->name;
+                    if (isset($filters[$name])) {
+                        throw new \LogicException(sprintf('Multiple definitions of the "%s" filter.', $name));
+                    }
+
+                    $parameters = $method->getParameters();
+                    $needsEnvironment = isset($parameters[0]) && Environment::class === $parameters[0]->getType()?->getName();
+                    $firstParam = $needsEnvironment ? 1 : 0;
+                    $needsContext = isset($parameters[$firstParam]) && 'context' === $parameters[$firstParam]->getName() && 'array' === $parameters[$firstParam]->getType()?->getName();
+                    $firstParam += $needsContext ? 1 : 0;
+                    $isVariadic = isset($parameters[$firstParam]) && end($parameters)->isVariadic();
+
+                    $filters[$name] = new TwigFilter($name, [$objectOrClass, $method->getName()], [
+                        'needs_environment' => $needsEnvironment,
+                        'needs_context' => $needsContext,
+                        'is_variadic' => $isVariadic,
+                        'is_safe' => $attribute->isSafe,
+                        'is_safe_callback' => $attribute->isSafeCallback,
+                        'pre_escape' => $attribute->preEscape,
+                        'preserves_safety' => $attribute->preservesSafety,
+                        'deprecated' => $attribute->deprecated,
+                        'alternative' => $attribute->alternative,
+                    ]);
+                }
+
+                // Functions
+                foreach ($method->getAttributes(AsTwigFunction::class) as $attribute) {
+                    $attribute = $attribute->newInstance();
+
+                    $name = $attribute->name;
+                    if (isset($functions[$name])) {
+                        throw new \LogicException(sprintf('Multiple definitions of the "%s" function.', $name));
+                    }
+
+                    $parameters = $method->getParameters();
+                    $needsEnvironment = isset($parameters[0]) && Environment::class === $parameters[0]->getType()?->getName();
+                    $firstParam = $needsEnvironment ? 1 : 0;
+                    $needsContext = isset($parameters[$firstParam]) && 'context' === $parameters[$firstParam]->getName() && 'array' === $parameters[$firstParam]->getType()?->getName();
+                    $firstParam += $needsContext ? 1 : 0;
+                    $isVariadic = isset($parameters[$firstParam]) && end($parameters)->isVariadic();
+
+                    $functions[$name] = new TwigFunction($name, [$objectOrClass, $method->getName()], [
+                        'needs_environment' => $needsEnvironment,
+                        'needs_context' => $needsContext,
+                        'is_variadic' => $isVariadic,
+                        'is_safe' => $attribute->isSafe,
+                        'is_safe_callback' => $attribute->isSafeCallback,
+                        'deprecated' => $attribute->deprecated,
+                        'alternative' => $attribute->alternative,
+                    ]);
+                }
+
+                // Tests
+                foreach ($method->getAttributes(AsTwigTest::class) as $attribute) {
+                    $attribute = $attribute->newInstance();
+
+                    $name = $attribute->name;
+                    if (isset($tests[$name])) {
+                        throw new \LogicException(sprintf('Multiple definitions of the "%s" test.', $name));
+                    }
+
+                    $parameters = $method->getParameters();
+                    $isVariadic = isset($parameters[$firstParam]) && end($parameters)->isVariadic();
+
+                    $tests[$name] = new TwigTest($name, [$objectOrClass, $method->getName()], [
+                        'is_variadic' => $isVariadic,
+                        'deprecated' => $attribute->deprecated,
path: src/Extension/AttributeExtension.php
user: login: smnandre
body: \+ options `node_class` and `one_mandatory_argument` ?

 (if TwigTest keeps it [current signature](https://github.com/twigphp/Twig/blob/aeeec9a5e907a79e50a6bb78979154599401726e/src/TwigTest.php#L38))

created_at: 2023-12-02T04:21:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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\Extension\Attribute;
+
+/**
+ * Identifies a class that uses PHP attributes to define filters, functions, or tests.
+ */
+#[\Attribute(\Attribute::TARGET_CLASS)]
+class AsTwigExtension
path: src/Extension/Attribute/AsTwigExtension.php
user: login: smnandre
body: What is the purpose of this attribute ? I don't see (yet) how it is / will be used 
created_at: 2023-12-02T04:45:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,179 @@
+<?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;
+
+use Twig\Environment;
+use Twig\Extension\Attribute\AsTwigFilter;
+use Twig\Extension\Attribute\AsTwigFunction;
+use Twig\Extension\Attribute\AsTwigTest;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Define Twig filters, functions, and tests with PHP attributes.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class AttributeExtension extends AbstractExtension implements WithLastModified
+{
+    private array $filters;
+    private array $functions;
+    private array $tests;
+
+    public function __construct(
+        /**
+         * A list of objects or class names defining filters, functions, and tests using PHP attributes.
+         * When passing a class name, it must be available in runtimes.
+         *
+         * @var iterable<object|class-string>
+         */
+        private iterable $objectsOrClasses,
+    ) {
+    }
+
+    public function getLastModified(): int
+    {
+        $lastModified = 0;
+
+        foreach ($this->objectsOrClasses as $objectOrClass) {
+            $r = new \ReflectionClass($objectOrClass);
+            if (is_file($r->getFileName()) && ($extensionTime = filemtime($r->getFileName())) > $lastModified) {
+                $lastModified = $extensionTime;
+            }
+        }
+
+        return $lastModified;
+    }
+
+    public function getFilters(): array
+    {
+        if (!isset($this->filters)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->filters;
+    }
+
+    public function getFunctions(): array
+    {
+        if (!isset($this->functions)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->functions;
+    }
+
+    public function getTests(): array
+    {
+        if (!isset($this->tests)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->tests;
+    }
+
+    private function initFromAttributes()
+    {
+        $filters = $functions = $tests = [];
+
+        foreach ($this->objectsOrClasses as $objectOrClass) {
+            try {
+                $reflectionClass = new \ReflectionClass($objectOrClass);
+            } catch (\ReflectionException $e) {
+                throw new \LogicException(sprintf('"%s" class requires a list of objects or class name, "%s" given.', __CLASS__, get_debug_type($objectOrClass)), 0, $e);
+            }
+
+            foreach ($reflectionClass->getMethods() as $method) {
+                // Filters
+                foreach ($method->getAttributes(AsTwigFilter::class) as $attribute) {
+                    $attribute = $attribute->newInstance();
+
+                    $name = $attribute->name;
+                    if (isset($filters[$name])) {
+                        throw new \LogicException(sprintf('Multiple definitions of the "%s" filter.', $name));
+                    }
+
+                    $parameters = $method->getParameters();
+                    $needsEnvironment = isset($parameters[0]) && Environment::class === $parameters[0]->getType()?->getName();
+                    $firstParam = $needsEnvironment ? 1 : 0;
+                    $needsContext = isset($parameters[$firstParam]) && 'context' === $parameters[$firstParam]->getName() && 'array' === $parameters[$firstParam]->getType()?->getName();
+                    $firstParam += $needsContext ? 1 : 0;
+                    $isVariadic = isset($parameters[$firstParam]) && end($parameters)->isVariadic();
+
+                    $filters[$name] = new TwigFilter($name, [$objectOrClass, $method->getName()], [
+                        'needs_environment' => $needsEnvironment,
+                        'needs_context' => $needsContext,
+                        'is_variadic' => $isVariadic,
+                        'is_safe' => $attribute->isSafe,
+                        'is_safe_callback' => $attribute->isSafeCallback,
+                        'pre_escape' => $attribute->preEscape,
+                        'preserves_safety' => $attribute->preservesSafety,
+                        'deprecated' => $attribute->deprecated,
+                        'alternative' => $attribute->alternative,
+                    ]);
+                }
+
+                // Functions
+                foreach ($method->getAttributes(AsTwigFunction::class) as $attribute) {
+                    $attribute = $attribute->newInstance();
+
+                    $name = $attribute->name;
+                    if (isset($functions[$name])) {
+                        throw new \LogicException(sprintf('Multiple definitions of the "%s" function.', $name));
+                    }
+
+                    $parameters = $method->getParameters();
+                    $needsEnvironment = isset($parameters[0]) && Environment::class === $parameters[0]->getType()?->getName();
+                    $firstParam = $needsEnvironment ? 1 : 0;
+                    $needsContext = isset($parameters[$firstParam]) && 'context' === $parameters[$firstParam]->getName() && 'array' === $parameters[$firstParam]->getType()?->getName();
+                    $firstParam += $needsContext ? 1 : 0;
+                    $isVariadic = isset($parameters[$firstParam]) && end($parameters)->isVariadic();
+
+                    $functions[$name] = new TwigFunction($name, [$objectOrClass, $method->getName()], [
+                        'needs_environment' => $needsEnvironment,
+                        'needs_context' => $needsContext,
+                        'is_variadic' => $isVariadic,
+                        'is_safe' => $attribute->isSafe,
+                        'is_safe_callback' => $attribute->isSafeCallback,
+                        'deprecated' => $attribute->deprecated,
+                        'alternative' => $attribute->alternative,
+                    ]);
+                }
+
+                // Tests
+                foreach ($method->getAttributes(AsTwigTest::class) as $attribute) {
+                    $attribute = $attribute->newInstance();
+
+                    $name = $attribute->name;
+                    if (isset($tests[$name])) {
+                        throw new \LogicException(sprintf('Multiple definitions of the "%s" test.', $name));
+                    }
+
+                    $parameters = $method->getParameters();
+                    $isVariadic = isset($parameters[$firstParam]) && end($parameters)->isVariadic();
+
+                    $tests[$name] = new TwigTest($name, [$objectOrClass, $method->getName()], [
+                        'is_variadic' => $isVariadic,
+                        'deprecated' => $attribute->deprecated,
path: src/Extension/AttributeExtension.php
user: login: GromNaN
body: - `node_class` option is exclusive with the callback. It cannot used for an attribute of a method. It we extends the attributes to the class, that would be the class itself.
- I must check how arguments works with tests.
created_at: 2023-12-02T10:54:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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\Extension\Attribute;
+
+/**
+ * Identifies a class that uses PHP attributes to define filters, functions, or tests.
+ */
+#[\Attribute(\Attribute::TARGET_CLASS)]
+class AsTwigExtension
path: src/Extension/Attribute/AsTwigExtension.php
user: login: GromNaN
body: It's similar to `Twig\Extension\RuntimeExtensionInterface`, it's a marker for classes that provide extension features, to be automatically tagged by Symfony dependency injection.
created_at: 2023-12-02T10:58:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,179 @@
+<?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;
+
+use Twig\Environment;
+use Twig\Extension\Attribute\AsTwigFilter;
+use Twig\Extension\Attribute\AsTwigFunction;
+use Twig\Extension\Attribute\AsTwigTest;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Define Twig filters, functions, and tests with PHP attributes.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class AttributeExtension extends AbstractExtension implements WithLastModified
+{
+    private array $filters;
+    private array $functions;
+    private array $tests;
+
+    public function __construct(
+        /**
+         * A list of objects or class names defining filters, functions, and tests using PHP attributes.
+         * When passing a class name, it must be available in runtimes.
+         *
+         * @var iterable<object|class-string>
+         */
+        private iterable $objectsOrClasses,
+    ) {
+    }
+
+    public function getLastModified(): int
+    {
+        $lastModified = 0;
+
+        foreach ($this->objectsOrClasses as $objectOrClass) {
+            $r = new \ReflectionClass($objectOrClass);
+            if (is_file($r->getFileName()) && ($extensionTime = filemtime($r->getFileName())) > $lastModified) {
+                $lastModified = $extensionTime;
+            }
+        }
+
+        return $lastModified;
+    }
+
+    public function getFilters(): array
+    {
+        if (!isset($this->filters)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->filters;
+    }
+
+    public function getFunctions(): array
+    {
+        if (!isset($this->functions)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->functions;
+    }
+
+    public function getTests(): array
+    {
+        if (!isset($this->tests)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->tests;
+    }
+
+    private function initFromAttributes()
+    {
+        $filters = $functions = $tests = [];
+
+        foreach ($this->objectsOrClasses as $objectOrClass) {
+            try {
+                $reflectionClass = new \ReflectionClass($objectOrClass);
+            } catch (\ReflectionException $e) {
+                throw new \LogicException(sprintf('"%s" class requires a list of objects or class name, "%s" given.', __CLASS__, get_debug_type($objectOrClass)), 0, $e);
+            }
+
+            foreach ($reflectionClass->getMethods() as $method) {
+                // Filters
+                foreach ($method->getAttributes(AsTwigFilter::class) as $attribute) {
+                    $attribute = $attribute->newInstance();
+
+                    $name = $attribute->name;
+                    if (isset($filters[$name])) {
+                        throw new \LogicException(sprintf('Multiple definitions of the "%s" filter.', $name));
+                    }
+
+                    $parameters = $method->getParameters();
+                    $needsEnvironment = isset($parameters[0]) && Environment::class === $parameters[0]->getType()?->getName();
+                    $firstParam = $needsEnvironment ? 1 : 0;
+                    $needsContext = isset($parameters[$firstParam]) && 'context' === $parameters[$firstParam]->getName() && 'array' === $parameters[$firstParam]->getType()?->getName();
+                    $firstParam += $needsContext ? 1 : 0;
+                    $isVariadic = isset($parameters[$firstParam]) && end($parameters)->isVariadic();
+
+                    $filters[$name] = new TwigFilter($name, [$objectOrClass, $method->getName()], [
+                        'needs_environment' => $needsEnvironment,
+                        'needs_context' => $needsContext,
+                        'is_variadic' => $isVariadic,
+                        'is_safe' => $attribute->isSafe,
+                        'is_safe_callback' => $attribute->isSafeCallback,
+                        'pre_escape' => $attribute->preEscape,
+                        'preserves_safety' => $attribute->preservesSafety,
+                        'deprecated' => $attribute->deprecated,
+                        'alternative' => $attribute->alternative,
+                    ]);
+                }
+
+                // Functions
+                foreach ($method->getAttributes(AsTwigFunction::class) as $attribute) {
+                    $attribute = $attribute->newInstance();
+
+                    $name = $attribute->name;
+                    if (isset($functions[$name])) {
+                        throw new \LogicException(sprintf('Multiple definitions of the "%s" function.', $name));
+                    }
+
+                    $parameters = $method->getParameters();
+                    $needsEnvironment = isset($parameters[0]) && Environment::class === $parameters[0]->getType()?->getName();
+                    $firstParam = $needsEnvironment ? 1 : 0;
+                    $needsContext = isset($parameters[$firstParam]) && 'context' === $parameters[$firstParam]->getName() && 'array' === $parameters[$firstParam]->getType()?->getName();
+                    $firstParam += $needsContext ? 1 : 0;
+                    $isVariadic = isset($parameters[$firstParam]) && end($parameters)->isVariadic();
+
+                    $functions[$name] = new TwigFunction($name, [$objectOrClass, $method->getName()], [
+                        'needs_environment' => $needsEnvironment,
+                        'needs_context' => $needsContext,
+                        'is_variadic' => $isVariadic,
+                        'is_safe' => $attribute->isSafe,
+                        'is_safe_callback' => $attribute->isSafeCallback,
+                        'deprecated' => $attribute->deprecated,
+                        'alternative' => $attribute->alternative,
+                    ]);
+                }
+
+                // Tests
+                foreach ($method->getAttributes(AsTwigTest::class) as $attribute) {
+                    $attribute = $attribute->newInstance();
+
+                    $name = $attribute->name;
+                    if (isset($tests[$name])) {
+                        throw new \LogicException(sprintf('Multiple definitions of the "%s" test.', $name));
+                    }
+
+                    $parameters = $method->getParameters();
+                    $isVariadic = isset($parameters[$firstParam]) && end($parameters)->isVariadic();
+
+                    $tests[$name] = new TwigTest($name, [$objectOrClass, $method->getName()], [
+                        'is_variadic' => $isVariadic,
+                        'deprecated' => $attribute->deprecated,
path: src/Extension/AttributeExtension.php
user: login: smnandre
body: I did not realize they were 100% exclusive, thanks for the explanation !
created_at: 2023-12-02T12:47:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,98 @@
+<?php
+
+namespace Twig\Tests\Extension\Fixtures;
+
+use Twig\Environment;
+use Twig\Extension\Attribute\AsTwigExtension;
+use Twig\Extension\Attribute\AsTwigFilter;
+use Twig\Extension\Attribute\AsTwigFunction;
+use Twig\Extension\Attribute\AsTwigTest;
+
+#[AsTwigExtension]
+class ExtensionWithAttributes
+{
+    #[AsTwigFilter(name: 'foo')]
+    public function fooFilter(string $string)
+    {
+    }
+
+    #[AsTwigFilter('with_context_filter')]
+    public function withContextFilter(array $context, string $string)
+    {
+    }
+
+    #[AsTwigFilter('with_env_filter')]
+    public function withEnvFilter(Environment $env, string $string)
+    {
+    }
+
+    #[AsTwigFilter('with_env_and_context_filter')]
+    public function withEnvAndContextFilter(Environment $env, array $context, string $string)
+    {
+    }
+
+    #[AsTwigFilter('no_arg_filter')]
+    public function noArgFilter()
+    {
+    }
+
+    #[AsTwigFilter('variadic_filter')]
+    public function variadicFilter(string ...$strings)
+    {
+    }
+
+    #[AsTwigFilter('deprecated_filter', deprecated: true, alternative: 'bar')]
+    public function deprecatedFilter(string $string)
+    {
+    }
+
+    #[AsTwigFunction(name: 'foo')]
+    public function fooFunction(string $string)
+    {
+    }
+
+    #[AsTwigFunction('with_context_function')]
+    public function withContextFunction(array $context, string $string)
+    {
+    }
+
+    #[AsTwigFunction('with_env_function')]
+    public function withEnvFunction(Environment $env, string $string)
+    {
+    }
+
+    #[AsTwigFunction('with_env_and_context_function')]
+    public function withEnvAndContextFunction(Environment $env, array $context, string $string)
+    {
+    }
+
+    #[AsTwigFunction('no_arg_function')]
+    public function noArgFunction()
+    {
+    }
+
+    #[AsTwigFunction('variadic_function')]
+    public function variadicFunction(string ...$strings)
+    {
+    }
+
+    #[AsTwigFunction('deprecated_function', deprecated: true, alternative: 'bar')]
+    public function deprecatedFunction(string $string)
+    {
+    }
+
+    #[AsTwigTest(name: 'foo')]
+    public function fooTest(string $string)
+    {
+    }
+
+    #[AsTwigTest('variadic_test')]
+    public function variadicTest(string ...$strings)
+    {
+    }
+
path: tests/Extension/Fixtures/ExtensionWithAttributes.php
user: login: smnandre
body: I don't see how this class would change anything (as it's [internal to ExtensionSet](https://github.com/twigphp/Twig/blob/aeeec9a5e907a79e50a6bb78979154599401726e/src/ExtensionSet.php#L169))... but maybe just to be sure could we add a "pattern" Filter/Function/Test  ?

`pattern_*_filter`
created_at: 2023-12-02T12:52:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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\Extension\Attribute;
path: src/Extension/Attribute/AsTwigExtension.php
user: login: GromNaN
body: Moved.
created_at: 2023-12-08T22:28:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,179 @@
+<?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;
+
+use Twig\Environment;
+use Twig\Extension\Attribute\AsTwigFilter;
+use Twig\Extension\Attribute\AsTwigFunction;
+use Twig\Extension\Attribute\AsTwigTest;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Define Twig filters, functions, and tests with PHP attributes.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class AttributeExtension extends AbstractExtension implements WithLastModified
+{
+    private array $filters;
+    private array $functions;
+    private array $tests;
+
+    public function __construct(
+        /**
+         * A list of objects or class names defining filters, functions, and tests using PHP attributes.
+         * When passing a class name, it must be available in runtimes.
+         *
+         * @var iterable<object|class-string>
+         */
+        private iterable $objectsOrClasses,
+    ) {
+    }
+
+    public function getLastModified(): int
+    {
+        $lastModified = 0;
+
+        foreach ($this->objectsOrClasses as $objectOrClass) {
+            $r = new \ReflectionClass($objectOrClass);
+            if (is_file($r->getFileName()) && ($extensionTime = filemtime($r->getFileName())) > $lastModified) {
+                $lastModified = $extensionTime;
+            }
+        }
+
+        return $lastModified;
+    }
+
+    public function getFilters(): array
+    {
+        if (!isset($this->filters)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->filters;
+    }
+
+    public function getFunctions(): array
+    {
+        if (!isset($this->functions)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->functions;
+    }
+
+    public function getTests(): array
+    {
+        if (!isset($this->tests)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->tests;
+    }
+
+    private function initFromAttributes()
+    {
+        $filters = $functions = $tests = [];
+
+        foreach ($this->objectsOrClasses as $objectOrClass) {
+            try {
+                $reflectionClass = new \ReflectionClass($objectOrClass);
+            } catch (\ReflectionException $e) {
+                throw new \LogicException(sprintf('"%s" class requires a list of objects or class name, "%s" given.', __CLASS__, get_debug_type($objectOrClass)), 0, $e);
+            }
+
+            foreach ($reflectionClass->getMethods() as $method) {
+                // Filters
+                foreach ($method->getAttributes(AsTwigFilter::class) as $attribute) {
+                    $attribute = $attribute->newInstance();
+
+                    $name = $attribute->name;
+                    if (isset($filters[$name])) {
+                        throw new \LogicException(sprintf('Multiple definitions of the "%s" filter.', $name));
path: src/Extension/AttributeExtension.php
user: login: GromNaN
body: This is a feature of Twig. You can replace an existing filter/function/test by declaring the same name in an extension.
I'm thinking about removing this check.
created_at: 2023-12-08T22:30:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,98 @@
+<?php
+
+namespace Twig\Tests\Extension\Fixtures;
+
+use Twig\Environment;
+use Twig\Extension\Attribute\AsTwigExtension;
+use Twig\Extension\Attribute\AsTwigFilter;
+use Twig\Extension\Attribute\AsTwigFunction;
+use Twig\Extension\Attribute\AsTwigTest;
+
+#[AsTwigExtension]
+class ExtensionWithAttributes
+{
+    #[AsTwigFilter(name: 'foo')]
+    public function fooFilter(string $string)
+    {
+    }
+
+    #[AsTwigFilter('with_context_filter')]
+    public function withContextFilter(array $context, string $string)
+    {
+    }
+
+    #[AsTwigFilter('with_env_filter')]
+    public function withEnvFilter(Environment $env, string $string)
+    {
+    }
+
+    #[AsTwigFilter('with_env_and_context_filter')]
+    public function withEnvAndContextFilter(Environment $env, array $context, string $string)
+    {
+    }
+
+    #[AsTwigFilter('no_arg_filter')]
+    public function noArgFilter()
+    {
+    }
+
+    #[AsTwigFilter('variadic_filter')]
+    public function variadicFilter(string ...$strings)
+    {
+    }
+
+    #[AsTwigFilter('deprecated_filter', deprecated: true, alternative: 'bar')]
+    public function deprecatedFilter(string $string)
+    {
+    }
+
+    #[AsTwigFunction(name: 'foo')]
+    public function fooFunction(string $string)
+    {
+    }
+
+    #[AsTwigFunction('with_context_function')]
+    public function withContextFunction(array $context, string $string)
+    {
+    }
+
+    #[AsTwigFunction('with_env_function')]
+    public function withEnvFunction(Environment $env, string $string)
+    {
+    }
+
+    #[AsTwigFunction('with_env_and_context_function')]
+    public function withEnvAndContextFunction(Environment $env, array $context, string $string)
+    {
+    }
+
+    #[AsTwigFunction('no_arg_function')]
+    public function noArgFunction()
+    {
+    }
+
+    #[AsTwigFunction('variadic_function')]
+    public function variadicFunction(string ...$strings)
+    {
+    }
+
+    #[AsTwigFunction('deprecated_function', deprecated: true, alternative: 'bar')]
+    public function deprecatedFunction(string $string)
+    {
+    }
+
+    #[AsTwigTest(name: 'foo')]
+    public function fooTest(string $string)
+    {
+    }
+
+    #[AsTwigTest('variadic_test')]
+    public function variadicTest(string ...$strings)
+    {
+    }
+
path: tests/Extension/Fixtures/ExtensionWithAttributes.php
user: login: GromNaN
body: Test added.
created_at: 2023-12-08T22:36:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,8 @@
+<?php
+
+namespace Twig\Extension;
+
+interface WithLastModified
path: src/Extension/WithLastModified.php
user: login: GromNaN
body: @stof I can't replace the modification date by a hash, the `Environment::isFresh` relies on a date comparison between the ExtensionSet and the cache file mtime.

https://github.com/twigphp/Twig/blob/aeeec9a5e907a79e50a6bb78979154599401726e/src/Environment.php#L416-L419
created_at: 2023-12-08T23:30:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -756,6 +756,156 @@ The ``getTests()`` method lets you add new test functions::
         // ...
     }
 
+Using PHP Attributes to define extensions
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 3.9
+
+    The ``Twig\Extension\AttributeExtension`` was added in Twig 3.9.
+
+From PHP 8.0, you can use the attributes ``#[AsTwigFilter]``, ``#[AsTwigFunction]``,
+and ``#[AsTwigTest]`` on any method of any class to define filters, functions, and tests.
+
+Create a class, you don't need to extend any class or implement any interface
+but it eases integration with frameworks if you use the attribute ``#[AsTwigExtension]``::
+
+    use Twig\Attribute\AsTwigExtension;
+    use Twig\Attribute\AsTwigFilter;
+    use Twig\Attribute\AsTwigFunction;
+    use Twig\Attribute\AsTwigTest;
+
+    #[AsTwigExtension]
+    class Project_Twig_Extension
+    {
+        #[AsTwigFilter('rot13')]
+        public static function rot13(string $string): string
+        {
+            // ...
+        }
+
+        #[AsTwigFunction('lipsum')]
+        public static function lipsum(int $count): string
+        {
+            // ...
+        }
+
+        #[AsTwigTest('even')]
+        public static function isEven(int $number): bool
+        {
+            // ...
+        }
+    }
+
+Then register the class using ``Twig\Extension\AttributeExtension``::
+
+    $twig = new \Twig\Environment($loader);
+    $twig->addExtension(new \Twig\Extension\AttributeExtension([
+        Project_Twig_Extension::class,
+    ]));
+
+.. note::
+
+    The ``\Twig\Extension\AttributeExtension`` can be added only once to an environment.
path: doc/advanced.rst
user: login: GromNaN
body: There is an exception if we try to register the extension a second time.
created_at: 2023-12-10T21:41:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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\Extension\Attribute;
+
+/**
+ * Identifies a class that uses PHP attributes to define filters, functions, or tests.
+ */
+#[\Attribute(\Attribute::TARGET_CLASS)]
+class AsTwigExtension
path: src/Extension/Attribute/AsTwigExtension.php
user: login: stof
body: Given that extensions must implement the ExtensionInterface anyway, there is no need for a marker attribute. The Symfony dependency injection component is also able to perform auto-configuration based on implemented interfaces (and TwigBundle already does it for the Twig ExtensionInterface
created_at: 2023-12-15T14:45:30Z
author_association: MEMBER
diff_hunk: @@ -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\Extension\Attribute;
+
+/**
+ * Identifies a class that uses PHP attributes to define filters, functions, or tests.
+ */
+#[\Attribute(\Attribute::TARGET_CLASS)]
+class AsTwigExtension
path: src/Extension/Attribute/AsTwigExtension.php
user: login: GromNaN
body: It's more a question of DX. It's more meaningful to use an attribute than an interface as a marker as the developer will use attributes in the class. Implementing an interface without method is a deviation from the interface system.
created_at: 2023-12-15T14:51:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,8 @@
+<?php
+
+namespace Twig\Extension;
+
+interface WithLastModified
path: src/Extension/WithLastModified.php
user: login: nicolas-grekas
body: `LastModifiedAwareInterface`? What's the existing convention in Twig?
created_at: 2023-12-26T15:50:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,8 @@
+<?php
+
+namespace Twig\Extension;
+
+interface WithLastModified
path: src/Extension/WithLastModified.php
user: login: GromNaN
body: Renamed to `ModificationAwareInterface`. The convention if to suffix with `Interface`.
created_at: 2023-12-26T20:04:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -616,14 +616,17 @@ public function getRuntime(string $class)
         throw new RuntimeError(sprintf('Unable to load the "%s" runtime.', $class));
     }
 
-    public function addExtension(ExtensionInterface $extension)
+    /**
+     * @param ExtensionInterface|object|class-string $extension
+     */
+    public function addExtension(object|string $extension)
path: src/Environment.php
user: login: GromNaN
body: This is a breaking change.
created_at: 2024-04-05T14:39:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,162 @@
+<?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;
+
+use Twig\Attribute\AsTwigExtension;
+use Twig\Attribute\AsTwigFilter;
+use Twig\Attribute\AsTwigFunction;
+use Twig\Attribute\AsTwigTest;
+use Twig\Environment;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Define Twig filters, functions, and tests with PHP attributes.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ *
+ * @internal
+ */
+final class AttributeExtension extends AbstractExtension
+{
+    private array $classes;
+    private array $filters;
+    private array $functions;
+    private array $tests;
+
+    /**
+     * A list of objects or class names defining filters, functions, and tests using PHP attributes.
+     * When passing a class name, it must be available in runtimes.
+     *
+     * @param class-string[]
+     */
+    public function __construct(array $classes)
+    {
+        $this->classes = $classes;
+    }
+
+    public function getFilters(): array
+    {
+        if (!isset($this->filters)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->filters;
+    }
+
+    public function getFunctions(): array
+    {
+        if (!isset($this->functions)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->functions;
+    }
+
+    public function getTests(): array
+    {
+        if (!isset($this->tests)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->tests;
+    }
+
+    private function initFromAttributes()
+    {
+        $filters = $functions = $tests = [];
+
+        foreach ($this->classes as $objectOrClass) {
+            try {
+                $reflectionClass = new \ReflectionClass($objectOrClass);
+            } catch (\ReflectionException $e) {
+                throw new \LogicException(sprintf('"%s" class requires a list of objects or class name, "%s" given.', __CLASS__, get_debug_type($objectOrClass)), 0, $e);
+            }
+
+            $attributes = $reflectionClass->getAttributes(AsTwigExtension::class);
+            if (!$attributes) {
+                throw new \LogicException(sprintf('Extension class "%s" must have the attribute "%s" in order to use attributes', is_string($objectOrClass) ? $objectOrClass : get_debug_type($objectOrClass), AsTwigExtension::class));
+            }
+
+            foreach ($reflectionClass->getMethods() as $method) {
+                // Filters
+                foreach ($method->getAttributes(AsTwigFilter::class) as $attribute) {
+                    $attribute = $attribute->newInstance();
+
+                    $name = $attribute->name;
+                    $parameters = $method->getParameters();
+                    $needsEnvironment = isset($parameters[0]) && Environment::class === $parameters[0]->getType()?->getName();
+                    $firstParam = $needsEnvironment ? 1 : 0;
+                    $needsContext = isset($parameters[$firstParam]) && 'context' === $parameters[$firstParam]->getName() && 'array' === $parameters[$firstParam]->getType()?->getName();
+                    $firstParam += $needsContext ? 1 : 0;
+                    $isVariadic = isset($parameters[$firstParam]) && end($parameters)->isVariadic();
+
+                    $filters[$name] = new TwigFilter($name, [$objectOrClass, $method->getName()], [
+                        'needs_environment' => $needsEnvironment,
+                        'needs_context' => $needsContext,
+                        'is_variadic' => $isVariadic,
+                        'is_safe' => $attribute->isSafe,
+                        'is_safe_callback' => $attribute->isSafeCallback,
+                        'pre_escape' => $attribute->preEscape,
+                        'preserves_safety' => $attribute->preservesSafety,
+                        'deprecated' => $attribute->deprecated,
+                        'alternative' => $attribute->alternative,
+                    ]);
+                }
+
+                // Functions
+                foreach ($method->getAttributes(AsTwigFunction::class) as $attribute) {
+                    $attribute = $attribute->newInstance();
+
+                    $name = $attribute->name;
+                    $parameters = $method->getParameters();
+                    $needsEnvironment = isset($parameters[0]) && Environment::class === $parameters[0]->getType()?->getName();
+                    $firstParam = $needsEnvironment ? 1 : 0;
+                    $needsContext = isset($parameters[$firstParam]) && 'context' === $parameters[$firstParam]->getName() && 'array' === $parameters[$firstParam]->getType()?->getName();
+                    $firstParam += $needsContext ? 1 : 0;
+                    $isVariadic = isset($parameters[$firstParam]) && end($parameters)->isVariadic();
+
+                    $functions[$name] = new TwigFunction($name, [$objectOrClass, $method->getName()], [
+                        'needs_environment' => $needsEnvironment,
+                        'needs_context' => $needsContext,
+                        'is_variadic' => $isVariadic,
+                        'is_safe' => $attribute->isSafe,
+                        'is_safe_callback' => $attribute->isSafeCallback,
+                        'deprecated' => $attribute->deprecated,
+                        'alternative' => $attribute->alternative,
+                    ]);
+                }
+
+                // Tests
+                foreach ($method->getAttributes(AsTwigTest::class) as $attribute) {
+                    $attribute = $attribute->newInstance();
+
+                    $name = $attribute->name;
+                    $parameters = $method->getParameters();
+                    $isVariadic = isset($parameters[$firstParam]) && end($parameters)->isVariadic();
+
+                    $tests[$name] = new TwigTest($name, [$objectOrClass, $method->getName()], [
+                        'is_variadic' => $isVariadic,
+                        'deprecated' => $attribute->deprecated,
+                        'alternative' => $attribute->alternative,
+                    ]);
+                }
+            }
+        }
+
+        // Assign all at the end to avoid inconsistent state in case of exception
+        $this->filters = $filters;
path: src/Extension/AttributeExtension.php
user: login: stof
body: you should do `array_values` on those arrays instead of returning names as keys
created_at: 2024-04-05T14:41:27Z
author_association: MEMBER
diff_hunk: @@ -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\Extension\Attribute;
+
+/**
+ * Identifies a class that uses PHP attributes to define filters, functions, or tests.
+ */
+#[\Attribute(\Attribute::TARGET_CLASS)]
+class AsTwigExtension
path: src/Extension/Attribute/AsTwigExtension.php
user: login: GromNaN
body: I've made this attribute mandatory to prevent any object from being passed by mistake. In practice, it will be mandatory anyway for Symfony autoconfiguration.
created_at: 2024-04-05T14:42:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,50 @@
+<?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\Attribute;
+
+use Twig\TwigTest;
+
+/**
+ * Registers a method as template test.
+ *
+ * If the first argument of the method has Twig\Environment type-hint, the test will receive the current environment.
+ * If the next argument of the method is named $context and has array type-hint, the test will receive the current context.
+ * The last argument of the method is the value to be tested, if any.
path: src/Attribute/AsTwigTest.php
user: login: stof
body: this is wrong. It is not the last argument. It is the following argument. A test can have as many arguments as you want after the value being tested, which become arguments in the Twig signature.
created_at: 2024-04-05T14:43:04Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,8 @@
+<?php
+
+namespace Twig\Extension;
+
+interface WithLastModified
path: src/Extension/WithLastModified.php
user: login: GromNaN
body: With the last change, I removed this interface. `ExtensionSet` is now aware of the extension classes that use attributes, and check their modification date as well.
created_at: 2024-04-05T14:44:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,78 @@
+<?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\Attribute;
+
+use Twig\Node\Node;
+use Twig\TwigFilter;
+
+/**
+ * Registers a method as template filter.
+ *
+ * If the first argument of the method has Twig\Environment type-hint, the filter will receive the current environment.
+ * If the next argument of the method is named $context and has array type-hint, the filter will receive the current context.
+ * Additional arguments of the method come from the filter call.
+ *
+ *     #[AsTwigFilter('foo')]
+ *     function fooFilter(Environment $env, array $context, $string, $arg1 = null, ...) { ... }
+ *
+ * @see TwigFilter
+ */
+#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+final class AsTwigFilter
+{
+    public function __construct(
+        /**
+         * The name of the filter in Twig.
+         *
+         * @var non-empty-string $name
+         */
+        public string $name,
+
+        /**
+         * List of formats in which you want the raw output to be printed unescaped.
+         *
+         * @var list<string>|null $isSafe
+         */
+        public ?array $isSafe = null,
+
+        /**
+         * Function called at compilation time to determine if the filter is safe.
+         *
+         * @var callable(Node):bool $isSafeCallback
+         */
+        public ?string $isSafeCallback = null,
+
+        /**
+         * Some filters may need to work on input that is already escaped or safe, for
+         * example when adding (safe) HTML tags to originally unsafe output. In such a
+         * case, set preEscape to an escape format to escape the input data before it
+         * is run through the filter.
+         */
+        public ?string $preEscape = null,
+
+        /**
+         * Preserves the safety of the value that the filter is applied to.
+         */
+        public ?array $preservesSafety = null,
+
+        /**
+         * Set to true if the filter is deprecated.
path: src/Attribute/AsTwigFilter.php
user: login: stof
body: what is the string type about then ?
created_at: 2024-04-05T14:44:43Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,78 @@
+<?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\Attribute;
+
+use Twig\Node\Node;
+use Twig\TwigFilter;
+
+/**
+ * Registers a method as template filter.
+ *
+ * If the first argument of the method has Twig\Environment type-hint, the filter will receive the current environment.
+ * If the next argument of the method is named $context and has array type-hint, the filter will receive the current context.
+ * Additional arguments of the method come from the filter call.
+ *
+ *     #[AsTwigFilter('foo')]
+ *     function fooFilter(Environment $env, array $context, $string, $arg1 = null, ...) { ... }
+ *
+ * @see TwigFilter
+ */
+#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+final class AsTwigFilter
+{
+    public function __construct(
+        /**
+         * The name of the filter in Twig.
+         *
+         * @var non-empty-string $name
+         */
+        public string $name,
+
+        /**
+         * List of formats in which you want the raw output to be printed unescaped.
+         *
+         * @var list<string>|null $isSafe
+         */
+        public ?array $isSafe = null,
+
+        /**
+         * Function called at compilation time to determine if the filter is safe.
+         *
+         * @var callable(Node):bool $isSafeCallback
+         */
+        public ?string $isSafeCallback = null,
+
+        /**
+         * Some filters may need to work on input that is already escaped or safe, for
+         * example when adding (safe) HTML tags to originally unsafe output. In such a
+         * case, set preEscape to an escape format to escape the input data before it
+         * is run through the filter.
+         */
+        public ?string $preEscape = null,
+
+        /**
+         * Preserves the safety of the value that the filter is applied to.
path: src/Attribute/AsTwigFilter.php
user: login: stof
body: this misses the doc for the type of array values
created_at: 2024-04-05T14:45:14Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,162 @@
+<?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;
+
+use Twig\Attribute\AsTwigExtension;
+use Twig\Attribute\AsTwigFilter;
+use Twig\Attribute\AsTwigFunction;
+use Twig\Attribute\AsTwigTest;
+use Twig\Environment;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Define Twig filters, functions, and tests with PHP attributes.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ *
+ * @internal
+ */
+final class AttributeExtension extends AbstractExtension
+{
+    private array $classes;
+    private array $filters;
+    private array $functions;
+    private array $tests;
+
+    /**
+     * A list of objects or class names defining filters, functions, and tests using PHP attributes.
+     * When passing a class name, it must be available in runtimes.
+     *
+     * @param class-string[]
+     */
+    public function __construct(array $classes)
+    {
+        $this->classes = $classes;
+    }
+
+    public function getFilters(): array
+    {
+        if (!isset($this->filters)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->filters;
+    }
+
+    public function getFunctions(): array
+    {
+        if (!isset($this->functions)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->functions;
+    }
+
+    public function getTests(): array
+    {
+        if (!isset($this->tests)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->tests;
+    }
+
+    private function initFromAttributes()
+    {
+        $filters = $functions = $tests = [];
+
+        foreach ($this->classes as $objectOrClass) {
+            try {
+                $reflectionClass = new \ReflectionClass($objectOrClass);
+            } catch (\ReflectionException $e) {
+                throw new \LogicException(sprintf('"%s" class requires a list of objects or class name, "%s" given.', __CLASS__, get_debug_type($objectOrClass)), 0, $e);
+            }
+
+            $attributes = $reflectionClass->getAttributes(AsTwigExtension::class);
+            if (!$attributes) {
+                throw new \LogicException(sprintf('Extension class "%s" must have the attribute "%s" in order to use attributes', is_string($objectOrClass) ? $objectOrClass : get_debug_type($objectOrClass), AsTwigExtension::class));
+            }
+
+            foreach ($reflectionClass->getMethods() as $method) {
path: src/Extension/AttributeExtension.php
user: login: stof
body: thinking about that, I'm wondering whether those `asTwigFilter`, `AsTwigFunction` and `AsTwigTest` attributes should be read by that extension at all.

An alternative solution would be to have an extension in which you inject this list of test, filter and function metadata, and letting TwigBundle perform all this attribute reading at build-time instead. 
This would even removed the need for the `AsTwigExtension` attribute entirely (which would be quite confusing as the class having this attribute is _not_ a twig extension but a twig runtime) because the autoconfiguration system is able to apply autoconfiguration based on method-level attributes already.
created_at: 2024-04-05T14:50:01Z
author_association: MEMBER
diff_hunk: @@ -424,9 +433,16 @@ private function initExtensions(): void
         $this->unaryOperators = [];
         $this->binaryOperators = [];
 
+        $classes = [];
         foreach ($this->extensions as $extension) {
-            $this->initExtension($extension);
+            if ($extension instanceof ExtensionInterface) {
+                $this->initExtension($extension);
+            } else {
+                $classes[] = $extension;
path: src/ExtensionSet.php
user: login: stof
body: making `$this->extensions` contain a mix of extensions and of runtimes (but not all of them) looks weird to me
created_at: 2024-04-05T14:52:06Z
author_association: MEMBER
diff_hunk: @@ -424,9 +433,16 @@ private function initExtensions(): void
         $this->unaryOperators = [];
         $this->binaryOperators = [];
 
+        $classes = [];
         foreach ($this->extensions as $extension) {
-            $this->initExtension($extension);
+            if ($extension instanceof ExtensionInterface) {
+                $this->initExtension($extension);
+            } else {
+                $classes[] = $extension;
path: src/ExtensionSet.php
user: login: stof
body: if this is a way to solve the case of `getLastModified`, I'd rather add a way to make ExtensionSet aware of a **separate** list of runtimes needing to be checked (which would allow solving the case where we don't invalidate the cache today if you change the argument names in a runtime while it actually impacts the template compilation if you use named parameters in Twig, by making TwigBundle inject the name of _all_ runtimes in the list).
created_at: 2024-04-05T14:55:20Z
author_association: MEMBER
diff_hunk: @@ -813,6 +813,145 @@ The ``getTests()`` method lets you add new test functions::
         // ...
     }
 
+Using PHP Attributes to define extensions
path: doc/advanced.rst
user: login: fabpot
body: ```suggestion
Using PHP Attributes to define Extensions
```
created_at: 2025-01-02T11:33:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -813,6 +813,145 @@ The ``getTests()`` method lets you add new test functions::
         // ...
     }
 
+Using PHP Attributes to define extensions
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 3.9
path: doc/advanced.rst
user: login: fabpot
body: ```suggestion
.. versionadded:: 3.19
```
created_at: 2025-01-02T11:33:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -813,6 +813,145 @@ The ``getTests()`` method lets you add new test functions::
         // ...
     }
 
+Using PHP Attributes to define extensions
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 3.9
+
+    The attribute classes were added in Twig 3.9.
path: doc/advanced.rst
user: login: fabpot
body: ```suggestion
    The attribute classes were added in Twig 3.19.
```
created_at: 2025-01-02T11:33:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -813,6 +813,145 @@ The ``getTests()`` method lets you add new test functions::
         // ...
     }
 
+Using PHP Attributes to define extensions
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 3.9
+
+    The attribute classes were added in Twig 3.9.
+
+From PHP 8.0, you can use the attributes ``#[AsTwigFilter]``, ``#[AsTwigFunction]``,
path: doc/advanced.rst
user: login: fabpot
body: As we now require PHP 8, the version mention can be removed:

```suggestion
You can use the attributes ``#[AsTwigFilter]``, ``#[AsTwigFunction]``,
```
created_at: 2025-01-02T11:34:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -813,6 +813,145 @@ The ``getTests()`` method lets you add new test functions::
         // ...
     }
 
+Using PHP Attributes to define extensions
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 3.9
+
+    The attribute classes were added in Twig 3.9.
+
+From PHP 8.0, you can use the attributes ``#[AsTwigFilter]``, ``#[AsTwigFunction]``,
+and ``#[AsTwigTest]`` on any method of any class to define filters, functions, and tests.
+
+Create a class with the attribute ``#[AsTwigExtension]``::
+
+    use Twig\Attribute\AsTwigExtension;
+    use Twig\Attribute\AsTwigFilter;
+    use Twig\Attribute\AsTwigFunction;
+    use Twig\Attribute\AsTwigTest;
+
+    #[AsTwigExtension]
+    class ProjectExtension
+    {
+        #[AsTwigFilter('rot13')]
+        public static function rot13(string $string): string
+        {
+            // ...
+        }
+
+        #[AsTwigFunction('lipsum')]
+        public static function lipsum(int $count): string
+        {
+            // ...
+        }
+
+        #[AsTwigTest('even')]
+        public static function isEven(int $number): bool
+        {
+            // ...
+        }
+    }
+
+Then register the extension class::
+
+    $twig = new \Twig\Environment($loader);
+    $twig->addExtension(ProjectExtension::class);
+
+If all the methods are static, you are done. The ``Project_Twig_Extension`` class will
path: doc/advanced.rst
user: login: fabpot
body: ```suggestion
If all the methods are static, you are done. The ``ProjectExtension`` class will
```
created_at: 2025-01-02T11:34:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -424,6 +434,7 @@ private function initExtensions(): void
         foreach ($this->extensions as $extension) {
             $this->initExtension($extension);
         }
+
path: src/ExtensionSet.php
user: login: fabpot
body: Can be reverted
created_at: 2025-01-02T11:36:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,13 @@
+<?php
+
+namespace Twig\Tests\Extension\Fixtures;
+
+use Twig\Attribute\AsTwigFilter;
+
+class FilterWithoutValue
+{
+    #[AsTwigFilter('my_filter')]
+    public function myFilter()
+    {
+    }
+}
path: tests/Extension/Fixtures/FilterWithoutValue.php
user: login: fabpot
body: missing new line
created_at: 2025-01-02T11:36:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,13 @@
+<?php
+
+namespace Twig\Tests\Extension\Fixtures;
+
+use Twig\Attribute\AsTwigTest;
+
+class TestWithoutValue
+{
+    #[AsTwigTest('my_test')]
+    public function myTest()
+    {
+    }
+}
path: tests/Extension/Fixtures/TestWithoutValue.php
user: login: fabpot
body: missing new line
created_at: 2025-01-02T11:36:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,162 @@
+<?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;
+
+use Twig\Attribute\AsTwigExtension;
+use Twig\Attribute\AsTwigFilter;
+use Twig\Attribute\AsTwigFunction;
+use Twig\Attribute\AsTwigTest;
+use Twig\Environment;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Define Twig filters, functions, and tests with PHP attributes.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ *
+ * @internal
+ */
+final class AttributeExtension extends AbstractExtension
+{
+    private array $classes;
+    private array $filters;
+    private array $functions;
+    private array $tests;
+
+    /**
+     * A list of objects or class names defining filters, functions, and tests using PHP attributes.
+     * When passing a class name, it must be available in runtimes.
+     *
+     * @param class-string[]
+     */
+    public function __construct(array $classes)
+    {
+        $this->classes = $classes;
+    }
+
+    public function getFilters(): array
+    {
+        if (!isset($this->filters)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->filters;
+    }
+
+    public function getFunctions(): array
+    {
+        if (!isset($this->functions)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->functions;
+    }
+
+    public function getTests(): array
+    {
+        if (!isset($this->tests)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->tests;
+    }
+
+    private function initFromAttributes()
+    {
+        $filters = $functions = $tests = [];
+
+        foreach ($this->classes as $objectOrClass) {
+            try {
+                $reflectionClass = new \ReflectionClass($objectOrClass);
+            } catch (\ReflectionException $e) {
+                throw new \LogicException(sprintf('"%s" class requires a list of objects or class name, "%s" given.', __CLASS__, get_debug_type($objectOrClass)), 0, $e);
+            }
+
+            $attributes = $reflectionClass->getAttributes(AsTwigExtension::class);
+            if (!$attributes) {
+                throw new \LogicException(sprintf('Extension class "%s" must have the attribute "%s" in order to use attributes', is_string($objectOrClass) ? $objectOrClass : get_debug_type($objectOrClass), AsTwigExtension::class));
+            }
+
+            foreach ($reflectionClass->getMethods() as $method) {
path: src/Extension/AttributeExtension.php
user: login: GromNaN
body: I think that the logic for reading attributes should be in Twig,and the extension could receive the configuration already extracted. And Symfony TwigBundle will do the job of serializing and caching the configuration.

The `serializing` may be problematic in the future if closures are used in attributes for `safeCallback` ([RFC for PHP 8.5](https://wiki.php.net/rfc/closures_in_const_expr))
created_at: 2025-01-02T12:02:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,179 @@
+<?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;
+
+use Twig\Environment;
+use Twig\Extension\Attribute\AsTwigFilter;
+use Twig\Extension\Attribute\AsTwigFunction;
+use Twig\Extension\Attribute\AsTwigTest;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Define Twig filters, functions, and tests with PHP attributes.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class AttributeExtension extends AbstractExtension implements WithLastModified
+{
+    private array $filters;
+    private array $functions;
+    private array $tests;
+
+    public function __construct(
+        /**
+         * A list of objects or class names defining filters, functions, and tests using PHP attributes.
+         * When passing a class name, it must be available in runtimes.
+         *
+         * @var iterable<object|class-string>
+         */
+        private iterable $objectsOrClasses,
+    ) {
+    }
+
+    public function getLastModified(): int
+    {
+        $lastModified = 0;
+
+        foreach ($this->objectsOrClasses as $objectOrClass) {
+            $r = new \ReflectionClass($objectOrClass);
+            if (is_file($r->getFileName()) && ($extensionTime = filemtime($r->getFileName())) > $lastModified) {
+                $lastModified = $extensionTime;
+            }
+        }
+
+        return $lastModified;
+    }
+
+    public function getFilters(): array
+    {
+        if (!isset($this->filters)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->filters;
+    }
+
+    public function getFunctions(): array
+    {
+        if (!isset($this->functions)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->functions;
+    }
+
+    public function getTests(): array
+    {
+        if (!isset($this->tests)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->tests;
+    }
+
+    private function initFromAttributes()
+    {
+        $filters = $functions = $tests = [];
+
+        foreach ($this->objectsOrClasses as $objectOrClass) {
+            try {
+                $reflectionClass = new \ReflectionClass($objectOrClass);
+            } catch (\ReflectionException $e) {
+                throw new \LogicException(sprintf('"%s" class requires a list of objects or class name, "%s" given.', __CLASS__, get_debug_type($objectOrClass)), 0, $e);
+            }
+
+            foreach ($reflectionClass->getMethods() as $method) {
+                // Filters
+                foreach ($method->getAttributes(AsTwigFilter::class) as $attribute) {
+                    $attribute = $attribute->newInstance();
+
+                    $name = $attribute->name ?? $method->getName();
+                    if (isset($filters[$name])) {
+                        throw new \LogicException(sprintf('Multiple definitions of the "%s" filter.', $name));
+                    }
+
+                    $parameters = $method->getParameters();
+                    $needsEnvironment = isset($parameters[0]) && Environment::class === $parameters[0]->getType()?->getName();
+                    $firstParam = $needsEnvironment ? 1 : 0;
+                    $needsContext = isset($parameters[$firstParam]) && 'context' === $parameters[$firstParam]->getName() && 'array' === $parameters[$firstParam]->getType()?->getName();
path: src/Extension/AttributeExtension.php
user: login: GromNaN
body: Removed this fragile detection.
created_at: 2025-01-03T12:20:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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\Extension\Attribute;
+
+/**
+ * Identifies a class that uses PHP attributes to define filters, functions, or tests.
+ */
+#[\Attribute(\Attribute::TARGET_CLASS)]
+class AsTwigExtension
path: src/Extension/Attribute/AsTwigExtension.php
user: login: GromNaN
body: Symfony autoconfiguration can work on method attributes, so I removed the `AsTwigExtension` attribute.
created_at: 2025-01-03T12:21:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,162 @@
+<?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;
+
+use Twig\Attribute\AsTwigExtension;
+use Twig\Attribute\AsTwigFilter;
+use Twig\Attribute\AsTwigFunction;
+use Twig\Attribute\AsTwigTest;
+use Twig\Environment;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Define Twig filters, functions, and tests with PHP attributes.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ *
+ * @internal
+ */
+final class AttributeExtension extends AbstractExtension
+{
+    private array $classes;
+    private array $filters;
+    private array $functions;
+    private array $tests;
+
+    /**
+     * A list of objects or class names defining filters, functions, and tests using PHP attributes.
+     * When passing a class name, it must be available in runtimes.
+     *
+     * @param class-string[]
+     */
+    public function __construct(array $classes)
+    {
+        $this->classes = $classes;
+    }
+
+    public function getFilters(): array
+    {
+        if (!isset($this->filters)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->filters;
+    }
+
+    public function getFunctions(): array
+    {
+        if (!isset($this->functions)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->functions;
+    }
+
+    public function getTests(): array
+    {
+        if (!isset($this->tests)) {
+            $this->initFromAttributes();
+        }
+
+        return $this->tests;
+    }
+
+    private function initFromAttributes()
+    {
+        $filters = $functions = $tests = [];
+
+        foreach ($this->classes as $objectOrClass) {
+            try {
+                $reflectionClass = new \ReflectionClass($objectOrClass);
+            } catch (\ReflectionException $e) {
+                throw new \LogicException(sprintf('"%s" class requires a list of objects or class name, "%s" given.', __CLASS__, get_debug_type($objectOrClass)), 0, $e);
+            }
+
+            $attributes = $reflectionClass->getAttributes(AsTwigExtension::class);
+            if (!$attributes) {
+                throw new \LogicException(sprintf('Extension class "%s" must have the attribute "%s" in order to use attributes', is_string($objectOrClass) ? $objectOrClass : get_debug_type($objectOrClass), AsTwigExtension::class));
+            }
+
+            foreach ($reflectionClass->getMethods() as $method) {
path: src/Extension/AttributeExtension.php
user: login: GromNaN
body: > thinking about that, I'm wondering whether those `asTwigFilter`, `AsTwigFunction` and `AsTwigTest` attributes should be read by that extension at all.

Here is an other approach where attribute class can create the twig callable themself.
https://github.com/twigphp/Twig/blob/36a0732ee66bf405c2164282626fbdc8c23ae81a/src/Attribute/AsTwigFilter.php
created_at: 2025-01-03T21:03:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -142,7 +143,11 @@ public function getLastModified(): int
 
     public function addExtension(ExtensionInterface $extension): void
     {
-        $class = $extension::class;
+        if ($extension instanceof AttributeExtension) {
+            $class = $extension->getClass();
+        } else {
+            $class = $extension::class;
+        }
path: src/ExtensionSet.php
user: login: GromNaN
body: This is for cache invalidation when an extension is added/removed.

Instead of relying on this class check, I can introduce an interface: `DelegatingExtensionInterface` with the `getClass` method.
created_at: 2025-03-03T08:38:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -814,6 +814,107 @@ The ``getTests()`` method lets you add new test functions::
         // ...
     }
 
+Using PHP Attributes to define Extensions
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 3.21
+
+    The attribute classes were added in Twig 3.21.
+
+You can use the attributes ``#[AsTwigFilter]``, ``#[AsTwigFunction]``, and
+``#[AsTwigTest]`` on any method of any class to define filters, functions, and tests.
+
+Create a class using this attributes::
+
+    use Twig\Attribute\AsTwigFilter;
+    use Twig\Attribute\AsTwigFunction;
+    use Twig\Attribute\AsTwigTest;
+
+    class ProjectExtension
+    {
+        #[AsTwigFilter('rot13')]
+        public static function rot13(string $string): string
+        {
+            // ...
+        }
+
+        #[AsTwigFunction('lipsum')]
+        public static function lipsum(int $count): string
+        {
+            // ...
+        }
+
+        #[AsTwigTest('even')]
+        public static function isEven(int $number): bool
+        {
+            // ...
+        }
+    }
+
+Then register the ``Twig\Extension\AttributeExtension`` with the class name::
+
+    $twig = new \Twig\Environment($loader);
+    $twig->addExtension(new \Twig\Extension\AttributeExtension(ProjectExtension::class);
path: doc/advanced.rst
user: login: fabpot
body: ```suggestion
    $twig->addExtension(new \Twig\Extension\AttributeExtension(ProjectExtension::class));
```
created_at: 2025-03-03T08:52:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+<?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\Attribute;
+
+use Twig\DeprecatedCallableInfo;
+use Twig\TwigFilter;
+
+/**
+ * Registers a method as template filter.
+ *
+ * If the first argument of the method has Twig\Environment type-hint, the filter will receive the current environment.
+ * If the next argument of the method is named $context and has array type-hint, the filter will receive the current context.
+ * Additional arguments of the method come from the filter call.
+ *
+ *     #[AsTwigFilter('foo')]
+ *     function fooFilter(Environment $env, array $context, $string, $arg1 = null, ...) { ... }
+ *
+ *    {{ 'string'|foo(arg1) }}
+ *
+ * @see TwigFilter
+ */
+#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+final class AsTwigFilter
+{
+    /**
+     * @param non-empty-string $name The name of the filter in Twig.
+     * @param bool|null $needsCharset Whether the filter needs the charset passed as the first argument.
+     * @param bool|null $needsEnvironment Whether the filter needs the environment passed as the first argument, or after the charset.
+     * @param bool|null $needsContext Whether the filter needs the context array passed as the first argument, or after the charset and the environment.
+     * @param string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped.
+     * @param string|array|null $isSafeCallback Function called at compilation time to determine if the filter is safe.
+     * @param string|null $preEscape Some filters may need to work on input that is already escaped or safe
+     * @param string[]|null $preservesSafety Preserves the safety of the value that the filter is applied to.
+     * @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation
+     */
+    public function __construct(
+        public string $name,
path: src/Attribute/AsTwigFilter.php
user: login: fabpot
body: Maybe we can make it optional and use the method name if no name is provided?
created_at: 2025-03-03T08:53:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+<?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\Attribute;
+
+use Twig\DeprecatedCallableInfo;
+use Twig\TwigFilter;
+
+/**
+ * Registers a method as template filter.
+ *
+ * If the first argument of the method has Twig\Environment type-hint, the filter will receive the current environment.
+ * If the next argument of the method is named $context and has array type-hint, the filter will receive the current context.
+ * Additional arguments of the method come from the filter call.
+ *
+ *     #[AsTwigFilter('foo')]
+ *     function fooFilter(Environment $env, array $context, $string, $arg1 = null, ...) { ... }
+ *
+ *    {{ 'string'|foo(arg1) }}
+ *
+ * @see TwigFilter
+ */
+#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+final class AsTwigFilter
+{
+    /**
+     * @param non-empty-string $name The name of the filter in Twig.
+     * @param bool|null $needsCharset Whether the filter needs the charset passed as the first argument.
+     * @param bool|null $needsEnvironment Whether the filter needs the environment passed as the first argument, or after the charset.
+     * @param bool|null $needsContext Whether the filter needs the context array passed as the first argument, or after the charset and the environment.
+     * @param string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped.
+     * @param string|array|null $isSafeCallback Function called at compilation time to determine if the filter is safe.
+     * @param string|null $preEscape Some filters may need to work on input that is already escaped or safe
+     * @param string[]|null $preservesSafety Preserves the safety of the value that the filter is applied to.
+     * @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation
+     */
+    public function __construct(
+        public string $name,
path: src/Attribute/AsTwigFilter.php
user: login: GromNaN
body: There is a conflict in name convention as method names are [camelCase](https://symfony.com/doc/current/contributing/code/standards.html#naming-conventions) and Twig function names are [snake_case](https://twig.symfony.com/doc/3.x/coding_standards.html).

This was discussed with @stof in https://github.com/twigphp/Twig/pull/3916#discussion_r1405929739.

But I agree that this is duplicate information.
created_at: 2025-03-03T11:02:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+<?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\Attribute;
+
+use Twig\DeprecatedCallableInfo;
+use Twig\TwigFilter;
+
+/**
+ * Registers a method as template filter.
+ *
+ * If the first argument of the method has Twig\Environment type-hint, the filter will receive the current environment.
+ * If the next argument of the method is named $context and has array type-hint, the filter will receive the current context.
+ * Additional arguments of the method come from the filter call.
+ *
+ *     #[AsTwigFilter('foo')]
+ *     function fooFilter(Environment $env, array $context, $string, $arg1 = null, ...) { ... }
+ *
+ *    {{ 'string'|foo(arg1) }}
+ *
+ * @see TwigFilter
+ */
+#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+final class AsTwigFilter
+{
+    /**
+     * @param non-empty-string $name The name of the filter in Twig.
+     * @param bool|null $needsCharset Whether the filter needs the charset passed as the first argument.
+     * @param bool|null $needsEnvironment Whether the filter needs the environment passed as the first argument, or after the charset.
+     * @param bool|null $needsContext Whether the filter needs the context array passed as the first argument, or after the charset and the environment.
+     * @param string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped.
+     * @param string|array|null $isSafeCallback Function called at compilation time to determine if the filter is safe.
+     * @param string|null $preEscape Some filters may need to work on input that is already escaped or safe
+     * @param string[]|null $preservesSafety Preserves the safety of the value that the filter is applied to.
+     * @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation
+     */
+    public function __construct(
+        public string $name,
path: src/Attribute/AsTwigFilter.php
user: login: fabpot
body: Those are just conventions ;) And for single word names, that works well.
created_at: 2025-03-03T11:15:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+<?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\Attribute;
+
+use Twig\DeprecatedCallableInfo;
+use Twig\TwigFilter;
+
+/**
+ * Registers a method as template filter.
+ *
+ * If the first argument of the method has Twig\Environment type-hint, the filter will receive the current environment.
+ * If the next argument of the method is named $context and has array type-hint, the filter will receive the current context.
+ * Additional arguments of the method come from the filter call.
+ *
+ *     #[AsTwigFilter('foo')]
+ *     function fooFilter(Environment $env, array $context, $string, $arg1 = null, ...) { ... }
+ *
+ *    {{ 'string'|foo(arg1) }}
+ *
+ * @see TwigFilter
+ */
+#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+final class AsTwigFilter
+{
+    /**
+     * @param non-empty-string $name The name of the filter in Twig.
+     * @param bool|null $needsCharset Whether the filter needs the charset passed as the first argument.
+     * @param bool|null $needsEnvironment Whether the filter needs the environment passed as the first argument, or after the charset.
+     * @param bool|null $needsContext Whether the filter needs the context array passed as the first argument, or after the charset and the environment.
+     * @param string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped.
+     * @param string|array|null $isSafeCallback Function called at compilation time to determine if the filter is safe.
+     * @param string|null $preEscape Some filters may need to work on input that is already escaped or safe
+     * @param string[]|null $preservesSafety Preserves the safety of the value that the filter is applied to.
+     * @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation
+     */
+    public function __construct(
+        public string $name,
path: src/Attribute/AsTwigFilter.php
user: login: GromNaN
body: You're right, I made the name optional again.
created_at: 2025-03-03T11:48:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -814,6 +814,119 @@ The ``getTests()`` method lets you add new test functions::
         // ...
     }
 
+Using PHP Attributes to define Extensions
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 3.21
+
+    The attribute classes were added in Twig 3.21.
+
+You can use the attributes ``#[AsTwigFilter]``, ``#[AsTwigFunction]``, and
+``#[AsTwigTest]`` on any method of any class to define filters, functions, and tests.
+
+Create a class using this attributes::
path: doc/advanced.rst
user: login: kbond
body: ```suggestion
Create a class using these attributes::
```
created_at: 2025-03-06T12:42:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -814,6 +814,119 @@ The ``getTests()`` method lets you add new test functions::
         // ...
     }
 
+Using PHP Attributes to define Extensions
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 3.21
+
+    The attribute classes were added in Twig 3.21.
+
+You can use the attributes ``#[AsTwigFilter]``, ``#[AsTwigFunction]``, and
+``#[AsTwigTest]`` on any method of any class to define filters, functions, and tests.
path: doc/advanced.rst
user: login: kbond
body: ```suggestion
You can add the ``#[AsTwigFilter]``, ``#[AsTwigFunction]``, and
``#[AsTwigTest]`` attributes to public methods of any class to define filters, functions, and tests.
```
created_at: 2025-03-06T12:43:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -814,6 +814,119 @@ The ``getTests()`` method lets you add new test functions::
         // ...
     }
 
+Using PHP Attributes to define Extensions
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 3.21
+
+    The attribute classes were added in Twig 3.21.
+
+You can use the attributes ``#[AsTwigFilter]``, ``#[AsTwigFunction]``, and
+``#[AsTwigTest]`` on any method of any class to define filters, functions, and tests.
+
+Create a class using this attributes::
+
+    use Twig\Attribute\AsTwigFilter;
+    use Twig\Attribute\AsTwigFunction;
+    use Twig\Attribute\AsTwigTest;
+
+    class ProjectExtension
+    {
+        #[AsTwigFilter]
+        public static function rot13(string $string): string
+        {
+            // ...
+        }
+
+        #[AsTwigFunction]
+        public static function lipsum(int $count): string
+        {
+            // ...
+        }
+
+        #[AsTwigTest]
+        public static function even(int $number): bool
+        {
+            // ...
+        }
+    }
+
+Then register the ``Twig\Extension\AttributeExtension`` with the class name::
+
+    $twig = new \Twig\Environment($loader);
+    $twig->addExtension(new \Twig\Extension\AttributeExtension(ProjectExtension::class));
+
+If all the methods are static, you are done. The ``ProjectExtension`` class will
+never be instantiated and the class attributes will be scanned only when a template
+is compiled.
+
+Otherwise, if some methods are not static, you need to register the class as
+a runtime extension using one of the runtime loaders::
+
+    use Twig\Attribute\AsTwigFunction;
+
+    class ProjectExtension
+    {
+        // Inject hypothetical dependencies
+        public function __construct(private LipsumProvider $lipsumProvider) {}
+
+        #[AsTwigFunction]
+        public function lipsum(int $count): string
+        {
+            return $this->lipsumProvider->lipsum($count);
+        }
+    }
+
+    $twig = new \Twig\Environment($loader);
+    $twig->addExtension(new \Twig\Extension\AttributeExtension(ProjectExtension::class);
+    $twig->addRuntimeLoader(new \Twig\RuntimeLoader\FactoryLoader([
+        ProjectExtension::class => function () use ($lipsumProvider) {
+            return new ProjectExtension($lipsumProvider);
+        },
+    ]));
+
+The name can be customized to prevent conflict with other Twig callables and follow
+the :doc:`Coding Standards<../coding_standards>`::
+
+        class ProjectExtension
+        {
+            #[AsTwigFunction(name: 'name_in_snake_case')]
+            public function nameInCamelCase(int $count): string
+            {
+                // ...
+            }
+        }
+
+If you want to access the current environment instance in your filter or function,
+add the ``Twig\Environment`` type to the first argument of the method::
path: doc/advanced.rst
user: login: kbond
body: Does this automatically add the `needsEnvironment` option? What about `needsContext`? Is adding `array $context` enough?
created_at: 2025-03-06T12:45:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+<?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\Attribute;
+
+use Twig\DeprecatedCallableInfo;
+use Twig\TwigFilter;
+
+/**
+ * Registers a method as template filter.
+ *
+ * If the first argument of the method has Twig\Environment type-hint, the filter will receive the current environment.
+ * If the next argument of the method is named $context and has array type-hint, the filter will receive the current context.
+ * Additional arguments of the method come from the filter call.
+ *
+ *     #[AsTwigFilter('foo')]
+ *     function fooFilter(Environment $env, array $context, $string, $arg1 = null, ...) { ... }
+ *
+ *    {{ 'string'|foo(arg1) }}
+ *
+ * @see TwigFilter
+ */
+#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+final class AsTwigFilter
+{
+    /**
+     * @param non-empty-string $name The name of the filter in Twig.
+     * @param bool|null $needsCharset Whether the filter needs the charset passed as the first argument.
+     * @param bool|null $needsEnvironment Whether the filter needs the environment passed as the first argument, or after the charset.
+     * @param bool|null $needsContext Whether the filter needs the context array passed as the first argument, or after the charset and the environment.
+     * @param string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped.
+     * @param string|array|null $isSafeCallback Function called at compilation time to determine if the filter is safe.
+     * @param string|null $preEscape Some filters may need to work on input that is already escaped or safe
+     * @param string[]|null $preservesSafety Preserves the safety of the value that the filter is applied to.
+     * @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation
+     */
+    public function __construct(
+        public string $name,
path: src/Attribute/AsTwigFilter.php
user: login: stof
body: To me, the bigger argument is that Twig filter names are global, and so should be specific enough to avoid conflicts, while the method name is purely internal. For non-core extensions, filter names are generally namespaced with a prefix, but the method names in implementation don't have this prefix in the PHP code as the extension class already separates the PHP code.
created_at: 2025-03-06T14:22:50Z
author_association: MEMBER
diff_hunk: @@ -814,6 +814,119 @@ The ``getTests()`` method lets you add new test functions::
         // ...
     }
 
+Using PHP Attributes to define Extensions
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 3.21
+
+    The attribute classes were added in Twig 3.21.
+
+You can use the attributes ``#[AsTwigFilter]``, ``#[AsTwigFunction]``, and
+``#[AsTwigTest]`` on any method of any class to define filters, functions, and tests.
+
+Create a class using this attributes::
+
+    use Twig\Attribute\AsTwigFilter;
+    use Twig\Attribute\AsTwigFunction;
+    use Twig\Attribute\AsTwigTest;
+
+    class ProjectExtension
+    {
+        #[AsTwigFilter]
+        public static function rot13(string $string): string
+        {
+            // ...
+        }
+
+        #[AsTwigFunction]
+        public static function lipsum(int $count): string
+        {
+            // ...
+        }
+
+        #[AsTwigTest]
+        public static function even(int $number): bool
+        {
+            // ...
+        }
+    }
+
+Then register the ``Twig\Extension\AttributeExtension`` with the class name::
+
+    $twig = new \Twig\Environment($loader);
+    $twig->addExtension(new \Twig\Extension\AttributeExtension(ProjectExtension::class));
+
+If all the methods are static, you are done. The ``ProjectExtension`` class will
+never be instantiated and the class attributes will be scanned only when a template
+is compiled.
+
+Otherwise, if some methods are not static, you need to register the class as
+a runtime extension using one of the runtime loaders::
+
+    use Twig\Attribute\AsTwigFunction;
+
+    class ProjectExtension
+    {
+        // Inject hypothetical dependencies
+        public function __construct(private LipsumProvider $lipsumProvider) {}
+
+        #[AsTwigFunction]
+        public function lipsum(int $count): string
+        {
+            return $this->lipsumProvider->lipsum($count);
+        }
+    }
+
+    $twig = new \Twig\Environment($loader);
+    $twig->addExtension(new \Twig\Extension\AttributeExtension(ProjectExtension::class);
+    $twig->addRuntimeLoader(new \Twig\RuntimeLoader\FactoryLoader([
+        ProjectExtension::class => function () use ($lipsumProvider) {
+            return new ProjectExtension($lipsumProvider);
+        },
+    ]));
+
+The name can be customized to prevent conflict with other Twig callables and follow
+the :doc:`Coding Standards<../coding_standards>`::
+
+        class ProjectExtension
+        {
+            #[AsTwigFunction(name: 'name_in_snake_case')]
+            public function nameInCamelCase(int $count): string
+            {
+                // ...
+            }
+        }
+
+If you want to access the current environment instance in your filter or function,
+add the ``Twig\Environment`` type to the first argument of the method::
path: doc/advanced.rst
user: login: GromNaN
body: The detection of the context is less obvious (no specific type).
And it's very rarely used: `dump` is the only that uses it in all the Twig extra, Symfony and UX packages. Leaving it explicit seems to me to be more safe.
created_at: 2025-03-06T14:27:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -814,6 +814,119 @@ The ``getTests()`` method lets you add new test functions::
         // ...
     }
 
+Using PHP Attributes to define Extensions
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 3.21
+
+    The attribute classes were added in Twig 3.21.
+
+You can use the attributes ``#[AsTwigFilter]``, ``#[AsTwigFunction]``, and
+``#[AsTwigTest]`` on any method of any class to define filters, functions, and tests.
+
+Create a class using this attributes::
+
+    use Twig\Attribute\AsTwigFilter;
+    use Twig\Attribute\AsTwigFunction;
+    use Twig\Attribute\AsTwigTest;
+
+    class ProjectExtension
+    {
+        #[AsTwigFilter]
+        public static function rot13(string $string): string
+        {
+            // ...
+        }
+
+        #[AsTwigFunction]
+        public static function lipsum(int $count): string
+        {
+            // ...
+        }
+
+        #[AsTwigTest]
+        public static function even(int $number): bool
+        {
+            // ...
+        }
+    }
+
+Then register the ``Twig\Extension\AttributeExtension`` with the class name::
+
+    $twig = new \Twig\Environment($loader);
+    $twig->addExtension(new \Twig\Extension\AttributeExtension(ProjectExtension::class));
+
+If all the methods are static, you are done. The ``ProjectExtension`` class will
+never be instantiated and the class attributes will be scanned only when a template
+is compiled.
+
+Otherwise, if some methods are not static, you need to register the class as
+a runtime extension using one of the runtime loaders::
+
+    use Twig\Attribute\AsTwigFunction;
+
+    class ProjectExtension
+    {
+        // Inject hypothetical dependencies
+        public function __construct(private LipsumProvider $lipsumProvider) {}
+
+        #[AsTwigFunction]
+        public function lipsum(int $count): string
+        {
+            return $this->lipsumProvider->lipsum($count);
+        }
+    }
+
+    $twig = new \Twig\Environment($loader);
+    $twig->addExtension(new \Twig\Extension\AttributeExtension(ProjectExtension::class);
+    $twig->addRuntimeLoader(new \Twig\RuntimeLoader\FactoryLoader([
+        ProjectExtension::class => function () use ($lipsumProvider) {
+            return new ProjectExtension($lipsumProvider);
+        },
+    ]));
+
+The name can be customized to prevent conflict with other Twig callables and follow
+the :doc:`Coding Standards<../coding_standards>`::
+
+        class ProjectExtension
+        {
+            #[AsTwigFunction(name: 'name_in_snake_case')]
+            public function nameInCamelCase(int $count): string
+            {
+                // ...
+            }
+        }
+
+If you want to access the current environment instance in your filter or function,
+add the ``Twig\Environment`` type to the first argument of the method::
path: doc/advanced.rst
user: login: kbond
body: Agreed, just auto-detecting `needsEnvironment` seems best for these reasons.
created_at: 2025-03-06T14:33:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+<?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\Attribute;
+
+use Twig\DeprecatedCallableInfo;
+use Twig\TwigFilter;
+
+/**
+ * Registers a method as template filter.
+ *
+ * If the first argument of the method has Twig\Environment type-hint, the filter will receive the current environment.
+ * If the next argument of the method is named $context and has array type-hint, the filter will receive the current context.
+ * Additional arguments of the method come from the filter call.
+ *
+ *     #[AsTwigFilter('foo')]
+ *     function fooFilter(Environment $env, array $context, $string, $arg1 = null, ...) { ... }
+ *
+ *    {{ 'string'|foo(arg1) }}
+ *
+ * @see TwigFilter
+ */
+#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+final class AsTwigFilter
+{
+    /**
+     * @param non-empty-string $name The name of the filter in Twig.
+     * @param bool|null $needsCharset Whether the filter needs the charset passed as the first argument.
+     * @param bool|null $needsEnvironment Whether the filter needs the environment passed as the first argument, or after the charset.
+     * @param bool|null $needsContext Whether the filter needs the context array passed as the first argument, or after the charset and the environment.
+     * @param string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped.
+     * @param string|array|null $isSafeCallback Function called at compilation time to determine if the filter is safe.
+     * @param string|null $preEscape Some filters may need to work on input that is already escaped or safe
+     * @param string[]|null $preservesSafety Preserves the safety of the value that the filter is applied to.
+     * @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation
+     */
+    public function __construct(
+        public string $name,
path: src/Attribute/AsTwigFilter.php
user: login: GromNaN
body: Getting the method name simplifies the creation of function/filters in the scope of an app. For shared libraries, it's still possible and encouraged to set a prefixed name.
created_at: 2025-03-06T14:51:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+<?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\Attribute;
+
+use Twig\DeprecatedCallableInfo;
+use Twig\TwigFilter;
+
+/**
+ * Registers a method as template filter.
+ *
+ * If the first argument of the method has Twig\Environment type-hint, the filter will receive the current environment.
+ * If the next argument of the method is named $context and has array type-hint, the filter will receive the current context.
+ * Additional arguments of the method come from the filter call.
+ *
+ *     #[AsTwigFilter('foo')]
+ *     function fooFilter(Environment $env, array $context, $string, $arg1 = null, ...) { ... }
+ *
+ *    {{ 'string'|foo(arg1) }}
+ *
+ * @see TwigFilter
+ */
+#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+final class AsTwigFilter
+{
+    /**
+     * @param non-empty-string $name The name of the filter in Twig.
+     * @param bool|null $needsCharset Whether the filter needs the charset passed as the first argument.
+     * @param bool|null $needsEnvironment Whether the filter needs the environment passed as the first argument, or after the charset.
+     * @param bool|null $needsContext Whether the filter needs the context array passed as the first argument, or after the charset and the environment.
+     * @param string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped.
+     * @param string|array|null $isSafeCallback Function called at compilation time to determine if the filter is safe.
+     * @param string|null $preEscape Some filters may need to work on input that is already escaped or safe
+     * @param string[]|null $preservesSafety Preserves the safety of the value that the filter is applied to.
+     * @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation
+     */
+    public function __construct(
+        public string $name,
path: src/Attribute/AsTwigFilter.php
user: login: stof
body: Also, my previous comment (linked above) still applies: as the Twig filter name is part of the public API of packages providing an extension (it might even be their main public API if they are all about extending Twig), we won't ever be able to change the algorithm in Twig as changing the guessed name would break those packages (technically, we could by implementing both algorithm in parallel, keeping the old return value and triggering a deprecation in case the new one produces a different result, asking projects to stop relying on guessing, but this means that any change in the algorithm requires waiting for the next major version to be active).
created_at: 2025-03-06T14:53:18Z
author_association: MEMBER
diff_hunk: @@ -814,6 +814,119 @@ The ``getTests()`` method lets you add new test functions::
         // ...
     }
 
+Using PHP Attributes to define Extensions
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 3.21
+
+    The attribute classes were added in Twig 3.21.
+
+You can use the attributes ``#[AsTwigFilter]``, ``#[AsTwigFunction]``, and
+``#[AsTwigTest]`` on any method of any class to define filters, functions, and tests.
+
+Create a class using this attributes::
+
+    use Twig\Attribute\AsTwigFilter;
+    use Twig\Attribute\AsTwigFunction;
+    use Twig\Attribute\AsTwigTest;
+
+    class ProjectExtension
+    {
+        #[AsTwigFilter]
+        public static function rot13(string $string): string
+        {
+            // ...
+        }
+
+        #[AsTwigFunction]
+        public static function lipsum(int $count): string
+        {
+            // ...
+        }
+
+        #[AsTwigTest]
+        public static function even(int $number): bool
+        {
+            // ...
+        }
+    }
+
+Then register the ``Twig\Extension\AttributeExtension`` with the class name::
+
+    $twig = new \Twig\Environment($loader);
+    $twig->addExtension(new \Twig\Extension\AttributeExtension(ProjectExtension::class));
+
+If all the methods are static, you are done. The ``ProjectExtension`` class will
+never be instantiated and the class attributes will be scanned only when a template
+is compiled.
+
+Otherwise, if some methods are not static, you need to register the class as
+a runtime extension using one of the runtime loaders::
+
+    use Twig\Attribute\AsTwigFunction;
+
+    class ProjectExtension
+    {
+        // Inject hypothetical dependencies
+        public function __construct(private LipsumProvider $lipsumProvider) {}
+
+        #[AsTwigFunction]
+        public function lipsum(int $count): string
+        {
+            return $this->lipsumProvider->lipsum($count);
+        }
+    }
+
+    $twig = new \Twig\Environment($loader);
+    $twig->addExtension(new \Twig\Extension\AttributeExtension(ProjectExtension::class);
+    $twig->addRuntimeLoader(new \Twig\RuntimeLoader\FactoryLoader([
+        ProjectExtension::class => function () use ($lipsumProvider) {
+            return new ProjectExtension($lipsumProvider);
+        },
+    ]));
+
+The name can be customized to prevent conflict with other Twig callables and follow
+the :doc:`Coding Standards<../coding_standards>`::
+
+        class ProjectExtension
+        {
+            #[AsTwigFunction(name: 'name_in_snake_case')]
+            public function nameInCamelCase(int $count): string
+            {
+                // ...
+            }
+        }
+
+If you want to access the current environment instance in your filter or function,
+add the ``Twig\Environment`` type to the first argument of the method::
path: doc/advanced.rst
user: login: GromNaN
body: I did the detection initially: https://github.com/twigphp/Twig/blob/c5c00ed6c64ea2470f8a0dcb4c1fc18682f8fa5c/src/Extension/Extension.php

created_at: 2025-03-06T15:05:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+<?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\Attribute;
+
+use Twig\DeprecatedCallableInfo;
+use Twig\TwigFilter;
+
+/**
+ * Registers a method as template filter.
+ *
+ * If the first argument of the method has Twig\Environment type-hint, the filter will receive the current environment.
+ * If the next argument of the method is named $context and has array type-hint, the filter will receive the current context.
+ * Additional arguments of the method come from the filter call.
+ *
+ *     #[AsTwigFilter('foo')]
+ *     function fooFilter(Environment $env, array $context, $string, $arg1 = null, ...) { ... }
+ *
+ *    {{ 'string'|foo(arg1) }}
+ *
+ * @see TwigFilter
+ */
+#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+final class AsTwigFilter
+{
+    /**
+     * @param non-empty-string $name The name of the filter in Twig.
+     * @param bool|null $needsCharset Whether the filter needs the charset passed as the first argument.
+     * @param bool|null $needsEnvironment Whether the filter needs the environment passed as the first argument, or after the charset.
+     * @param bool|null $needsContext Whether the filter needs the context array passed as the first argument, or after the charset and the environment.
+     * @param string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped.
+     * @param string|array|null $isSafeCallback Function called at compilation time to determine if the filter is safe.
+     * @param string|null $preEscape Some filters may need to work on input that is already escaped or safe
+     * @param string[]|null $preservesSafety Preserves the safety of the value that the filter is applied to.
+     * @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation
+     */
+    public function __construct(
+        public string $name,
path: src/Attribute/AsTwigFilter.php
user: login: GromNaN
body: Keeping the name of the method is the best that can be done. Trying to apply a transformation (prefix, snake case) would make it impossible to find the name when searching in the source code. 

If I understand correctly, what you want is to force developers to think of a name? For me, that's the only good reason to make the name required.
created_at: 2025-03-06T15:39:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,177 @@
+<?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;
+
+use Twig\Attribute\AsTwigFilter;
+use Twig\Attribute\AsTwigFunction;
+use Twig\Attribute\AsTwigTest;
+use Twig\Environment;
+use Twig\TwigFilter;
+use Twig\TwigFunction;
+use Twig\TwigTest;
+
+/**
+ * Define Twig filters, functions, and tests with PHP attributes.
+ *
+ * @author Jérôme Tamarelle <jerome@tamarelle.net>
+ */
+final class AttributeExtension extends AbstractExtension
path: src/Extension/AttributeExtension.php
user: login: GromNaN
body: It would be possible to extend this class to create an extension without runtime dependency. This is interesting to simplify standalone usage only, as Symfony will abstract the runtime declaration part.

```php
class CustomExtension extends AttributeExtension
{
    public function __construct()
    {
        // This can be set automatically for child classes
        parent::__construct(self::class);
    }

    #[AsTwigFunction('foo')]
    public function foo(): string
    {
        return 'foo';
    }
}
```

Reflection for a subsequent PR.
created_at: 2025-03-12T17:19:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,57 @@
+<?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\Attribute;
+
+use Twig\DeprecatedCallableInfo;
+use Twig\TwigFilter;
+
+/**
+ * Registers a method as template filter.
+ *
+ * If the first argument of the method has Twig\Environment type-hint, the filter will receive the current environment.
+ * If the next argument of the method is named $context and has array type-hint, the filter will receive the current context.
+ * Additional arguments of the method come from the filter call.
+ *
+ *     #[AsTwigFilter('foo')]
+ *     function fooFilter(Environment $env, array $context, $string, $arg1 = null, ...) { ... }
+ *
+ *    {{ 'string'|foo(arg1) }}
+ *
+ * @see TwigFilter
+ */
+#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+final class AsTwigFilter
+{
+    /**
+     * @param non-empty-string $name The name of the filter in Twig.
+     * @param bool|null $needsCharset Whether the filter needs the charset passed as the first argument.
+     * @param bool|null $needsEnvironment Whether the filter needs the environment passed as the first argument, or after the charset.
+     * @param bool|null $needsContext Whether the filter needs the context array passed as the first argument, or after the charset and the environment.
+     * @param string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped.
+     * @param string|array|null $isSafeCallback Function called at compilation time to determine if the filter is safe.
+     * @param string|null $preEscape Some filters may need to work on input that is already escaped or safe
+     * @param string[]|null $preservesSafety Preserves the safety of the value that the filter is applied to.
+     * @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation
+     */
+    public function __construct(
+        public string $name,
path: src/Attribute/AsTwigFilter.php
user: login: GromNaN
body: I reverted the default name fallback. This can be added later.
created_at: 2025-03-12T17:29:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -814,6 +814,119 @@ The ``getTests()`` method lets you add new test functions::
         // ...
     }
 
+Using PHP Attributes to define Extensions
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 3.21
+
+    The attribute classes were added in Twig 3.21.
+
+You can use the attributes ``#[AsTwigFilter]``, ``#[AsTwigFunction]``, and
+``#[AsTwigTest]`` on any method of any class to define filters, functions, and tests.
+
+Create a class using this attributes::
+
+    use Twig\Attribute\AsTwigFilter;
+    use Twig\Attribute\AsTwigFunction;
+    use Twig\Attribute\AsTwigTest;
+
+    class ProjectExtension
+    {
+        #[AsTwigFilter]
+        public static function rot13(string $string): string
+        {
+            // ...
+        }
+
+        #[AsTwigFunction]
+        public static function lipsum(int $count): string
+        {
+            // ...
+        }
+
+        #[AsTwigTest]
+        public static function even(int $number): bool
+        {
+            // ...
+        }
+    }
+
+Then register the ``Twig\Extension\AttributeExtension`` with the class name::
+
+    $twig = new \Twig\Environment($loader);
+    $twig->addExtension(new \Twig\Extension\AttributeExtension(ProjectExtension::class));
+
+If all the methods are static, you are done. The ``ProjectExtension`` class will
+never be instantiated and the class attributes will be scanned only when a template
+is compiled.
+
+Otherwise, if some methods are not static, you need to register the class as
+a runtime extension using one of the runtime loaders::
+
+    use Twig\Attribute\AsTwigFunction;
+
+    class ProjectExtension
+    {
+        // Inject hypothetical dependencies
+        public function __construct(private LipsumProvider $lipsumProvider) {}
+
+        #[AsTwigFunction]
+        public function lipsum(int $count): string
+        {
+            return $this->lipsumProvider->lipsum($count);
+        }
+    }
+
+    $twig = new \Twig\Environment($loader);
+    $twig->addExtension(new \Twig\Extension\AttributeExtension(ProjectExtension::class);
+    $twig->addRuntimeLoader(new \Twig\RuntimeLoader\FactoryLoader([
+        ProjectExtension::class => function () use ($lipsumProvider) {
+            return new ProjectExtension($lipsumProvider);
+        },
+    ]));
+
+The name can be customized to prevent conflict with other Twig callables and follow
+the :doc:`Coding Standards<../coding_standards>`::
+
+        class ProjectExtension
+        {
+            #[AsTwigFunction(name: 'name_in_snake_case')]
+            public function nameInCamelCase(int $count): string
+            {
+                // ...
+            }
+        }
+
+If you want to access the current environment instance in your filter or function,
+add the ``Twig\Environment`` type to the first argument of the method::
path: doc/advanced.rst
user: login: fabpot
body: I'm confused. `needsEnvironment` is auto-detected, right?
created_at: 2025-03-24T20:58:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -814,6 +814,119 @@ The ``getTests()`` method lets you add new test functions::
         // ...
     }
 
+Using PHP Attributes to define Extensions
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 3.21
+
+    The attribute classes were added in Twig 3.21.
+
+You can use the attributes ``#[AsTwigFilter]``, ``#[AsTwigFunction]``, and
+``#[AsTwigTest]`` on any method of any class to define filters, functions, and tests.
+
+Create a class using this attributes::
+
+    use Twig\Attribute\AsTwigFilter;
+    use Twig\Attribute\AsTwigFunction;
+    use Twig\Attribute\AsTwigTest;
+
+    class ProjectExtension
+    {
+        #[AsTwigFilter]
+        public static function rot13(string $string): string
+        {
+            // ...
+        }
+
+        #[AsTwigFunction]
+        public static function lipsum(int $count): string
+        {
+            // ...
+        }
+
+        #[AsTwigTest]
+        public static function even(int $number): bool
+        {
+            // ...
+        }
+    }
+
+Then register the ``Twig\Extension\AttributeExtension`` with the class name::
+
+    $twig = new \Twig\Environment($loader);
+    $twig->addExtension(new \Twig\Extension\AttributeExtension(ProjectExtension::class));
+
+If all the methods are static, you are done. The ``ProjectExtension`` class will
+never be instantiated and the class attributes will be scanned only when a template
+is compiled.
+
+Otherwise, if some methods are not static, you need to register the class as
+a runtime extension using one of the runtime loaders::
+
+    use Twig\Attribute\AsTwigFunction;
+
+    class ProjectExtension
+    {
+        // Inject hypothetical dependencies
+        public function __construct(private LipsumProvider $lipsumProvider) {}
+
+        #[AsTwigFunction]
+        public function lipsum(int $count): string
+        {
+            return $this->lipsumProvider->lipsum($count);
+        }
+    }
+
+    $twig = new \Twig\Environment($loader);
+    $twig->addExtension(new \Twig\Extension\AttributeExtension(ProjectExtension::class);
+    $twig->addRuntimeLoader(new \Twig\RuntimeLoader\FactoryLoader([
+        ProjectExtension::class => function () use ($lipsumProvider) {
+            return new ProjectExtension($lipsumProvider);
+        },
+    ]));
+
+The name can be customized to prevent conflict with other Twig callables and follow
+the :doc:`Coding Standards<../coding_standards>`::
+
+        class ProjectExtension
+        {
+            #[AsTwigFunction(name: 'name_in_snake_case')]
+            public function nameInCamelCase(int $count): string
+            {
+                // ...
+            }
+        }
+
+If you want to access the current environment instance in your filter or function,
+add the ``Twig\Environment`` type to the first argument of the method::
path: doc/advanced.rst
user: login: kbond
body: `needsEnvironment` *is* auto-detected - `needsContext` is not.
created_at: 2025-03-24T21:13:52Z
author_association: CONTRIBUTOR

PR comments:
user: login: GromNaN
created_at: 2023-11-26T09:36:45Z
body: I'll rework the implementation after reading discussions on symfony/symfony#50016
author_association: CONTRIBUTOR
user: login: GromNaN
created_at: 2024-01-03T09:04:27Z
body: In all honesty, I'm not totally convinced by this implementation. The fact that extensions have to be registered in 2 places (as a runtime extension and for definition in AttributeExtension) makes them difficult to use with Twig standalone, this is totally hidden for Symfony users with the TwigBundle.

I would like to add a new `Environment::add(object $extension)` method that would accept any class with attributes. Invocable classes could be used to define filters and functions.
author_association: CONTRIBUTOR
user: login: nicolas-grekas
created_at: 2024-01-03T09:21:43Z
body: What is a bit strange is having one single instance of AttributeExtension be responsible for registering many runtimes.
Maybe we should instead have one AttributeExtension per runtime?
Then it might be easier to use this standalone : `addExtension(new AttributeExtension(new AttributeBasedRuntime)))` (and such runtimes could have a static factory to make this even easier to create).
Note that this suggestion might be wrong as I didn't think of how runtimes should be made lazy-instantiated.
author_association: CONTRIBUTOR
user: login: kbond
created_at: 2024-01-03T16:13:53Z
body: For reference, this is my attempt/experiment to solve the problem of apps having to create a bunch of twig extensions for things: https://github.com/zenstruck/twig-service-bundle
author_association: CONTRIBUTOR
user: login: GromNaN
created_at: 2025-01-03T19:03:55Z
body: > What is a bit strange is having one single instance of AttributeExtension be responsible for registering many runtimes. Maybe we should instead have one AttributeExtension per runtime? Then it might be easier to use this standalone : `addExtension(new AttributeExtension(new AttributeBasedRuntime)))` (and such runtimes could have a static factory to make this even easier to create). Note that this suggestion might be wrong as I didn't think of how runtimes should be made lazy-instantiated.

I refactored once again to get 1 instance of `AttributeExtension` for each class. This allows to list the encapsulated class in the `ExtensionSet::getSignature` to invalidate the cache when an extension class is removed.

Also, I removed the ability to pass an instance of an object to `AttributeExtension`, this would require proxying calls to `ExtensionSet::getExtension('MyClass')->functionCall()` to the wrapped object. This can be implemented in a follow-up PR.

The doc is up-to-date on how to use it standalone. The Symfony implementation need to be updated.
author_association: CONTRIBUTOR
user: login: nicolas-grekas
created_at: 2025-03-02T19:19:51Z
body: git rebase FTW ;)
author_association: CONTRIBUTOR
user: login: fabpot
created_at: 2025-03-24T21:34:27Z
body: Thank you @GromNaN.
author_association: CONTRIBUTOR
user: login: GromNaN
created_at: 2025-03-26T10:08:23Z
body: Documentation: [Using PHP Attributes to define Extensions](https://twig.symfony.com/doc/3.x/advanced.html#using-php-attributes-to-define-extensions)
author_association: CONTRIBUTOR