diff --git a/CHANGELOG.md b/CHANGELOG.md index 775f87e6..40d5be35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,29 @@ CHANGELOG ========= +7.3 +--- + + * Enable `#[AsTwigFilter]`, `#[AsTwigFunction]` and `#[AsTwigTest]` attributes + to configure extensions on runtime classes + * Add support for a `twig` validator + * Use `ChainCache` to store warmed-up cache in `kernel.build_dir` and runtime cache in `kernel.cache_dir` + * Make `TemplateCacheWarmer` use `kernel.build_dir` instead of `kernel.cache_dir` + +7.1 +--- + + * Mark class `TemplateCacheWarmer` as `final` + +7.0 +--- + + * Remove the `Twig_Environment` autowiring alias, use `Twig\Environment` instead + * Remove option `twig.autoescape`; create a class that implements your escaping strategy + (check `FileExtensionEscapingStrategy::guess()` for inspiration) and reference it using + the `twig.autoescape_service` option instead + * Drop support for Twig 2 + 6.4 --- diff --git a/CacheWarmer/TemplateCacheWarmer.php b/CacheWarmer/TemplateCacheWarmer.php index 2ab80113..3bb89760 100644 --- a/CacheWarmer/TemplateCacheWarmer.php +++ b/CacheWarmer/TemplateCacheWarmer.php @@ -14,6 +14,8 @@ use Psr\Container\ContainerInterface; use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; use Symfony\Contracts\Service\ServiceSubscriberInterface; +use Twig\Cache\CacheInterface; +use Twig\Cache\NullCache; use Twig\Environment; use Twig\Error\Error; @@ -21,40 +23,61 @@ * Generates the Twig cache for all templates. * * @author Fabien Potencier + * + * @final since Symfony 7.1 */ class TemplateCacheWarmer implements CacheWarmerInterface, ServiceSubscriberInterface { - private ContainerInterface $container; private Environment $twig; - private iterable $iterator; - - public function __construct(ContainerInterface $container, iterable $iterator) - { - // As this cache warmer is optional, dependencies should be lazy-loaded, that's why a container should be injected. - $this->container = $container; - $this->iterator = $iterator; - } /** - * @param string|null $buildDir + * As this cache warmer is optional, dependencies should be lazy-loaded, that's why a container should be injected. */ - public function warmUp(string $cacheDir /* , string $buildDir = null */): array + public function __construct( + private ContainerInterface $container, + private iterable $iterator, + private ?CacheInterface $cache = null, + ) { + } + + public function warmUp(string $cacheDir, ?string $buildDir = null): array { $this->twig ??= $this->container->get('twig'); - foreach ($this->iterator as $template) { - try { - $this->twig->load($template); - } catch (Error) { + $originalCache = $this->twig->getCache(); + if ($originalCache instanceof NullCache) { + // There's no point to warm up a cache that won't be used afterward + return []; + } + + if (null !== $this->cache) { + if (!$buildDir) { /* - * Problem during compilation, give up for this template (e.g. syntax errors). - * Failing silently here allows to ignore templates that rely on functions that aren't available in - * the current environment. For example, the WebProfilerBundle shouldn't be available in the prod - * environment, but some templates that are never used in prod might rely on functions the bundle provides. - * As we can't detect which templates are "really" important, we try to load all of them and ignore - * errors. Error checks may be performed by calling the lint:twig command. + * The cache has already been warmup during the build of the container, when $buildDir was set. */ + return []; + } + // Swap the cache for the warmup as the Twig Environment has the ChainCache injected + $this->twig->setCache($this->cache); + } + + try { + foreach ($this->iterator as $template) { + try { + $this->twig->load($template); + } catch (Error) { + /* + * Problem during compilation, give up for this template (e.g. syntax errors). + * Failing silently here allows to ignore templates that rely on functions that aren't available in + * the current environment. For example, the WebProfilerBundle shouldn't be available in the prod + * environment, but some templates that are never used in prod might rely on functions the bundle provides. + * As we can't detect which templates are "really" important, we try to load all of them and ignore + * errors. Error checks may be performed by calling the lint:twig command. + */ + } } + } finally { + $this->twig->setCache($originalCache); } return []; diff --git a/DependencyInjection/Compiler/AttributeExtensionPass.php b/DependencyInjection/Compiler/AttributeExtensionPass.php new file mode 100644 index 00000000..35487486 --- /dev/null +++ b/DependencyInjection/Compiler/AttributeExtensionPass.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\TwigBundle\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\LogicException; +use Twig\Attribute\AsTwigFilter; +use Twig\Attribute\AsTwigFunction; +use Twig\Attribute\AsTwigTest; +use Twig\Extension\AbstractExtension; +use Twig\Extension\AttributeExtension; +use Twig\Extension\ExtensionInterface; + +/** + * Register an instance of AttributeExtension for each service using the + * PHP attributes to declare Twig callables. + * + * @author Jérôme Tamarelle + * + * @internal + */ +final class AttributeExtensionPass implements CompilerPassInterface +{ + private const TAG = 'twig.attribute_extension'; + + public static function autoconfigureFromAttribute(ChildDefinition $definition, AsTwigFilter|AsTwigFunction|AsTwigTest $attribute, \ReflectionMethod $reflector): void + { + $class = $reflector->getDeclaringClass(); + if ($class->implementsInterface(ExtensionInterface::class)) { + if ($class->isSubclassOf(AbstractExtension::class)) { + throw new LogicException(\sprintf('The class "%s" cannot extend "%s" and use the "#[%s]" attribute on method "%s()", choose one or the other.', $class->name, AbstractExtension::class, $attribute::class, $reflector->name)); + } + throw new LogicException(\sprintf('The class "%s" cannot implement "%s" and use the "#[%s]" attribute on method "%s()", choose one or the other.', $class->name, ExtensionInterface::class, $attribute::class, $reflector->name)); + } + + $definition->addTag(self::TAG); + + // The service must be tagged as a runtime to call non-static methods + if (!$reflector->isStatic()) { + $definition->addTag('twig.runtime'); + } + } + + public function process(ContainerBuilder $container): void + { + foreach ($container->findTaggedServiceIds(self::TAG, true) as $id => $tags) { + $container->register('.twig.extension.'.$id, AttributeExtension::class) + ->setArguments([$container->getDefinition($id)->getClass()]) + ->addTag('twig.extension'); + } + } +} diff --git a/DependencyInjection/Compiler/ExtensionPass.php b/DependencyInjection/Compiler/ExtensionPass.php index 63dd68e9..b21e4f37 100644 --- a/DependencyInjection/Compiler/ExtensionPass.php +++ b/DependencyInjection/Compiler/ExtensionPass.php @@ -15,6 +15,7 @@ use Symfony\Component\DependencyInjection\Alias; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\Emoji\EmojiTransliterator; use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Workflow\Workflow; @@ -25,15 +26,16 @@ */ class ExtensionPass implements CompilerPassInterface { - /** - * @return void - */ - public function process(ContainerBuilder $container) + public function process(ContainerBuilder $container): void { if (!class_exists(Packages::class)) { $container->removeDefinition('twig.extension.assets'); } + if (!class_exists(\Transliterator::class) || !class_exists(EmojiTransliterator::class)) { + $container->removeDefinition('twig.extension.emoji'); + } + if (!class_exists(Expression::class)) { $container->removeDefinition('twig.extension.expression'); } @@ -128,6 +130,10 @@ public function process(ContainerBuilder $container) $container->getDefinition('twig.extension.expression')->addTag('twig.extension'); } + if ($container->hasDefinition('twig.extension.emoji')) { + $container->getDefinition('twig.extension.emoji')->addTag('twig.extension'); + } + if (!class_exists(Workflow::class) || !$container->has('workflow.registry')) { $container->removeDefinition('workflow.twig_extension'); } else { diff --git a/DependencyInjection/Compiler/RuntimeLoaderPass.php b/DependencyInjection/Compiler/RuntimeLoaderPass.php index ecb99ce2..275f5c9c 100644 --- a/DependencyInjection/Compiler/RuntimeLoaderPass.php +++ b/DependencyInjection/Compiler/RuntimeLoaderPass.php @@ -21,10 +21,7 @@ */ class RuntimeLoaderPass implements CompilerPassInterface { - /** - * @return void - */ - public function process(ContainerBuilder $container) + public function process(ContainerBuilder $container): void { if (!$container->hasDefinition('twig.runtime_loader')) { return; diff --git a/DependencyInjection/Compiler/TwigEnvironmentPass.php b/DependencyInjection/Compiler/TwigEnvironmentPass.php index 99b975ed..104464b0 100644 --- a/DependencyInjection/Compiler/TwigEnvironmentPass.php +++ b/DependencyInjection/Compiler/TwigEnvironmentPass.php @@ -24,10 +24,7 @@ class TwigEnvironmentPass implements CompilerPassInterface { use PriorityTaggedServiceTrait; - /** - * @return void - */ - public function process(ContainerBuilder $container) + public function process(ContainerBuilder $container): void { if (false === $container->hasDefinition('twig')) { return; diff --git a/DependencyInjection/Compiler/TwigLoaderPass.php b/DependencyInjection/Compiler/TwigLoaderPass.php index 1da7e867..b4d359e1 100644 --- a/DependencyInjection/Compiler/TwigLoaderPass.php +++ b/DependencyInjection/Compiler/TwigLoaderPass.php @@ -23,10 +23,7 @@ */ class TwigLoaderPass implements CompilerPassInterface { - /** - * @return void - */ - public function process(ContainerBuilder $container) + public function process(ContainerBuilder $container): void { if (false === $container->hasDefinition('twig')) { return; diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index 114e693b..354e1a4e 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -32,7 +32,9 @@ public function getConfigTreeBuilder(): TreeBuilder $treeBuilder = new TreeBuilder('twig'); $rootNode = $treeBuilder->getRootNode(); - $rootNode->beforeNormalization() + $rootNode + ->docUrl('https://symfony.com/doc/{version:major}.{version:minor}/reference/configuration/twig.html', 'symfony/twig-bundle') + ->beforeNormalization() ->ifTrue(fn ($v) => \is_array($v) && \array_key_exists('exception_controller', $v)) ->then(function ($v) { if (isset($v['exception_controller'])) { @@ -64,7 +66,7 @@ private function addFormThemesSection(ArrayNodeDefinition $rootNode): void ->prototype('scalar')->defaultValue('form_div_layout.html.twig')->end() ->example(['@My/form.html.twig']) ->validate() - ->ifTrue(fn ($v) => !\in_array('form_div_layout.html.twig', $v)) + ->ifTrue(fn ($v) => !\in_array('form_div_layout.html.twig', $v, true)) ->then(fn ($v) => array_merge(['form_div_layout.html.twig'], $v)) ->end() ->end() @@ -127,26 +129,26 @@ private function addTwigOptions(ArrayNodeDefinition $rootNode): void $rootNode ->fixXmlConfig('path') ->children() - ->variableNode('autoescape') - ->defaultValue('name') - ->setDeprecated('symfony/twig-bundle', '6.1', 'Option "%node%" at "%path%" is deprecated, use autoescape_service[_method] instead.') - ->end() ->scalarNode('autoescape_service')->defaultNull()->end() ->scalarNode('autoescape_service_method')->defaultNull()->end() - ->scalarNode('base_template_class')->example('Twig\Template')->cannotBeEmpty()->end() - ->scalarNode('cache')->defaultValue('%kernel.cache_dir%/twig')->end() + ->scalarNode('base_template_class') + ->setDeprecated('symfony/twig-bundle', '7.1') + ->example('Twig\Template') + ->cannotBeEmpty() + ->end() + ->scalarNode('cache')->defaultTrue()->end() ->scalarNode('charset')->defaultValue('%kernel.charset%')->end() ->booleanNode('debug')->defaultValue('%kernel.debug%')->end() ->booleanNode('strict_variables')->defaultValue('%kernel.debug%')->end() ->scalarNode('auto_reload')->end() ->integerNode('optimizations')->min(-1)->end() ->scalarNode('default_path') - ->info('The default path used to load templates') + ->info('The default path used to load templates.') ->defaultValue('%kernel.project_dir%/templates') ->end() ->arrayNode('file_name_pattern') ->example('*.twig') - ->info('Pattern of file name used for cache warmer and linter') + ->info('Pattern of file name used for cache warmer and linter.') ->beforeNormalization() ->ifString() ->then(fn ($value) => [$value]) @@ -190,19 +192,19 @@ private function addTwigFormatOptions(ArrayNodeDefinition $rootNode): void $rootNode ->children() ->arrayNode('date') - ->info('The default format options used by the date filter') + ->info('The default format options used by the date filter.') ->addDefaultsIfNotSet() ->children() ->scalarNode('format')->defaultValue('F j, Y H:i')->end() ->scalarNode('interval_format')->defaultValue('%d days')->end() ->scalarNode('timezone') - ->info('The timezone used when formatting dates, when set to null, the timezone returned by date_default_timezone_get() is used') + ->info('The timezone used when formatting dates, when set to null, the timezone returned by date_default_timezone_get() is used.') ->defaultNull() ->end() ->end() ->end() ->arrayNode('number_format') - ->info('The default format options for the number_format filter') + ->info('The default format options for the number_format filter.') ->addDefaultsIfNotSet() ->children() ->integerNode('decimals')->defaultValue(0)->end() @@ -221,7 +223,7 @@ private function addMailerSection(ArrayNodeDefinition $rootNode): void ->arrayNode('mailer') ->children() ->scalarNode('html_to_text_converter') - ->info(sprintf('A service implementing the "%s"', HtmlToTextConverterInterface::class)) + ->info(\sprintf('A service implementing the "%s".', HtmlToTextConverterInterface::class)) ->defaultNull() ->end() ->end() diff --git a/DependencyInjection/Configurator/EnvironmentConfigurator.php b/DependencyInjection/Configurator/EnvironmentConfigurator.php index b3eec9ff..35f7b890 100644 --- a/DependencyInjection/Configurator/EnvironmentConfigurator.php +++ b/DependencyInjection/Configurator/EnvironmentConfigurator.php @@ -15,9 +15,6 @@ use Twig\Environment; use Twig\Extension\CoreExtension; -// BC/FC with namespaced Twig -class_exists(Environment::class); - /** * Twig environment configurator. * @@ -25,27 +22,17 @@ class_exists(Environment::class); */ class EnvironmentConfigurator { - private string $dateFormat; - private string $intervalFormat; - private ?string $timezone; - private int $decimals; - private string $decimalPoint; - private string $thousandsSeparator; - - public function __construct(string $dateFormat, string $intervalFormat, ?string $timezone, int $decimals, string $decimalPoint, string $thousandsSeparator) - { - $this->dateFormat = $dateFormat; - $this->intervalFormat = $intervalFormat; - $this->timezone = $timezone; - $this->decimals = $decimals; - $this->decimalPoint = $decimalPoint; - $this->thousandsSeparator = $thousandsSeparator; + public function __construct( + private string $dateFormat, + private string $intervalFormat, + private ?string $timezone, + private int $decimals, + private string $decimalPoint, + private string $thousandsSeparator, + ) { } - /** - * @return void - */ - public function configure(Environment $environment) + public function configure(Environment $environment): void { $environment->getExtension(CoreExtension::class)->setDateFormat($this->dateFormat, $this->intervalFormat); diff --git a/DependencyInjection/TwigExtension.php b/DependencyInjection/TwigExtension.php index c27daf61..41817295 100644 --- a/DependencyInjection/TwigExtension.php +++ b/DependencyInjection/TwigExtension.php @@ -11,6 +11,7 @@ namespace Symfony\Bundle\TwigBundle\DependencyInjection; +use Symfony\Bundle\TwigBundle\DependencyInjection\Compiler\AttributeExtensionPass; use Symfony\Component\AssetMapper\AssetMapper; use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\Resource\FileExistenceResource; @@ -24,7 +25,12 @@ use Symfony\Component\Mailer\Mailer; use Symfony\Component\Translation\LocaleSwitcher; use Symfony\Component\Translation\Translator; +use Symfony\Component\Validator\Constraint; use Symfony\Contracts\Service\ResetInterface; +use Twig\Attribute\AsTwigFilter; +use Twig\Attribute\AsTwigFunction; +use Twig\Attribute\AsTwigTest; +use Twig\Cache\FilesystemCache; use Twig\Environment; use Twig\Extension\ExtensionInterface; use Twig\Extension\RuntimeExtensionInterface; @@ -38,10 +44,7 @@ */ class TwigExtension extends Extension { - /** - * @return void - */ - public function load(array $configs, ContainerBuilder $container) + public function load(array $configs, ContainerBuilder $container): void { $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); $loader->load('twig.php'); @@ -68,6 +71,10 @@ public function load(array $configs, ContainerBuilder $container) $container->removeDefinition('twig.translation.extractor'); } + if ($container::willBeAvailable('symfony/validator', Constraint::class, ['symfony/twig-bundle'])) { + $loader->load('validator.php'); + } + foreach ($configs as $key => $config) { if (isset($config['globals'])) { foreach ($config['globals'] as $name => $value) { @@ -161,8 +168,35 @@ public function load(array $configs, ContainerBuilder $container) } } + if (true === $config['cache']) { + $autoReloadOrDefault = $container->getParameterBag()->resolveValue($config['auto_reload'] ?? $config['debug']); + $buildDir = $container->getParameter('kernel.build_dir'); + $cacheDir = $container->getParameter('kernel.cache_dir'); + + if ($autoReloadOrDefault || $cacheDir === $buildDir) { + $config['cache'] = '%kernel.cache_dir%/twig'; + } + } + + if (true === $config['cache']) { + $config['cache'] = new Reference('twig.template_cache.chain'); + } else { + $container->removeDefinition('twig.template_cache.chain'); + $container->removeDefinition('twig.template_cache.runtime_cache'); + $container->removeDefinition('twig.template_cache.readonly_cache'); + $container->removeDefinition('twig.template_cache.warmup_cache'); + + if (false === $config['cache']) { + $container->removeDefinition('twig.template_cache_warmer'); + } else { + $container->getDefinition('twig.template_cache_warmer')->replaceArgument(2, null); + } + } + if (isset($config['autoescape_service'])) { $config['autoescape'] = [new Reference($config['autoescape_service']), $config['autoescape_service_method'] ?? '__invoke']; + } else { + $config['autoescape'] = 'name'; } $container->getDefinition('twig')->replaceArgument(1, array_intersect_key($config, [ @@ -176,15 +210,13 @@ public function load(array $configs, ContainerBuilder $container) 'optimizations' => true, ])); - $container->registerForAutoconfiguration(\Twig_ExtensionInterface::class)->addTag('twig.extension'); - $container->registerForAutoconfiguration(\Twig_LoaderInterface::class)->addTag('twig.loader'); $container->registerForAutoconfiguration(ExtensionInterface::class)->addTag('twig.extension'); $container->registerForAutoconfiguration(LoaderInterface::class)->addTag('twig.loader'); $container->registerForAutoconfiguration(RuntimeExtensionInterface::class)->addTag('twig.runtime'); - if (false === $config['cache']) { - $container->removeDefinition('twig.template_cache_warmer'); - } + $container->registerAttributeForAutoconfiguration(AsTwigFilter::class, AttributeExtensionPass::autoconfigureFromAttribute(...)); + $container->registerAttributeForAutoconfiguration(AsTwigFunction::class, AttributeExtensionPass::autoconfigureFromAttribute(...)); + $container->registerAttributeForAutoconfiguration(AsTwigTest::class, AttributeExtensionPass::autoconfigureFromAttribute(...)); } private function getBundleTemplatePaths(ContainerBuilder $container, array $config): array diff --git a/Resources/config/schema/twig-1.0.xsd b/Resources/config/schema/twig-1.0.xsd index 50eff2bc..05f949e9 100644 --- a/Resources/config/schema/twig-1.0.xsd +++ b/Resources/config/schema/twig-1.0.xsd @@ -18,7 +18,6 @@ - diff --git a/Resources/config/twig.php b/Resources/config/twig.php index 69d0aa2f..812ac1f6 100644 --- a/Resources/config/twig.php +++ b/Resources/config/twig.php @@ -17,7 +17,7 @@ use Symfony\Bridge\Twig\ErrorRenderer\TwigErrorRenderer; use Symfony\Bridge\Twig\EventListener\TemplateAttributeListener; use Symfony\Bridge\Twig\Extension\AssetExtension; -use Symfony\Bridge\Twig\Extension\CodeExtension; +use Symfony\Bridge\Twig\Extension\EmojiExtension; use Symfony\Bridge\Twig\Extension\ExpressionExtension; use Symfony\Bridge\Twig\Extension\HtmlSanitizerExtension; use Symfony\Bridge\Twig\Extension\HttpFoundationExtension; @@ -36,7 +36,9 @@ use Symfony\Bundle\TwigBundle\CacheWarmer\TemplateCacheWarmer; use Symfony\Bundle\TwigBundle\DependencyInjection\Configurator\EnvironmentConfigurator; use Symfony\Bundle\TwigBundle\TemplateIterator; +use Twig\Cache\ChainCache; use Twig\Cache\FilesystemCache; +use Twig\Cache\ReadOnlyFilesystemCache; use Twig\Environment; use Twig\Extension\CoreExtension; use Twig\Extension\DebugExtension; @@ -66,9 +68,6 @@ ->tag('container.preload', ['class' => ExtensionSet::class]) ->tag('container.preload', ['class' => Template::class]) ->tag('container.preload', ['class' => TemplateWrapper::class]) - - ->alias('Twig_Environment', 'twig') - ->deprecate('symfony/twig-bundle', '6.3', 'The "%alias_id%" service alias is deprecated, use "'.Environment::class.'" or "twig" instead.') ->alias(Environment::class, 'twig') ->set('twig.app_variable', AppVariable::class) @@ -82,8 +81,24 @@ ->set('twig.template_iterator', TemplateIterator::class) ->args([service('kernel'), abstract_arg('Twig paths'), param('twig.default_path'), abstract_arg('File name pattern')]) + ->set('twig.template_cache.runtime_cache', FilesystemCache::class) + ->args([param('kernel.cache_dir').'/twig']) + + ->set('twig.template_cache.readonly_cache', ReadOnlyFilesystemCache::class) + ->args([param('kernel.build_dir').'/twig']) + + ->set('twig.template_cache.warmup_cache', FilesystemCache::class) + ->args([param('kernel.build_dir').'/twig']) + + ->set('twig.template_cache.chain', ChainCache::class) + ->args([[service('twig.template_cache.readonly_cache'), service('twig.template_cache.runtime_cache')]]) + ->set('twig.template_cache_warmer', TemplateCacheWarmer::class) - ->args([service(ContainerInterface::class), service('twig.template_iterator')]) + ->args([ + service(ContainerInterface::class), + service('twig.template_iterator'), + service('twig.template_cache.warmup_cache'), + ]) ->tag('kernel.cache_warmer') ->tag('container.service_subscriber', ['id' => 'twig']) @@ -109,10 +124,6 @@ ->set('twig.extension.assets', AssetExtension::class) ->args([service('assets.packages')]) - ->set('twig.extension.code', CodeExtension::class) - ->args([service('debug.file_link_formatter')->ignoreOnInvalid(), param('kernel.project_dir'), param('kernel.charset')]) - ->tag('twig.extension') - ->set('twig.extension.routing', RoutingExtension::class) ->args([service('router')]) @@ -123,6 +134,8 @@ ->set('twig.extension.expression', ExpressionExtension::class) + ->set('twig.extension.emoji', EmojiExtension::class) + ->set('twig.extension.htmlsanitizer', HtmlSanitizerExtension::class) ->args([tagged_locator('html_sanitizer', 'sanitizer')]) diff --git a/Resources/config/validator.php b/Resources/config/validator.php new file mode 100644 index 00000000..1c0e8dd4 --- /dev/null +++ b/Resources/config/validator.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use Symfony\Bridge\Twig\Validator\Constraints\TwigValidator; + +return static function (ContainerConfigurator $container) { + $container->services() + ->set('twig.validator', TwigValidator::class) + ->args([service('twig')]) + ->tag('validator.constraint_validator') + ; +}; diff --git a/TemplateIterator.php b/TemplateIterator.php index bd42f1ac..04cb2a5a 100644 --- a/TemplateIterator.php +++ b/TemplateIterator.php @@ -25,23 +25,19 @@ */ class TemplateIterator implements \IteratorAggregate { - private KernelInterface $kernel; private \Traversable $templates; - private array $paths; - private ?string $defaultPath; - private array $namePatterns; /** * @param array $paths Additional Twig paths to warm * @param string|null $defaultPath The directory where global templates can be stored * @param string[] $namePatterns Pattern of file names */ - public function __construct(KernelInterface $kernel, array $paths = [], ?string $defaultPath = null, array $namePatterns = []) - { - $this->kernel = $kernel; - $this->paths = $paths; - $this->defaultPath = $defaultPath; - $this->namePatterns = $namePatterns; + public function __construct( + private KernelInterface $kernel, + private array $paths = [], + private ?string $defaultPath = null, + private array $namePatterns = [], + ) { } public function getIterator(): \Traversable diff --git a/Tests/DependencyInjection/Fixtures/php/full.php b/Tests/DependencyInjection/Fixtures/php/full.php index 9e7b5007..68c7f5a3 100644 --- a/Tests/DependencyInjection/Fixtures/php/full.php +++ b/Tests/DependencyInjection/Fixtures/php/full.php @@ -10,9 +10,7 @@ 'pi' => 3.14, 'bad' => ['key' => 'foo'], ], - 'auto_reload' => true, - 'base_template_class' => 'stdClass', - 'cache' => '/tmp', + 'auto_reload' => false, 'charset' => 'ISO-8859-1', 'debug' => true, 'strict_variables' => true, diff --git a/Tests/DependencyInjection/Fixtures/php/no-cache.php b/Tests/DependencyInjection/Fixtures/php/no-cache.php new file mode 100644 index 00000000..df1ae5c6 --- /dev/null +++ b/Tests/DependencyInjection/Fixtures/php/no-cache.php @@ -0,0 +1,5 @@ +loadFromExtension('twig', [ + 'cache' => false, +]); diff --git a/Tests/DependencyInjection/Fixtures/php/path-cache.php b/Tests/DependencyInjection/Fixtures/php/path-cache.php new file mode 100644 index 00000000..f0701a57 --- /dev/null +++ b/Tests/DependencyInjection/Fixtures/php/path-cache.php @@ -0,0 +1,5 @@ +loadFromExtension('twig', [ + 'cache' => 'random-path', +]); diff --git a/Tests/DependencyInjection/Fixtures/php/prod-cache.php b/Tests/DependencyInjection/Fixtures/php/prod-cache.php new file mode 100644 index 00000000..62885460 --- /dev/null +++ b/Tests/DependencyInjection/Fixtures/php/prod-cache.php @@ -0,0 +1,6 @@ +loadFromExtension('twig', [ + 'cache' => true, + 'auto_reload' => false, +]); diff --git a/Tests/DependencyInjection/Fixtures/php/templateClass.php b/Tests/DependencyInjection/Fixtures/php/templateClass.php new file mode 100644 index 00000000..bf995046 --- /dev/null +++ b/Tests/DependencyInjection/Fixtures/php/templateClass.php @@ -0,0 +1,5 @@ +loadFromExtension('twig', [ + 'base_template_class' => 'stdClass', +]); diff --git a/Tests/DependencyInjection/Fixtures/xml/extra.xml b/Tests/DependencyInjection/Fixtures/xml/extra.xml index 92767e41..df02c9dc 100644 --- a/Tests/DependencyInjection/Fixtures/xml/extra.xml +++ b/Tests/DependencyInjection/Fixtures/xml/extra.xml @@ -6,7 +6,7 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd http://symfony.com/schema/dic/twig https://symfony.com/schema/dic/twig/twig-1.0.xsd"> - + namespaced_path3 diff --git a/Tests/DependencyInjection/Fixtures/xml/full.xml b/Tests/DependencyInjection/Fixtures/xml/full.xml index 3f7d1de2..3349e0d5 100644 --- a/Tests/DependencyInjection/Fixtures/xml/full.xml +++ b/Tests/DependencyInjection/Fixtures/xml/full.xml @@ -6,7 +6,7 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd http://symfony.com/schema/dic/twig https://symfony.com/schema/dic/twig/twig-1.0.xsd"> - + MyBundle::form.html.twig @@qux diff --git a/Tests/DependencyInjection/Fixtures/xml/no-cache.xml b/Tests/DependencyInjection/Fixtures/xml/no-cache.xml new file mode 100644 index 00000000..f6fa72c7 --- /dev/null +++ b/Tests/DependencyInjection/Fixtures/xml/no-cache.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/Tests/DependencyInjection/Fixtures/xml/path-cache.xml b/Tests/DependencyInjection/Fixtures/xml/path-cache.xml new file mode 100644 index 00000000..9caf2fc0 --- /dev/null +++ b/Tests/DependencyInjection/Fixtures/xml/path-cache.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/Tests/DependencyInjection/Fixtures/xml/prod-cache.xml b/Tests/DependencyInjection/Fixtures/xml/prod-cache.xml new file mode 100644 index 00000000..6ee9f385 --- /dev/null +++ b/Tests/DependencyInjection/Fixtures/xml/prod-cache.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/Tests/DependencyInjection/Fixtures/xml/templateClass.xml b/Tests/DependencyInjection/Fixtures/xml/templateClass.xml new file mode 100644 index 00000000..a735ed8d --- /dev/null +++ b/Tests/DependencyInjection/Fixtures/xml/templateClass.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/Tests/DependencyInjection/Fixtures/yml/full.yml b/Tests/DependencyInjection/Fixtures/yml/full.yml index d1867245..ab19cbf0 100644 --- a/Tests/DependencyInjection/Fixtures/yml/full.yml +++ b/Tests/DependencyInjection/Fixtures/yml/full.yml @@ -6,9 +6,7 @@ twig: baz: "@@qux" pi: 3.14 bad: {key: foo} - auto_reload: true - base_template_class: stdClass - cache: /tmp + auto_reload: false charset: ISO-8859-1 debug: true strict_variables: true diff --git a/Tests/DependencyInjection/Fixtures/yml/no-cache.yml b/Tests/DependencyInjection/Fixtures/yml/no-cache.yml new file mode 100644 index 00000000..c1e9f184 --- /dev/null +++ b/Tests/DependencyInjection/Fixtures/yml/no-cache.yml @@ -0,0 +1,2 @@ +twig: + cache: false diff --git a/Tests/DependencyInjection/Fixtures/yml/path-cache.yml b/Tests/DependencyInjection/Fixtures/yml/path-cache.yml new file mode 100644 index 00000000..04e9d1dc --- /dev/null +++ b/Tests/DependencyInjection/Fixtures/yml/path-cache.yml @@ -0,0 +1,2 @@ +twig: + cache: random-path diff --git a/Tests/DependencyInjection/Fixtures/yml/prod-cache.yml b/Tests/DependencyInjection/Fixtures/yml/prod-cache.yml new file mode 100644 index 00000000..82a1dd9e --- /dev/null +++ b/Tests/DependencyInjection/Fixtures/yml/prod-cache.yml @@ -0,0 +1,3 @@ +twig: + cache: true + auto_reload: false diff --git a/Tests/DependencyInjection/Fixtures/yml/templateClass.yml b/Tests/DependencyInjection/Fixtures/yml/templateClass.yml new file mode 100644 index 00000000..886a5ee6 --- /dev/null +++ b/Tests/DependencyInjection/Fixtures/yml/templateClass.yml @@ -0,0 +1,2 @@ +twig: + base_template_class: stdClass diff --git a/Tests/DependencyInjection/TwigExtensionTest.php b/Tests/DependencyInjection/TwigExtensionTest.php index 7a874e7b..086a4cdd 100644 --- a/Tests/DependencyInjection/TwigExtensionTest.php +++ b/Tests/DependencyInjection/TwigExtensionTest.php @@ -11,6 +11,7 @@ namespace Symfony\Bundle\TwigBundle\Tests\DependencyInjection; +use Symfony\Bridge\PhpUnit\ExpectUserDeprecationMessageTrait; use Symfony\Bundle\TwigBundle\DependencyInjection\Compiler\RuntimeLoaderPass; use Symfony\Bundle\TwigBundle\DependencyInjection\TwigExtension; use Symfony\Bundle\TwigBundle\Tests\DependencyInjection\AcmeBundle\AcmeBundle; @@ -27,10 +28,13 @@ use Symfony\Component\Form\FormRenderer; use Symfony\Component\Mailer\Mailer; use Symfony\Component\Stopwatch\Stopwatch; +use Symfony\Component\Validator\Validator\ValidatorInterface; use Twig\Environment; class TwigExtensionTest extends TestCase { + use ExpectUserDeprecationMessageTrait; + public function testLoadEmptyConfiguration() { $container = $this->createContainer(); @@ -51,14 +55,20 @@ public function testLoadEmptyConfiguration() if (class_exists(Mailer::class)) { $this->assertCount(2, $container->getDefinition('twig.mime_body_renderer')->getArguments()); } + + if (interface_exists(ValidatorInterface::class)) { + $this->assertTrue($container->hasDefinition('twig.validator')); + } else { + $this->assertFalse($container->hasDefinition('twig.validator')); + } } /** - * @dataProvider getFormats + * @dataProvider getFormatsAndBuildDir */ - public function testLoadFullConfiguration($format) + public function testLoadFullConfiguration(string $format, ?string $buildDir) { - $container = $this->createContainer(); + $container = $this->createContainer($buildDir); $container->registerExtension(new TwigExtension()); $this->loadFromFile($container, 'full', $format); $this->compileContainer($container); @@ -89,19 +99,89 @@ public function testLoadFullConfiguration($format) // Twig options $options = $container->getDefinition('twig')->getArgument(1); - $this->assertTrue($options['auto_reload'], '->load() sets the auto_reload option'); + $this->assertFalse($options['auto_reload'], '->load() sets the auto_reload option'); $this->assertSame('name', $options['autoescape'], '->load() sets the autoescape option'); - $this->assertEquals('stdClass', $options['base_template_class'], '->load() sets the base_template_class option'); - $this->assertEquals('/tmp', $options['cache'], '->load() sets the cache option'); + $this->assertArrayNotHasKey('base_template_class', $options, '->load() does not set the base_template_class if none is provided'); $this->assertEquals('ISO-8859-1', $options['charset'], '->load() sets the charset option'); $this->assertTrue($options['debug'], '->load() sets the debug option'); $this->assertTrue($options['strict_variables'], '->load() sets the strict_variables option'); + $this->assertEquals($buildDir !== null ? new Reference('twig.template_cache.chain') : '%kernel.cache_dir%/twig', $options['cache'], '->load() sets the cache option'); + } + + /** + * @dataProvider getFormatsAndBuildDir + */ + public function testLoadNoCacheConfiguration(string $format, ?string $buildDir) + { + $container = $this->createContainer($buildDir); + $container->registerExtension(new TwigExtension()); + $this->loadFromFile($container, 'no-cache', $format); + $this->compileContainer($container); + + $this->assertEquals(Environment::class, $container->getDefinition('twig')->getClass(), '->load() loads the twig.xml file'); + + // Twig options + $options = $container->getDefinition('twig')->getArgument(1); + $this->assertFalse($options['cache'], '->load() sets cache option to false'); + } + + /** + * @dataProvider getFormatsAndBuildDir + */ + public function testLoadPathCacheConfiguration(string $format, ?string $buildDir) + { + $container = $this->createContainer($buildDir); + $container->registerExtension(new TwigExtension()); + $this->loadFromFile($container, 'path-cache', $format); + $this->compileContainer($container); + + $this->assertEquals(Environment::class, $container->getDefinition('twig')->getClass(), '->load() loads the twig.xml file'); + + // Twig options + $options = $container->getDefinition('twig')->getArgument(1); + $this->assertSame('random-path', $options['cache'], '->load() sets cache option to string path'); + } + + /** + * @dataProvider getFormatsAndBuildDir + */ + public function testLoadProdCacheConfiguration(string $format, ?string $buildDir) + { + $container = $this->createContainer($buildDir); + $container->registerExtension(new TwigExtension()); + $this->loadFromFile($container, 'prod-cache', $format); + $this->compileContainer($container); + + $this->assertEquals(Environment::class, $container->getDefinition('twig')->getClass(), '->load() loads the twig.xml file'); + + // Twig options + $options = $container->getDefinition('twig')->getArgument(1); + $this->assertEquals($buildDir !== null ? new Reference('twig.template_cache.chain') : '%kernel.cache_dir%/twig', $options['cache'], '->load() sets cache option to CacheChain reference'); } /** + * @group legacy + * * @dataProvider getFormats */ - public function testLoadCustomTemplateEscapingGuesserConfiguration($format) + public function testLoadCustomBaseTemplateClassConfiguration(string $format) + { + $container = $this->createContainer(); + $container->registerExtension(new TwigExtension()); + + $this->expectUserDeprecationMessage('Since symfony/twig-bundle 7.1: The child node "base_template_class" at path "twig" is deprecated.'); + + $this->loadFromFile($container, 'templateClass', $format); + $this->compileContainer($container); + + $options = $container->getDefinition('twig')->getArgument(1); + $this->assertEquals('stdClass', $options['base_template_class'], '->load() sets the base_template_class option'); + } + + /** + * @dataProvider getFormats + */ + public function testLoadCustomTemplateEscapingGuesserConfiguration(string $format) { $container = $this->createContainer(); $container->registerExtension(new TwigExtension()); @@ -115,7 +195,7 @@ public function testLoadCustomTemplateEscapingGuesserConfiguration($format) /** * @dataProvider getFormats */ - public function testLoadDefaultTemplateEscapingGuesserConfiguration($format) + public function testLoadDefaultTemplateEscapingGuesserConfiguration(string $format) { $container = $this->createContainer(); $container->registerExtension(new TwigExtension()); @@ -129,7 +209,7 @@ public function testLoadDefaultTemplateEscapingGuesserConfiguration($format) /** * @dataProvider getFormats */ - public function testLoadCustomDateFormats($fileFormat) + public function testLoadCustomDateFormats(string $fileFormat) { $container = $this->createContainer(); $container->registerExtension(new TwigExtension()); @@ -178,7 +258,7 @@ public function testGlobalsWithDifferentTypesAndValues() /** * @dataProvider getFormats */ - public function testTwigLoaderPaths($format) + public function testTwigLoaderPaths(string $format) { $container = $this->createContainer(); $container->registerExtension(new TwigExtension()); @@ -207,7 +287,7 @@ public function testTwigLoaderPaths($format) ], $paths); } - public static function getFormats() + public static function getFormats(): array { return [ ['php'], @@ -216,10 +296,23 @@ public static function getFormats() ]; } + public static function getFormatsAndBuildDir(): array + { + return [ + ['php', null], + ['php', __DIR__.'/build'], + ['yml', null], + ['yml', __DIR__.'/build'], + ['xml', null], + ['xml', __DIR__.'/build'], + ]; + } + + /** * @dataProvider stopwatchExtensionAvailabilityProvider */ - public function testStopwatchExtensionAvailability($debug, $stopwatchEnabled, $expected) + public function testStopwatchExtensionAvailability(bool $debug, bool $stopwatchEnabled, bool $expected) { $container = $this->createContainer(); $container->setParameter('kernel.debug', $debug); @@ -290,10 +383,11 @@ public function testCustomHtmlToTextConverterService(string $format) $this->assertEquals(new Reference('my_converter'), $bodyRenderer->getArgument('$converter')); } - private function createContainer() + private function createContainer(?string $buildDir = null): ContainerBuilder { $container = new ContainerBuilder(new ParameterBag([ 'kernel.cache_dir' => __DIR__, + 'kernel.build_dir' => $buildDir ?? __DIR__, 'kernel.project_dir' => __DIR__, 'kernel.charset' => 'UTF-8', 'kernel.debug' => false, @@ -311,7 +405,7 @@ private function createContainer() return $container; } - private function compileContainer(ContainerBuilder $container) + private function compileContainer(ContainerBuilder $container): void { $container->getCompilerPassConfig()->setOptimizationPasses([]); $container->getCompilerPassConfig()->setRemovingPasses([]); @@ -319,7 +413,7 @@ private function compileContainer(ContainerBuilder $container) $container->compile(); } - private function loadFromFile(ContainerBuilder $container, $file, $format) + private function loadFromFile(ContainerBuilder $container, string $file, string $format): void { $locator = new FileLocator(__DIR__.'/Fixtures/'.$format); diff --git a/Tests/Functional/AttributeExtensionTest.php b/Tests/Functional/AttributeExtensionTest.php new file mode 100644 index 00000000..8b4e4555 --- /dev/null +++ b/Tests/Functional/AttributeExtensionTest.php @@ -0,0 +1,167 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\TwigBundle\Tests\Functional; + +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\Before; +use PHPUnit\Framework\Attributes\BeforeClass; +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\TwigBundle\Tests\TestCase; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\LogicException; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\HttpKernel\Kernel; +use Twig\Attribute\AsTwigFilter; +use Twig\Attribute\AsTwigFunction; +use Twig\Attribute\AsTwigTest; +use Twig\Environment; +use Twig\Error\RuntimeError; +use Twig\Extension\AbstractExtension; +use Twig\Extension\AttributeExtension; + +class AttributeExtensionTest extends TestCase +{ + /** @beforeClass */ + #[BeforeClass] + public static function assertTwigVersion(): void + { + if (!class_exists(AttributeExtension::class)) { + self::markTestSkipped('Twig 3.21 is required.'); + } + } + + public function testExtensionWithAttributes() + { + $kernel = new class extends AttributeExtensionKernel { + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $loader->load(static function (ContainerBuilder $container) { + $container->setParameter('kernel.secret', 'secret'); + $container->register(StaticExtensionWithAttributes::class, StaticExtensionWithAttributes::class) + ->setAutoconfigured(true); + $container->register(RuntimeExtensionWithAttributes::class, RuntimeExtensionWithAttributes::class) + ->setArguments(['prefix_']) + ->setAutoconfigured(true); + + $container->setAlias('twig_test', 'twig')->setPublic(true); + }); + } + }; + + $kernel->boot(); + + /** @var Environment $twig */ + $twig = $kernel->getContainer()->get('twig_test'); + + self::assertInstanceOf(AttributeExtension::class, $twig->getExtension(StaticExtensionWithAttributes::class)); + self::assertInstanceOf(AttributeExtension::class, $twig->getExtension(RuntimeExtensionWithAttributes::class)); + self::assertInstanceOf(RuntimeExtensionWithAttributes::class, $twig->getRuntime(RuntimeExtensionWithAttributes::class)); + + self::expectException(RuntimeError::class); + $twig->getRuntime(StaticExtensionWithAttributes::class); + } + + public function testInvalidExtensionClass() + { + $kernel = new class extends AttributeExtensionKernel { + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $loader->load(static function (ContainerBuilder $container) { + $container->register(InvalidExtensionWithAttributes::class, InvalidExtensionWithAttributes::class) + ->setAutoconfigured(true); + }); + } + }; + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The class "Symfony\Bundle\TwigBundle\Tests\Functional\InvalidExtensionWithAttributes" cannot extend "Twig\Extension\AbstractExtension" and use the "#[Twig\Attribute\AsTwigFilter]" attribute on method "funFilter()", choose one or the other.'); + + $kernel->boot(); + } + + + /** + * @before + * @after + */ + #[Before, After] + protected function deleteTempDir() + { + if (file_exists($dir = sys_get_temp_dir().'/'.Kernel::VERSION.'/AttributeExtension')) { + (new Filesystem())->remove($dir); + } + } +} + +abstract class AttributeExtensionKernel extends Kernel +{ + public function __construct() + { + parent::__construct('test', true); + } + + public function registerBundles(): iterable + { + return [new FrameworkBundle(), new TwigBundle()]; + } + + public function getProjectDir(): string + { + return sys_get_temp_dir().'/'.Kernel::VERSION.'/AttributeExtension'; + } +} + +class StaticExtensionWithAttributes +{ + #[AsTwigFilter('foo')] + public static function fooFilter(string $value): string + { + return $value; + } + + #[AsTwigFunction('foo')] + public static function fooFunction(string $value): string + { + return $value; + } + + #[AsTwigTest('foo')] + public static function fooTest(bool $value): bool + { + return $value; + } +} + +class RuntimeExtensionWithAttributes +{ + public function __construct(private bool $prefix) + { + } + + #[AsTwigFilter('prefix_foo')] + #[AsTwigFunction('prefix_foo')] + public function prefix(string $value): string + { + return $this->prefix.$value; + } +} + +class InvalidExtensionWithAttributes extends AbstractExtension +{ + #[AsTwigFilter('fun')] + public function funFilter(): string + { + return 'fun'; + } +} diff --git a/TwigBundle.php b/TwigBundle.php index 802cb536..d9bc6078 100644 --- a/TwigBundle.php +++ b/TwigBundle.php @@ -11,6 +11,7 @@ namespace Symfony\Bundle\TwigBundle; +use Symfony\Bundle\TwigBundle\DependencyInjection\Compiler\AttributeExtensionPass; use Symfony\Bundle\TwigBundle\DependencyInjection\Compiler\ExtensionPass; use Symfony\Bundle\TwigBundle\DependencyInjection\Compiler\RuntimeLoaderPass; use Symfony\Bundle\TwigBundle\DependencyInjection\Compiler\TwigEnvironmentPass; @@ -27,24 +28,19 @@ */ class TwigBundle extends Bundle { - /** - * @return void - */ - public function build(ContainerBuilder $container) + public function build(ContainerBuilder $container): void { parent::build($container); // ExtensionPass must be run before the FragmentRendererPass as it adds tags that are processed later $container->addCompilerPass(new ExtensionPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 10); + $container->addCompilerPass(new AttributeExtensionPass()); $container->addCompilerPass(new TwigEnvironmentPass()); $container->addCompilerPass(new TwigLoaderPass()); $container->addCompilerPass(new RuntimeLoaderPass(), PassConfig::TYPE_BEFORE_REMOVING); } - /** - * @return void - */ - public function registerCommands(Application $application) + public function registerCommands(Application $application): void { // noop } diff --git a/composer.json b/composer.json index 9094536e..221a7f47 100644 --- a/composer.json +++ b/composer.json @@ -16,30 +16,30 @@ } ], "require": { - "php": ">=8.1", + "php": ">=8.2", "composer-runtime-api": ">=2.1", - "symfony/config": "^6.1|^7.0", - "symfony/dependency-injection": "^6.1|^7.0", - "symfony/twig-bridge": "^6.4", - "symfony/http-foundation": "^5.4|^6.0|^7.0", - "symfony/http-kernel": "^6.2", - "twig/twig": "^2.13|^3.0.4" + "symfony/config": "^7.3", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/twig-bridge": "^7.3", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "twig/twig": "^3.12" }, "require-dev": { - "symfony/asset": "^5.4|^6.0|^7.0", - "symfony/stopwatch": "^5.4|^6.0|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", - "symfony/form": "^5.4|^6.0|^7.0", - "symfony/routing": "^5.4|^6.0|^7.0", - "symfony/translation": "^5.4|^6.0|^7.0", - "symfony/yaml": "^5.4|^6.0|^7.0", - "symfony/framework-bundle": "^5.4|^6.0|^7.0", - "symfony/web-link": "^5.4|^6.0|^7.0" + "symfony/asset": "^6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/form": "^6.4|^7.0", + "symfony/routing": "^6.4|^7.0", + "symfony/translation": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0", + "symfony/framework-bundle": "^6.4|^7.0", + "symfony/web-link": "^6.4|^7.0" }, "conflict": { - "symfony/framework-bundle": "<5.4", - "symfony/translation": "<5.4" + "symfony/framework-bundle": "<6.4", + "symfony/translation": "<6.4" }, "autoload": { "psr-4": { "Symfony\\Bundle\\TwigBundle\\": "" },