Symfony forms

Quick intro

I've being working with complex forms last week and I would like to remember my stoppers on the future.

Consistency, avoid setters

Classes has to be solid otherwise the class should not be instantiated.
Lets create a simple Discount class I.E :

class Discount
{
   /**
    * @var string
    * @Assert\NotNull(message="discount.exception.name.not_null")
    * @Assert\NotBlank(message="discount.exception.name.not_blank")
    */
   protected $name;

   /**
    * @var int
    *
    * @Assert\NotNull(message="discount.exception.value.not_null")
    * @Assert\NotBlank(message="discount.exception.value.not_blank")
    * @Assert\Regex(pattern="/^[0-9]\d*$/", message="discount.value.must_be_a_positive_integer")
    */
   protected $value;

   /**
    * Discount constructor.
    *
    * @param string $name
    * @param int $value
    */
   public function __construct(string $name, $value = 0)
   {
       $this->name = $name;
       $this->value = $value;
   }

   /**
    * @return string
    */
   public function getName(): string
   {
       return $this->name;
   }

   /**
    * @return int
    */
   public function getValue()
   {
       return $this->value;
   }
}

Avoid setters is a good practice but you will have to deal with forms. That's the way:

class DiscountType extends AbstractType
{
    /**
     * {@inheritdoc}
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name', TextType::class)
            ->add('value', NumberType::class, ['scale' => 0])
        ;
    }

    /**
     * {@inheritdoc}
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'csrf_protection' => false,
            'data_class' => Discount::class,
            'empty_data' => function(FormInterface $form) {

                return new Discount(
                    $form->get('name')->getData(),
                    $form->get('value')->getData()
                );
            }
        ]);
    }
}
Problem with NumberType and IntegerType

NumberType will be a double always, so imagine the case you are working on base 100.
See the following example:

POST /discount

{
   "name": "Demo",
   "value": 2.50
}

The form will fail because of the regEx, good!

So, now change:

->add('value', NumberType::class, ['scale' => 0])
// To
->add('value', IntegerType::class)

Because we want better consistency on the code

And try again:

POST /discount

{
   "name": "Demo",
   "value": 2.50
}

On the Discount constructor now we have value = 2. Shit!

There is an constraint on symfony Assert\Type(type="integer") but doesn't work with NumberType properly because in the Discount constructor we will have a double, even if its 250, but the Assert\Type will fail beacause espects an integer.

So change NumberType to IntergerType, its not possible until fix. (Symfony 3.0.6)

Create the form:
$form = $this->container->get('form.factory')->create(DiscountType::class)->submit($data, true)

Where $data is the array with:

[
    'name' => "Hello",
    'discount' => 250
]

The annotations on the object will validate the parameters.

To get our Discount object $form->getData() and you are done.

Pro tip

Do not forget add the $clearMissing to submit(), otherwise will always null missing params, and you may not always want it.

Embed forms

To use a collection of discounts you can embed it into another form just adding. I.E to the Cart:

public function buildForm(FormBuilderInterface $builder, array $options)
{
    $builder->add('discounts', CollectionType::class, [
        'required' => false,
        'allow_add' => true,
        'entry_type' => DiscountType::class
    ])
}


POST /cart

{
   "discounts": [
       [
           "name": "Promocode",
           "value": 2000
       ],
       [
           "name": "Welcome",
           "value": 250
       ]
   ]
}

Very pro tip

When using FOSRestBundle use ParamFetcher annotations to get only known values:

...
 * @RequestParam(name="user", strict=false)
 * @RequestParam(name="discounts", strict=false) // delegate the fail to the form
 */
public function postAction(ParamFetcher $paramFetcher)
{
    $data = $paramFetcher->all()
    $cart = $this->createForm(CartType::class)->submit($data, true)->getData();

Is a good practice don't do that on the controller better, delegate this to a Factory class.

If you have a better solution for that... Let me know!