Benjamin Georgeault
Contributed by Benjamin Georgeault in #60563

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.

Published in #Living on the edge