Skip to content

[Form] Add AsFormType attribute to create FormType directly on model classes #60563

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: 7.4
Choose a base branch
from

Conversation

WedgeSama
Copy link
Contributor

@WedgeSama WedgeSama commented May 27, 2025

Q A
Branch? 7.4
Bug fix? no
New feature? yes
Deprecations? no
License MIT

Form Attribute

This PR is about how to use PHP Attribute to build a Symfony Form.

Build your form directly on your data (e.g. on a DTO but not limited to it 😉).

Brainstormed with @Jean-Beru @Neirda24 and @smnandre

Description

What in mind when starting this:

  • less code to build a form (a lot less!)
  • prevent the need to write a dedicated FormType class
  • Make mandatory to use object (DTO?) as data and not array (data_class mandatory but implicit)
  • "Data first, Form second instead of Form first, Data second" quoted from @Neirda24 😄
  • keep all existing features and capabilities of a classic FormType (easy because in the background, it is still a FormType 😉)

Here what it looks like with a basic example:

// UserDTO.php
use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\Form\Attribute\Type;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;

#[AsFormType(options: [
    // Set default option for your root FormType.
    'label' => 'FOO MAIN TITLE',
])]
class UserDTO
{
    #[Type] // Use of FormTypeGuesser to guess the right type.
    public ?string $name = null;

    #[Type(EmailType::class)]
    public ?string $email = null;

    #[Type(RepeatedType::class, [
        'type' => PasswordType::class,
    ])]
    public ?string $password = null;

    #[Type(options: [
        'label' => 'More info',
    ])]
    public ?string $info = null;

    #[Type(TextareaType::class)]
    public ?string $description = null;

    #[Type(AnotherDTO::class)] // Allow the use of others DTO directly.
    public ?AnotherDTO $another;
}
// Anywhere you nee to call your form, service, controller, etc...

$formFactory = /* get here your FromFactory */

$user = new UserDTO();
$form = $formFactory->create(UserDTO::class, $user);
// or get a FormBuilder (or call any other method of the Factory like you'll do with a classic FormType)
$formBuilder = $formFactory->createBuilder(UserDTO::class, $user);

// Then do what you need to do with your brand-new form :wink: 

Basic features (implemented in this PR)

Basic form
| - With Attributes
namespace App\DTO;

use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\Form\Attribute\Type;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;

#[AsFormType(options: [
    'label' => 'FOO MAIN TITLE',
])]
class UserDTO
{
    #[Type]
    public string $name;

    #[Type(EmailType::class)]
    public string $email;

    #[Type(RepeatedType::class, [
        'type' => PasswordType::class,
    ])]
    public string $password;

    #[Type(options: [
        'label' => 'More info',
    ])]
    public string $info;

    #[Type(type: TextareaType::class)]
    public string $description;
}
namespace App\Controller;

use App\DTO\UserDTO;

class HomeController extends AbstractController
{
    #[Route('/', name: 'app_home')]
    public function index(): Response
    {
        // Same `createForm` method for existing form.
        $form = $this->createForm(UserDTO::class);
        // ...
    }
}
| - Classic equivalence (without attribute)
namespace App\DTO;

class UserDTO
{
    public string $name;
    public string $email;
    public string $password;
    public string $info;
    public string $description;
}
namespace App\Controller;

use App\Form\UserDTOType;

class HomeController extends AbstractController
{
    #[Route('/', name: 'app_home')]
    public function index(): Response
    {
        $form = $this->createForm(UserDTOType::class);
        // ...
    }
}
namespace App\Form;

use App\DTO\UserDTO;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class UserDTOType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name')
            ->add('email', EmailType::class)
            ->add('password', RepeatedType::class, [
                'type' => PasswordType::class,
            ])
            ->add('info', null, [
                'label' => 'More info',
            ])
            ->add('description', TextareaType::class)
        ;
    }
    
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => UserDTO::class;
        ]);
    }
}
| - Variant 1, with one attribute per FormType (Not implemented)

Thought of that too, I like the readability but maybe too much work to maintain.

Still need a 'GenericType' to work with type that does not have attribute equivalence.

namespace App\DTO;

use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\Form\Attribute\Type;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;

#[AsFormType]
class UserDTO
{
    #[Type\TextType]
    public string $name;

    #[Type\EmailType]
    public string $email;

    #[Type\RepeatedType(options: [
        'type' => PasswordType::class,
    ])]
    public string $password;

    #[Type\TextType(options: [
        'label' => 'More info',
    ])]
    public string $info;

    // Still got a Generic type
    #[Type\FormType(type: TextareaType::class)]
    public string $description;
}
With validation

Work like a charm, without to do anything more to support it

namespace App\DTO;

use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\Form\Attribute\Type;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Validator\Constraints as Assert;

#[AsFormType]
class UserDTO
{
    #[Type]
    #[Assert\NotBlank]
    public string $name;

    #[Type(EmailType::class)]
    #[Assert\NotBlank]
    #[Assert\Email]
    public ?string $email;

    #[Type(RepeatedType::class, [
        'type' => PasswordType::class,
    ])]
    public string $password;

    #[Type(options: [
        'label' => 'More info',
    ])]
    #[Assert\Length(min: 5, max: 511)]
    public string $info;

    #[Type(type: TextareaType::class)]
    #[Assert\Length(min: 5, max: 511)]
    public string $description;
}
Class inheritance
| - With attribute
// ParentDTO.php

#[AsFormType]
class ParentDTO
{
    #[Type]
    public string $name;
}
// ChildDTO.php

#[AsFormType]
class ChildDTO extends ParentDTO
{
    #[Type]
    public string $anotherProp;
}
| - Classic equivalence (without attribute)
// ParentDTO.php

class ParentDTO
{
    public string $name;
}
// ChildDTO.php

class ChildDTO extends ParentDTO
{
    public string $anotherProp;
}
// ParentType.php

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class ParentType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('name');
    }
    
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => ParentDTO::class;
        ]);
    }
}
// ChildType.php

class ChildType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('anotherProp');
    }
    
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => ChildDTO::class;
        ]);
    }
    public function getParent(): string
    {
        return ParentType::class;
    }
}

Next features (WIP)

Here some DX example of how to implement next features from form component with attributes.

For Form Event and Data Transformer, can be more powerful with PHP 8.5 closures_in_const_expr in the future \o/

Form Event
| - Event on the root FormType
    | - With Attributes
namespace App\DTO;

use Symfony\Component\Form\Attribute\ApplyFormEvent;
use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\Form\Attribute\Type;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;

#[AsFormType]
#[ApplyFormEvent]
class UserDTO
{
    #[Type]
    public string $name;

    public static function onPreSetData(FormEvent $event): void
    {
        // Do stuff
    }

    #[ApplyFormEvent(FormEvents::POST_SET_DATA)]
    public static function anotherMethod(FormEvent $event): void
    {
        // Do stuff
    }
}
    | - Classic equivalence (without attribute)
namespace App\DTO;

class UserDTO
{
    public string $name;
}
namespace App\Form;

use App\DTO\UserDTO;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;

class UserDTOType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('name');
        
        $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
            // Do stuff
        });
        
        $builder->addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event): void {
            // Do stuff
        });
    }
    
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => UserDTO::class;
        ]);
    }
}
| - Event on a FormType's field
    | - With Attributes
namespace App\DTO;

use Symfony\Component\Form\Attribute\ApplyFormEvent;
use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\Form\Attribute\Type;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;

#[AsFormType]
class UserDTO
{
    #[Type]
    #[ApplyFormEvent(FormEvents::PRE_SET_DATA, 'myStaticMethod')]
//    #[ApplyFormEvent(FormEvents::PRE_SET_DATA, [AnotherClass::class, 'myStaticMethod'])] // Maybe callable too?
    public string $name;

    public static function myStaticMethod(FormEvent $event): void
    {
        // Do stuff
    }
}
    | - Classic equivalence (without attribute)
namespace App\DTO;

class UserDTO
{
    public string $name;
}
namespace App\Form;

use App\DTO\UserDTO;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;

class UserDTOType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('name');
        
        $builder->get('add')->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
            // Do stuff
        });
    }
    
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => UserDTO::class;
        ]);
    }
}
Data Transformer
| - DT on the root FormType
    | - With attribute
namespace App\DTO;

use Symfony\Component\Form\Attribute\ApplyDataTransformer;
use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\Form\Attribute\Type;

#[AsFormType]
#[ApplyDataTransformer(new MyTransformer())] // `model` by default?
//#[ApplyDataTransformer(new MyTransformer(), 'view')]
class UserDTO
{
    #[Type]
    public string $name;
}
    | - Classic equivalence (without attribute)
namespace App\DTO;

class UserDTO
{
    public string $name;
}
namespace App\Form;

use App\DTO\UserDTO;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class UserDTOType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->addModelTransformer(new MyTransformer());
//        $builder->addViewTransformer(new MyTransformer());
        $builder->add('name');
    }
    
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => UserDTO::class;
        ]);
    }
}
| - DT on a FormType's field
    | - With attribute
namespace App\DTO;

use Symfony\Component\Form\Attribute\ApplyDataTransformer;
use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\Form\Attribute\Type;

#[AsFormType]
class UserDTO
{
    #[Type]
    #[ApplyDataTransformer(new MyTransformer())] // `model` by default?
//    #[ApplyDataTransformer(new MyTransformer(), 'view')]
    public string $name;
}
    | - Classic equivalence (without attribute)
namespace App\DTO;

class UserDTO
{
    public string $name;
}
namespace App\Form;

use App\DTO\UserDTO;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class UserDTOType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('name');
        $builder->get('name')->addModelTransformer(new MyTransformer())
//        $builder->get('name')->addViewTransformer(new MyTransformer())
        ;
    }
    
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => UserDTO::class;
        ]);
    }
}
| - Variant 1 with DataTransformerInterface

Not a fan but.

namespace App\DTO;

use Symfony\Component\Form\Attribute\ApplyDataTransformer;
use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\Form\Attribute\Type;
use Symfony\Component\Form\DataTransformerInterface;

#[AsFormType]
#[ApplyDataTransformer]
//#[ApplyDataTransformer(type: 'view')]
class UserDTO implements DataTransformerInterface
{
    #[Type]
    public string $name;

    public function transform(mixed $value): mixed {}
    public function reverseTransform(mixed $value): mixed {}
}
| - Variant 2, with 2 attributes

Use 2 distinct attribute, one for model transformer, another for view transformer.

namespace App\DTO;

use Symfony\Component\Form\Attribute\ApplyModelTransformer;
use Symfony\Component\Form\Attribute\ApplyViewTransformer;
use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\Form\Attribute\Type;
use Symfony\Component\Form\DataTransformerInterface;

#[AsFormType]
#[ApplyModelTransformer]
//#[ApplyViewTransformer]
class UserDTO
{
    #[Type]
    public string $name;
}
Form Type Extension
namespace App\Form\Extension;

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
    {
        // do stuff, like add mapped false fields.
    }
}
Auto Form \o/
namespace App\DTO;

use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\Form\Attribute\Exclude;

#[AsFormType(auto: true)]
class UserDTO
{
    public string $name;
    public string $email;
    public string $password;
    public AnotherDTO $another;

    #[Exclude]
    public string $description;
}

Need to be discussed / Possible evolution

Here stuff that I am no satisfy for now and need to be discuss to find a better way to do it.

Autowire
| - In options
namespace App\DTO;

use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\Form\Attribute\Type;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;

#[AsFormType(options: [
    'label' => new Autowire(param: 'the_foo'),
])]
class UserDTO
{
    #[Type]
    public string $name;
    
    #[Type(ChoiceType::class, options: [
        'choice_loader' => new Autowire(service: MyChoiceLoader::class),
        'multiple' => true,
    ])]
    public array $tags = [];

    #[Type(ChoiceType::class, options: [
        'choice_loader' => new Autowire(expression: 'service("App\\Form\\ChoiceLoader\\TypeLoader")'),
//        'choice_label' => new Expression('choice.getIcon()'),
        'choice_label' => [new Autowire(service: MyChoiceLoader::class), 'choiceLabel'],
    ])]
    public string $type;
}
| - Event
namespace App\DTO;

use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Form\Attribute\ApplyFormEvent;
use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\Form\Attribute\Type;
use Symfony\Component\Form\FormEvents;

#[AsFormType]
#[ApplyFormEvent(FormEvents::PRE_SET_DATA, new Autowire(service: MyChoiceLoader::class))]
class UserDTO
{
    #[Type]
    #[ApplyFormEvent(FormEvents::POST_SET_DATA, new Autowire(service: MyChoiceLoader::class))]
    public string $name;
}
| - Data Transformer
namespace App\DTO;

use Symfony\Component\Form\Attribute\ApplyDataTransformer;
use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\Form\Attribute\Type;

#[AsFormType]
#[ApplyDataTransformer(new Autowire(service: MyTransformer::class))]
class UserDTO
{
    #[Type]
    #[ApplyDataTransformer(new Autowire(service: MyTransformer::class))]
    public string $name;
}
configureOption method
| - Varaint 1 (not fan of it)
namespace App\DTO;

use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\OptionsResolver\OptionsResolver;

#[AsFormType]
class UserDTO
{
    public string $name;
    
    public static function configureOptions(OptionsResolver $resolver): void
    {
        // Do stuff
    }
}
| - Varaint 2 (love it but PHP 8.5)
namespace App\DTO;

use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\OptionsResolver\OptionsResolver;

#[AsFormType(configureOptions: static function (OptionsResolver $resolver): void {
    // Do stuff
})]
class UserDTO
{
    public string $name;
}
Override getParent

Not sure if this means something, maybe not needed. What do you think?

namespace App\DTO;

use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\OptionsResolver\OptionsResolver;

#[AsFormType(parent: AnotherFormType::class)]
class UserDTO
{
    public string $name;
}

Symfony Demo Application with Form Attribute

You can try the basic examples of AsFormType in the Symfony Demo Application fork:
https://github.com/WedgeSama/demo-with-form-attribute/tree/demo-with-form-attribute

List of FormType replaced (examples on both DTO and entities):

  • Form\ChangePasswordType => created DTO DTO\UserChangePasswordDTO
  • Form\UserType => created DTO DTO\UserProfileDTO
  • Form\CommentType => on existing entity Entity\Comment
  • Form\PostType => on existing entity Entity\Post

It use git submodule to require the form component with form attribute.

PS: any suggestion are welcome 😉

@carsonbot
Copy link

It looks like you unchecked the "Allow edits from maintainer" box. That is fine, but please note that if you have multiple commits, you'll need to squash your commits into one before this can be merged. Or, you can check the "Allow edits from maintainers" box and the maintainer can squash for you.

Cheers!

Carsonbot

Comment on lines +78 to +86
$commandDefinition = new Definition(DebugCommand::class, [
new Reference('form.registry'),
[],
[],
[],
[],
null,
[],
]);
Copy link
Contributor

Choose a reason for hiding this comment

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

Why this change ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If I dont, I got this error on tests:

1) Symfony\Component\Form\Tests\DependencyInjection\FormPassTest::testAddTaggedTypesToDebugCommand
Symfony\Component\DependencyInjection\Exception\RuntimeException: Invalid constructor argument 7 for service "console.command.form_debug": argument 4 must be defined before. Check your service definition.

/app/src/Symfony/Component/DependencyInjection/Compiler/DefinitionErrorExceptionPass.php:48
/app/src/Symfony/Component/DependencyInjection/Compiler/Compiler.php:73
/app/src/Symfony/Component/DependencyInjection/ContainerBuilder.php:813
/app/src/Symfony/Component/Form/Tests/DependencyInjection/FormPassTest.php:86

Do you have an alternative for that?

@Tiriel
Copy link
Contributor

Tiriel commented May 28, 2025

I do like the feature, but thinking about an extreme example of going all-in on attributes, I wonder were it goes.

Small simple example of building an app where we store informations about Events sent by users with our own schema, and we also receive informations from APIs with their own structure (you see where this is going). So we need the usual ORM attributes, some Validation, Object-Mapper, and these Form attributes. The entity becomes quite heavy on attributes.

use App\Dto\Event as EventDTO;
use App\Repository\EventRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\Form\Attribute\Type;
use Symfony\Component\Form\Extension\Core\Type as FormType;
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\Validator\Constraints as Assert;

#[AsFormType]
#[Map(target: EventDTO::class)]
#[ORM\Entity(repositoryClass: EventRepository::class)]
class Event
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;


    #[Map(target: 'eventName')]
    #[Assert\Length(min: 15)]
    #[Assert\NotBlank()]
    #[ORM\Column(length: 255)]
    #[Type]
    private ?string $name = null;

    #[Map(target: 'synopsis')]
    #[Assert\NotBlank()]
    #[ORM\Column(type: Types::TEXT)]
    #[Type(FormType\TextareaType::class)]
    private ?string $description = null;

    // ...

As you can see, the business need is small, there's not much in terms of validation or mapping, and I've included only few properties.

I don't know if that's a problem honestly, it's not that unreadable to me, by I do think the question should be raised.

That being said, once again, this is a really nice and neat feature, great job!

@Neirda24
Copy link
Contributor

I do like the feature, but thinking about an extreme example of going all-in on attributes, I wonder were it goes.

Small simple example of building an app where we store informations about Events sent by users with our own schema, and we also receive informations from APIs with their own structure (you see where this is going). So we need the usual ORM attributes, some Validation, Object-Mapper, and these Form attributes. The entity becomes quite heavy on attributes.

use App\Dto\Event as EventDTO;
use App\Repository\EventRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Form\Attribute\AsFormType;
use Symfony\Component\Form\Attribute\Type;
use Symfony\Component\Form\Extension\Core\Type as FormType;
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\Validator\Constraints as Assert;

#[AsFormType]
#[Map(target: EventDTO::class)]
#[ORM\Entity(repositoryClass: EventRepository::class)]
class Event
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;


    #[Map(target: 'eventName')]
    #[Assert\Length(min: 15)]
    #[Assert\NotBlank()]
    #[ORM\Column(length: 255)]
    #[Type]
    private ?string $name = null;

    #[Map(target: 'synopsis')]
    #[Assert\NotBlank()]
    #[ORM\Column(type: Types::TEXT)]
    #[Type(FormType\TextareaType::class)]
    private ?string $description = null;

    // ...

As you can see, the business need is small, there's not much in terms of validation or mapping, and I've included only few properties.

I don't know if that's a problem honestly, it's not that unreadable to me, by I do think the question should be raised.

That being said, once again, this is a really nice and neat feature, great job!

I think in your usecase if you map object to a dto then the form should probably be on said dto. WDYT ?

@Tiriel
Copy link
Contributor

Tiriel commented May 28, 2025

I think in your usecase if you map object to a dto then the form should probably be on said dto. WDYT ?

I know you love teasing me 😉 . Just to keep you happy:

  1. It's a fictitious example made just for testing
  2. In this case the DTO is intended to receive data from an API and allow mapping to the entity, when the entity is the base for the frontend form.

I don't pretend this usecase is universale though. Once again, it's designed to create a fake extreme example, to show what it could lead. It's more of a "let's pretend there's a need like that".

And then again, I don't say I'm definitely against this, I love the feature. But I also like to raise questions ;)

@yceruto
Copy link
Member

yceruto commented May 28, 2025

This look like a fantastic addition for simple forms, but honestly I'm not 100% sure for mid-level and complex forms.

PHP attributes are excellent for declaratively adding metadata, but there are limitations and cases where attributes are not the best fit. For instance, dynamic field definition, custom business logic, injecting services, or conditional configuration. In those cases, the traditional FormType classes may still be preferable (as they are services by definition).

I don’t think we should replicate all form type capabilities with attributes (because the scope and dynamic limitations), but they can certainly be useful for building simple forms quickly, and then easily switch to FormType classes when things get more complex.

@Neirda24
Copy link
Contributor

@yceruto : Yes this is a totally valid point. I don't think attributes are meant to replace Forms. But should try to cover most common cases without the need to refactor it to a dedicated class. Might be worth adding a warning to the documentation of this feature the list of known limitations.

@yceruto
Copy link
Member

yceruto commented May 28, 2025

$form = $formFactory->create(UserDTO::class, $user);

It looks strange from the design PoV passing the DTO class as form type. I’m wondering if, instead of passing the data class, we can leave the form type param as null and let the type be guessed from the data (if AsFormType)?

$form = $formFactory->create(null, $user); // the form type will be guessed and defined from the DTO

Maybe the same guesser mechanism could help with this? It'd be aligned with the null form type concept.

@WedgeSama
Copy link
Contributor Author

@Tiriel Yes it can be used directly on entities, but even with your example, to make it more readable, a dedicated DTO for the form can still be done 😉

@yceruto I think its a good start to keep it simple yes. We just need to set the limit to which capabilities we want to replicate. I also thought of a command that can "convert" an attribute form to a traditional FormType, something like make:form:from-metadata. WDYT?

And Im totally agree to something like :

$form = $formFactory->create(null, $user);
// or even?
$form = $formFactory->create($user);

* @author Benjamin Georgeault <git@wedgesama.fr>
*/
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final readonly class Type
Copy link
Member

Choose a reason for hiding this comment

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

To stay consistent with the purpose of this attribute, as described in the class description, it represents a form field, so I’d prefer to name this class Field ?

Copy link
Contributor

Choose a reason for hiding this comment

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

I think Field is wrong. Because it's biased by how it displays. But a "Field" could be an entire form. so I think we should keep Type but maybe rename other occurences of Field's to Type's. WDYT ?

*/
public function __construct(
private ?string $type = null,
private array $options = [],
Copy link
Member

Choose a reason for hiding this comment

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

What about also adding ?string $name = null, which defaults to the property name if null? If it’s set, it would use property_path for mapping. This could be convenient in cases where you need to change the field name in the view

@Tiriel
Copy link
Contributor

Tiriel commented May 28, 2025

@Tiriel Yes it can be used directly on entities, but even with your example, to make it more readable, a dedicated DTO for the form can still be done 😉

In that case, what would be the benefit of using a new dedicated DTO+ attribute over a regular FormType class?

@yceruto
Copy link
Member

yceruto commented May 28, 2025

I think its a good start to keep it simple yes. We just need to set the limit to which capabilities we want to replicate.

There are implicit limitations with options and Closures, which may be mitigated in PHP 8.5, but still, adding large blocks of logic in attributes feels a bit messy to me.

I think advanced features like data mappers, form events, and model/view transformers carry special complexity and responsibility tied to the form type itself, but I’m open to making them configurable using attributes, as long as we can also implement them outside the DTO.

@yceruto
Copy link
Member

yceruto commented May 28, 2025

I also thought of a command that can "convert" an attribute form to a traditional FormType, something like make:form:from-metadata. WDYT?

Yes, but what about just creating a command that generate the form type from a DTO 😅?
It's already possible from an Entity class https://github.com/symfony/maker-bundle/blob/1.x/src/Maker/MakeForm.php

@yceruto
Copy link
Member

yceruto commented May 28, 2025

And Im totally agree to something like :
$form = $formFactory->create(null, $user);
// or even?
$form = $formFactory->create($user);

even $form = $formFactory->create(data: $user) is fine currently

Comment on lines +42 to +47
($resolver = $this->createMock(OptionsResolver::class))
->expects($this->once())
->method('setDefaults')
->with([
'label' => 'Foo',
]);
Copy link
Contributor

Choose a reason for hiding this comment

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

We could create an instance of OptionResolver and make assertions later instead. It will ensure that only setDefaults is called.

// Arrange
$resolver = new OptionResolver();

// Act
(new MetadataType($metadata))->configureOptions($resolver);

// Assert
$this->assertSame(['label' => 'Foo'], $resolver->resolve());

Comment on lines +90 to +93
($metadata = $this->createMock(FormMetadataInterface::class))
->expects($this->once())
->method('getBlockPrefix')
->willReturn('Foo');
Copy link
Contributor

Choose a reason for hiding this comment

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

What about declaring an anonymous class implementing FormMetadataInterface in setUp to make these tests easier to read?

@Neirda24
Copy link
Contributor

And Im totally agree to something like :
$form = $formFactory->create(null, $user);
// or even?
$form = $formFactory->create($user);

even $form = $formFactory->create(data: $user) is fine currently

Symfony does not guarantee named arguments on methods at the moment. So maybe better not show this on documentation / example ?

@WedgeSama
Copy link
Contributor Author

@Tiriel Still one class less, less code:

  • Traditional (with DTO): Entity + DTO + FormType
  • Attributes (with DTO) : Entity + DTO

@yceruto

which may be mitigated in PHP 8.5

Yes, using the closure inside attributes in 8.5 can remove some limitations.

I think advanced features like data mappers, form events, and model/view transformers carry special complexity and responsibility tied to the form type itself, but I’m open to making them configurable using attributes, as long as we can also implement them outside the DTO.

Agreed

Yes, but what about just creating a command that generate the form type from a DTO 😅?

Can still be done, those 2 features are not mutually exclusive 😄

@Tiriel
Copy link
Contributor

Tiriel commented May 28, 2025

@Tiriel Still one class less, less code:

  • Traditional (with DTO): Entity + DTO + FormType
  • Attributes (with DTO) : Entity + DTO

I don't think we're talking about the same thing and we're going deep into things not strictly related to this feature. I don't see the point of the DTO in the first traditional case. And so, the number of classes is the same.

But that's beside the point. I'm not against you or the feature, I'm just saying that there are use cases where this could lead to bloated entities. And I'm not sure that advising user to put the attributes on another unnecessary class is the best solution.

But then again I seem to be the only one fearing that so maybe it's not that much of a concern.

@yceruto
Copy link
Member

yceruto commented May 28, 2025

Still one class less, less code:

Traditional (with DTO): Entity + DTO + FormType
Attributes (with DTO) : Entity + DTO

In terms of number of classes, yes. But to be fair, it’s not significantly less code. We’re going to inline the form type definition within the DTO class using attributes instead, so you’ll have to write extra code for that purpose.

The comparison should be in terms of coding: Attributes (with DTO): Entity + DTO + Attributes. It reduces the boilerplate of defining form fields and having an extra class. It makes things simpler for simple forms. Yes!

@yceruto
Copy link
Member

yceruto commented May 28, 2025

I loved the auto form example, but I’m afraid it would work only as a sample. In practice, you’ll need to define at least the field type to match the property’s purpose.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants