Skip to content

Allow extending AttributeExtension to declare callables with attributes - #4995

Open
GromNaN wants to merge 1 commit into
twigphp:3.xfrom
GromNaN:GromNaN/attribute-extension-lazy
Open

GromNaN wants to merge 1 commit into
twigphp:3.xfrom
GromNaN:GromNaN/attribute-extension-lazy

Conversation

@GromNaN

@GromNaN GromNaN commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Follows #4989.

An existing extension can declare its filters, functions, and tests with PHP attributes instead of implementing getFilters(), getFunctions(), and getTests(). Add the #[AsTwigFilter], #[AsTwigFunction], and #[AsTwigTest] attributes on the methods, extend AttributeExtension, and remove the three getters:

use Twig\Attribute\AsTwigFunction;
use Twig\Extension\AttributeExtension;

class ProjectExtension extends AttributeExtension
{
    public function __construct(private Mailer $mailer) {}

    #[AsTwigFunction('lipsum')]
    public function lipsum(int $count): string { ... }
}

The class is still a regular extension, so it is registered as usual:

$twig->addExtension(new ProjectExtension($mailer));

It also works with lazy registration: AttributeExtension is registered with the class that holds the attributes, and a runtime loader builds that class on demand:

$twig->addExtension(new AttributeExtension(ProjectExtension::class));
$twig->addRuntimeLoader(new FactoryRuntimeLoader([
    ProjectExtension::class => fn () => new ProjectExtension($mailer),
]));

All the extension methods are final, so a subclass declares nothing else than the attributes to read. Globals cannot be declared with attributes: getGlobals() is final and throws, so a subclass cannot provide globals of its own.

Symfony counterpart: symfony/symfony#67135.

@GromNaN GromNaN changed the title Add AttributeExtensionTrait to declare an extension's callables with attributes [3.x] Add AttributeExtensionTrait to declare an extension's callables with attributes Oct 16, 2026
@GromNaN GromNaN changed the title [3.x] Add AttributeExtensionTrait to declare an extension's callables with attributes Allow extending AttributeExtension to declare callables with attributes Oct 16, 2026
@stof

stof commented Oct 16, 2026

Copy link
Copy Markdown
Member

The PR title and description don't reflect the implementation.

Comment thread src/Extension/AttributeExtensionTrait.php Outdated
Comment thread src/Extension/AttributeExtensionTrait.php Outdated
Comment thread src/Extension/AttributeExtensionTrait.php Outdated
Comment thread src/Extension/AttributeExtensionTrait.php Outdated
Comment thread src/Extension/AttributeExtensionTrait.php Outdated
@GromNaN

GromNaN commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor Author

The PR title and description don't reflect the implementation.

Sorry, I forgot to push.
I'm think extending AttributeExtension is better for the DX: no @internal, only a class to extend.

The only disadvantage is the exposed getClass() method.

@GromNaN
GromNaN force-pushed the GromNaN/attribute-extension-lazy branch 4 times, most recently from 0c0108d to d534ef7 Compare October 16, 2026 19:31
@fabpot

fabpot commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

The commit message (description) is not accurate anymore :)

@fabpot fabpot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that Symfony's LazyExtensionPass won't register subclasses of AttributeExtension lazily, because AttributeExtension::getLastModified() redeclares an AbstractExtension method that its check doesn't allow; it'll need a follow-up PR on Symfony as well.

Comment thread src/Extension/AttributeExtension.php
Comment thread src/Environment.php Outdated
@GromNaN

GromNaN commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor Author

All the extension methods are now final: getTokenParsers(), getNodeVisitors(), getOperators(), getExpressionParsers(), getGlobals(), and getLastModified(), in addition to getClass(), getFilters(), getFunctions(), and getTests(). AttributeExtension extends AbstractExtension again, since dropping it would be a BC break.

getGlobals() is final and throws, so a subclass cannot declare globals of its own. Since the method is final, the check does not need to reflect or load anything.

Environment::getExtension() now checks that the registered extension is AttributeExtension itself, as suggested.

The Symfony PR that relies on this change is open: symfony/symfony#67135.

Comment thread src/Extension/AttributeExtension.php Outdated
* @author Jérôme Tamarelle <jerome@tamarelle.net>
*/
final class AttributeExtension extends AbstractExtension
class AttributeExtension implements LastModifiedExtensionInterface

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping extends AbstractExtension is a BC break. I would add it back as removing it is not needed.

Comment thread src/Extension/AttributeExtension.php Outdated
throw new \InvalidArgumentException(\sprintf('Do not call the parent constructor when extending "%s", the attributes are read from the extension class itself.', self::class));
}

if ($this instanceof GlobalsInterface) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is bypassed if a subclass implements GlobalsInterface and has its own constructor.
What about checking is_a($extension->getClass(), GlobalsInterface::class, true) in ExtensionSet::addExtension() for AttributeExtension instances?

@GromNaN GromNaN Oct 19, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated: getGlobals() is now final and throws, so a subclass cannot declare globals of its own. Being final, the guard needs no reflection and does not load the class.

I first tried a check in ExtensionSet::addExtension(), but that would have forced loading the extension class even when it is never used.

@GromNaN
GromNaN force-pushed the GromNaN/attribute-extension-lazy branch 2 times, most recently from c1a248c to 7a22012 Compare October 19, 2026 07:09
Make AttributeExtension extendable so an extension class can extend it and
declare its filters, functions, and tests with the #[AsTwigFilter],
#[AsTwigFunction], and #[AsTwigTest] attributes.

All the extension methods are final, so a subclass declares nothing else
than the callables to read from the attributes. getClass() returns the
class passed to the constructor, or static::class when the extension is
extended.

The constructor enforces the two usages. An AttributeExtension that is not
extended must be given the name of the class to read the attributes from,
and cannot use AttributeExtension::class. An extended AttributeExtension
cannot be given another class than its own.

Globals cannot be declared with attributes: getGlobals() is final and
throws, so a subclass cannot provide globals of its own.

Environment::getExtension() checks that the registered extension is
AttributeExtension itself instead of any AttributeExtension instance.
@GromNaN
GromNaN force-pushed the GromNaN/attribute-extension-lazy branch from 7a22012 to bf3f355 Compare October 19, 2026 07:23
@GromNaN
GromNaN requested a review from fabpot October 19, 2026 08:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants