Symfony recommends creating forms in classes rather than in controllers. That's the right choice for most forms, but applications with many small DTOs end up with lots of form classes whose only content is a field list.
Symfony 8.2 lets you define form types with PHP attributes directly on the class that holds the data. This is mostly useful for uncomplicated forms backed by DTOs. It doesn't deprecate anything and form classes are still the way to go when a form needs more than a list of fields.
To create a form in a DTO class, add the #[AsFormType] attribute to the class
and the #[FormField] attribute to each property that should become a field:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
// src/Dto/UserDto.php
namespace App\Dto;
use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\Form\Attribute\FormField;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
#[AsFormType(options: ['label' => 'User'])]
class UserDto
{
// no type given: it's guessed, as with $builder->add()
#[FormField]
public ?string $name = null;
#[FormField(EmailType::class)]
public ?string $email = null;
#[FormField(TextareaType::class, ['label' => 'Bio'])]
public ?string $bio = null;
// the form field is called "publicName" and its value is mapped
// to this property through the "property_path" option
#[FormField(name: 'publicName')]
public ?string $internalName = null;
}
The class name can now be used wherever a form type name is expected:
1 2
// src/Controller/UserController.php
$form = $this->createForm(UserDto::class, $userDto);
Everything Is Resolved at Compile Time
Symfony reads these attributes when compiling the service container and turns each class into a regular form type service. Any mistake (e.g. a duplicate field name) is detected when compiling the container, not on the first request.
For this to work, the DTO class must be scanned by the container with
autoconfiguration enabled. The default config/services.yaml already does
that for src/, but keep in mind that it excludes src/Entity/.
Option values must be scalars, arrays or enums (closures are rejected, even
though PHP 8.5 accepts them in attributes). To pass a service, use a
Reference object, which the container injects as usual:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
// src/Dto/PostDto.php
namespace App\Dto;
use App\Form\TagChoiceLoader;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\Form\Attribute\FormField;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
#[AsFormType]
class PostDto
{
#[FormField(ChoiceType::class, [
'choice_loader' => new Reference(TagChoiceLoader::class),
])]
public array $tags = [];
}
Literal strings stay literal: a % sign in an option value such as
'label' => '100% free' is not mistaken for a container parameter.
Adding Logic with Type Extensions
Attributes only describe fields. Form events, data transformers, fields computed at runtime and anything else that needs services belong in a form type extension that targets the data class. This works because the derived form type keeps the data class as its identity:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
// src/Form/Extension/UserDtoExtension.php
namespace App\Form\Extension;
use App\Dto\UserDto;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\FormBuilderInterface;
class UserDtoExtension extends AbstractTypeExtension
{
public static function getExtendedTypes(): iterable
{
yield UserDto::class;
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
// add form events, transformers, dynamic fields, etc.
}
}
When a class with #[AsFormType] extends other classes, the closest ancestor
that also has the attribute becomes the parent form type and its fields are
inherited; otherwise the parent is FormType.