Skip to content

Commit 82ed4dc

Browse files
committed
[Form] Add AsFormType attribute to create FormType directly on model classes
1 parent 298e56a commit 82ed4dc

31 files changed

+1074
-6
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,9 @@ private function addFormSection(ArrayNodeDefinition $rootNode, callable $enableI
245245
->info('Form configuration')
246246
->{$enableIfStandalone('symfony/form', Form::class)}()
247247
->children()
248+
->booleanNode('use_attribute')
249+
->defaultFalse()
250+
->end()
248251
->arrayNode('csrf_protection')
249252
->treatFalseLike(['enabled' => false])
250253
->treatTrueLike(['enabled' => true])

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
use Symfony\Component\Filesystem\Filesystem;
8383
use Symfony\Component\Finder\Finder;
8484
use Symfony\Component\Finder\Glob;
85+
use Symfony\Component\Form\Attribute\AsFormType;
8586
use Symfony\Component\Form\Extension\HtmlSanitizer\Type\TextTypeHtmlSanitizerExtension;
8687
use Symfony\Component\Form\Form;
8788
use Symfony\Component\Form\FormTypeExtensionInterface;
@@ -891,6 +892,18 @@ private function registerFormConfiguration(array $config, ContainerBuilder $cont
891892
$container->setParameter('form.type_extension.csrf.enabled', false);
892893
}
893894

895+
if ($config['form']['use_attribute']) {
896+
$loader->load('form_metadata.php');
897+
898+
$container->registerAttributeForAutoconfiguration(AsFormType::class, static function (ChildDefinition $definition, AsFormType $attribute, \ReflectionClass $ref) {
899+
$definition
900+
->addTag('container.excluded.form.metadata.form_type', ['class_name' => $ref->getName()])
901+
->addTag('container.excluded')
902+
->setAbstract(true)
903+
;
904+
});
905+
}
906+
894907
if (!ContainerBuilder::willBeAvailable('symfony/translation', Translator::class, ['symfony/framework-bundle', 'symfony/form'])) {
895908
$container->removeDefinition('form.type_extension.upload.validator');
896909
}

src/Symfony/Bundle/FrameworkBundle/Resources/config/console.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@
335335
[], // All type extensions are stored here by FormPass
336336
[], // All type guessers are stored here by FormPass
337337
service('debug.file_link_formatter')->nullOnInvalid(),
338+
[], // All metadata form types are stored here by FormPass
338339
])
339340
->tag('console.command')
340341

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
13+
14+
use Symfony\Component\Form\Extension\Metadata\MetadataExtension;
15+
use Symfony\Component\Form\Metadata\Loader\AttributeLoader;
16+
17+
return static function (ContainerConfigurator $container) {
18+
$container->services()
19+
->set('form.metadata.attribute_loader', AttributeLoader::class)
20+
21+
->alias('form.metadata.default_loader', 'form.metadata.attribute_loader')
22+
;
23+
};

src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,7 @@ protected static function getBundleDefaultConfig()
751751
'field_attr' => ['data-controller' => 'csrf-protection'],
752752
'token_id' => null,
753753
],
754+
'use_attribute' => false,
754755
],
755756
'esi' => ['enabled' => false],
756757
'ssi' => ['enabled' => false],

src/Symfony/Component/Form/AbstractType.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,9 @@ public function getBlockPrefix()
6363
{
6464
return StringUtil::fqcnToBlockPrefix(static::class) ?: '';
6565
}
66+
67+
final public function getClassName(): string
68+
{
69+
return static::class;
70+
}
6671
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Form\Attribute;
13+
14+
/**
15+
* Register a model class (e.g. DTO, entity, model, etc...) as a FormType.
16+
*
17+
* @author Benjamin Georgeault <git@wedgesama.fr>
18+
*/
19+
#[\Attribute(\Attribute::TARGET_CLASS)]
20+
final readonly class AsFormType
21+
{
22+
/**
23+
* @param array<string, mixed> $options
24+
*/
25+
public function __construct(
26+
private array $options = [],
27+
) {
28+
}
29+
30+
public function getOptions(): array
31+
{
32+
return $this->options;
33+
}
34+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Form\Attribute;
13+
14+
/**
15+
* Add an AsFormType class property as a FormType's field.
16+
*
17+
* @author Benjamin Georgeault <git@wedgesama.fr>
18+
*/
19+
#[\Attribute(\Attribute::TARGET_PROPERTY)]
20+
final readonly class Type
21+
{
22+
/**
23+
* @param class-string|null $type
24+
* @param array<string, mixed> $options
25+
*/
26+
public function __construct(
27+
private ?string $type = null,
28+
private array $options = [],
29+
) {
30+
}
31+
32+
/**
33+
* @return array<string, mixed>
34+
*/
35+
public function getOptions(): array
36+
{
37+
return $this->options;
38+
}
39+
40+
/**
41+
* @return class-string|null
42+
*/
43+
public function getType(): ?string
44+
{
45+
return $this->type;
46+
}
47+
}

src/Symfony/Component/Form/Command/DebugCommand.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public function __construct(
4242
private array $extensions = [],
4343
private array $guessers = [],
4444
private ?FileLinkFormatter $fileLinkFormatter = null,
45+
private array $metadataTypes = [],
4546
) {
4647
parent::__construct();
4748
}
@@ -95,6 +96,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
9596
$object = null;
9697
$options['core_types'] = $this->getCoreTypes();
9798
$options['service_types'] = array_values(array_diff($this->types, $options['core_types']));
99+
$options['metadata_types'] = $this->metadataTypes;
98100
if ($input->getOption('show-deprecated')) {
99101
$options['core_types'] = $this->filterTypesByDeprecated($options['core_types']);
100102
$options['service_types'] = $this->filterTypesByDeprecated($options['service_types']);
@@ -150,7 +152,7 @@ private function getFqcnTypeClass(InputInterface $input, SymfonyStyle $io, strin
150152
if (0 === $count = \count($classes)) {
151153
$message = \sprintf("Could not find type \"%s\" into the following namespaces:\n %s", $shortClassName, implode("\n ", $this->namespaces));
152154

153-
$allTypes = array_merge($this->getCoreTypes(), $this->types);
155+
$allTypes = array_merge($this->getCoreTypes(), $this->types, $this->metadataTypes);
154156
if ($alternatives = $this->findAlternatives($shortClassName, $allTypes)) {
155157
if (1 === \count($alternatives)) {
156158
$message .= "\n\nDid you mean this?\n ";
@@ -238,7 +240,7 @@ private function findAlternatives(string $name, array $collection): array
238240
public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
239241
{
240242
if ($input->mustSuggestArgumentValuesFor('class')) {
241-
$suggestions->suggestValues(array_merge($this->getCoreTypes(), $this->types));
243+
$suggestions->suggestValues(array_merge($this->getCoreTypes(), $this->types, $this->metadataTypes));
242244

243245
return;
244246
}

src/Symfony/Component/Form/Console/Descriptor/JsonDescriptor.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ protected function describeDefaults(array $options): void
2525
{
2626
$data['builtin_form_types'] = $options['core_types'];
2727
$data['service_form_types'] = $options['service_types'];
28+
$data['metadata_form_types'] = $options['metadata_types'];
2829
if (!$options['show_deprecated']) {
2930
$data['type_extensions'] = $options['extensions'];
3031
$data['type_guessers'] = $options['guessers'];

src/Symfony/Component/Form/Console/Descriptor/TextDescriptor.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ protected function describeDefaults(array $options): void
4444
$this->output->listing(array_map($this->formatClassLink(...), $options['service_types']));
4545
}
4646

47+
if ($options['metadata_types']) {
48+
$this->output->section('Metadata form types');
49+
$this->output->listing(array_map($this->formatClassLink(...), $options['metadata_types']));
50+
}
51+
4752
if (!$options['show_deprecated']) {
4853
if ($options['extensions']) {
4954
$this->output->section('Type extensions');

src/Symfony/Component/Form/DependencyInjection/FormPass.php

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@
1717
use Symfony\Component\DependencyInjection\Compiler\PriorityTaggedServiceTrait;
1818
use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass;
1919
use Symfony\Component\DependencyInjection\ContainerBuilder;
20+
use Symfony\Component\DependencyInjection\Definition;
2021
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
2122
use Symfony\Component\DependencyInjection\Reference;
23+
use Symfony\Component\Form\Extension\Metadata\Type\MetadataType;
24+
use Symfony\Component\Form\Metadata\FormMetadataInterface;
2225

2326
/**
2427
* Adds all services with the tags "form.type", "form.type_extension" and
@@ -46,6 +49,7 @@ private function processFormTypes(ContainerBuilder $container): Reference
4649
{
4750
// Get service locator argument
4851
$servicesMap = [];
52+
$metadataTypeMap = [];
4953
$namespaces = ['Symfony\Component\Form\Extension\Core\Type' => true];
5054
$csrfTokenIds = [];
5155

@@ -61,10 +65,33 @@ private function processFormTypes(ContainerBuilder $container): Reference
6165
}
6266
}
6367

68+
foreach ($container->findTaggedResourceIds('container.excluded.form.metadata.form_type') as $excludedServiceId => $tag) {
69+
if (!isset($tag[0]['class_name'])) {
70+
throw new InvalidArgumentException(\sprintf('The excluded service "%s" with tag "container.excluded.form.metadata.form_type" must have the tag\'s attribute "class_name" set.', $excludedServiceId));
71+
}
72+
73+
$className = $tag[0]['class_name'];
74+
$formTypeId = $excludedServiceId.'.form_type';
75+
$metadataId = $excludedServiceId.'.metadata';
76+
77+
$container->setDefinition($metadataId, new Definition(FormMetadataInterface::class))
78+
->setFactory([new Reference('form.metadata.default_loader'), 'load'])
79+
->addArgument($className)
80+
->addTag('form.metadata');
81+
82+
$container->setDefinition($formTypeId, new Definition(MetadataType::class))
83+
->addArgument(new Reference($metadataId))
84+
->addTag('form.metadata_type', ['class_name' => $className]);
85+
86+
$metadataTypeMap[$className] = new Reference($formTypeId);
87+
$namespaces[substr($className, 0, strrpos($className, '\\'))] = true;
88+
}
89+
6490
if ($container->hasDefinition('console.command.form_debug')) {
6591
$commandDefinition = $container->getDefinition('console.command.form_debug');
6692
$commandDefinition->setArgument(1, array_keys($namespaces));
6793
$commandDefinition->setArgument(2, array_keys($servicesMap));
94+
$commandDefinition->setArgument(6, array_keys($metadataTypeMap));
6895
}
6996

7097
if ($csrfTokenIds && $container->hasDefinition('form.type_extension.csrf')) {
@@ -75,7 +102,7 @@ private function processFormTypes(ContainerBuilder $container): Reference
75102
}
76103
}
77104

78-
return ServiceLocatorTagPass::register($container, $servicesMap);
105+
return ServiceLocatorTagPass::register($container, [...$servicesMap, ...$metadataTypeMap]);
79106
}
80107

81108
private function processFormTypeExtensions(ContainerBuilder $container): array
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Form\Exception;
13+
14+
/**
15+
* Thrown when an error occurred during Metadata creation.
16+
*
17+
* @author Benjamin Georgeault <git@wedgesama.fr>
18+
*/
19+
class MetadataException extends LogicException
20+
{
21+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Form\Extension\Metadata;
13+
14+
use Symfony\Component\Form\Exception\InvalidArgumentException;
15+
use Symfony\Component\Form\Exception\MetadataException;
16+
use Symfony\Component\Form\Extension\Metadata\Type\MetadataType;
17+
use Symfony\Component\Form\FormExtensionInterface;
18+
use Symfony\Component\Form\FormTypeGuesserInterface;
19+
use Symfony\Component\Form\FormTypeInterface;
20+
use Symfony\Component\Form\Metadata\Loader\LoaderInterface;
21+
22+
/**
23+
* Responsible for instantiating FormType based on a {@see \Symfony\Component\Form\Metadata\FormMetadataInterface}.
24+
*
25+
* @author Benjamin Georgeault <git@wedgesama.fr>
26+
*/
27+
final class MetadataExtension implements FormExtensionInterface
28+
{
29+
/**
30+
* @var array<class-string, FormTypeInterface>
31+
*/
32+
private array $loadedTypes = [];
33+
34+
public function __construct(
35+
private readonly LoaderInterface $loader,
36+
) {
37+
}
38+
39+
public function getType(string $name): FormTypeInterface
40+
{
41+
if (null !== $type = $this->loadedTypes[$name] ?? null) {
42+
return $type;
43+
}
44+
45+
try {
46+
return $this->loadedTypes[$name] = new MetadataType($this->loader->load($name));
47+
} catch (MetadataException $e) {
48+
throw new InvalidArgumentException(\sprintf('Cannot instantiate a "%s" for the given class "%s".', FormTypeInterface::class, $name), previous: $e);
49+
}
50+
}
51+
52+
public function hasType(string $name): bool
53+
{
54+
return ($this->loadedTypes[$name] ?? false) || $this->loader->support($name);
55+
}
56+
57+
public function getTypeExtensions(string $name): array
58+
{
59+
return [];
60+
}
61+
62+
public function hasTypeExtensions(string $name): bool
63+
{
64+
return false;
65+
}
66+
67+
public function getTypeGuesser(): ?FormTypeGuesserInterface
68+
{
69+
return null;
70+
}
71+
}

0 commit comments

Comments
 (0)