Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Symfony Forms - One use case, many optimizations

Jules Pietri
December 02, 2016

Symfony Forms - One use case, many optimizations

By taking one of the most usual and simple use case: a post and some tags, we will identify many ways to optimize our work with forms.

The focus will be about:
- simple many-to-many relationship,
- data mapping,
- PHP 7 scalar type hints,
- form type inheritance mechanism,

while some tricks will be shared along the way.

Jules Pietri

December 02, 2016
Tweet

More Decks by Jules Pietri

Other Decks in Programming

Transcript

  1. Welcome to SymfonyCon Berlin 2016
    Symfony Forms
    One use case, many
    optimizations

    View Slide

  2. Jules Pietri
    Symfony/PHP developer
    @SensioLabs
    @julespietri @HeahDude

    View Slide

  3. Devs Symfony

    View Slide

  4. But when it comes to forms…

    View Slide

  5. Haaave you met @webmozart?
    Bernhard Schussek is the core developer of the
    Form Component
    https://speakerdeck.com/webmozart
    https://webmozart.io

    View Slide

  6. Haaave you met @webmozart?
    Bernhard Schussek is the core developer of the
    Form Component
    https://speakerdeck.com/webmozart
    https://webmozart.io

    View Slide

  7. “Make simple cases simple,
    make complex cases
    possible”
    @webmozart, talking about Form component

    View Slide

  8. MODELS
    FORMS
    Simple
    Complex
    Complex

    View Slide

  9. MODELS
    FORMS
    Simple
    Complex
    Complex

    View Slide

  10. MODELS
    FORMS
    Simple
    Complex
    Complex
    OK
    I got it
    WTF?
    I thought I got it

    View Slide

  11. On the road to mastering Form types with
    a simple use case
    1. ManyToMany relationship
    2. Handling unmapped data
    3. Work around PHP 7 scalar type hints
    4. Using form types inheritance mechanism

    View Slide

  12. Back to basics: a simple Post
    class Post
    {
    private $id;
    private $title;
    private $slug;
    private $summary;
    private $content;
    private $authorEmail;
    private $publishedAt;
    // Getters and setters
    // ...
    }

    View Slide

  13. A simple FormType
    class PostType extends AbstractType
    {
    public function buildForm(…)
    {
    $builder
    ->add('title', TextType::class, ...)
    ->add('summary', TextareaType::class, ...)
    ->add('content', TextareaType::class, ...)
    ->add('authorEmail', TextType::class, ...)
    ->add('publishedAt', DateTimeType::class, ...)
    ;
    }
    public function configureOptions(OptionsResolver $resolver)
    {
    $resolver->setDefaults([
    'data_class' => Post::class,
    ]);
    }
    }

    View Slide

  14. A simple action
    public function newAction(Request $request)
    {
    $post = new Post();
    $post->setAuthorEmail($this->getUser()->getEmail());
    $form = $this->createForm(PostType::class, $post);
    $form->handleRequest($request);
    // ...

    View Slide

  15. /**
    * @ORM\Entity
    * @UniqueEntity(fields={"name"})
    */
    class Tag
    {
    /**
    * @ORM\Id
    * @ORM\GeneratedValue
    * @ORM\Column(type="integer")
    */
    private $id;
    /**
    * @ORM\Column(type="string")
    * @Assert\NotBlank
    */
    private $name;
    /**
    * @var Collection|Post[]
    *
    * @ORM\ManyToMany(targetEntity="Post", mappedBy="tags")
    */
    private $posts;
    // ...
    }

    View Slide

  16. class Tag
    {
    // ...
    /**
    * @return Collection|Post[]
    */
    public function getPosts()
    {
    return $this->posts;
    }
    /**
    * @param Post $post
    */
    public function addPost(Post $post)
    {
    if (!$this->posts->contains($post)) {
    $this->posts->add($post);
    }
    }
    /**
    * @param Post $post
    */
    public function removePost(Post $post)
    {
    $this->posts->remove($post);
    }
    }

    View Slide

  17. class Post
    {
    // ...
    /**
    * @ORM\ManyToMany(
    * targetEntity="AppBundle\Entity\Tag",
    * inversedBy="posts",
    * cascade={"persist"}
    * )
    */
    private $tags;
    /*
    * @var Collection|Tag[]
    */
    public function getTags()
    {
    return $this->tags;
    }
    // ...

    View Slide

  18. class Post
    {
    // ...
    public function addTag(Tag $tag)
    {
    if (!$this->tags->contains($tag)) {
    $this->tags->add($tag);
    $tag->addPost($this);
    }
    }
    public function removeTag(Tag $tag)
    {
    $this->tags->removeElement($tag);
    }
    }

    View Slide

  19. WOW! Sometimes it really is simple.
    class TagType extends AbstractType
    {
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
    $builder->add('name', TextType::class);
    }
    public function configureOptions(OptionsResolver $resolver)
    {
    $resolver->setDefault('data_class', Tag::class);
    }
    }

    View Slide

  20. class PostType extends AbstractType
    {
    public function buildForm(...)
    {
    $builder
    // ...
    ->add('tags', CollectionType::class, [
    'entry_type' => TagType::class,
    'allow_add' => true,
    'by_reference' => false,
    ])
    ;
    }
    }
    “allow_add” option allows to create new
    tags while attaching them to a post

    View Slide

  21. But what if a tag already exists
    or is modified?
    /**
    * @ORM\Entity
    * @UniqueEntity(fields={"name"})
    */
    class Tag
    {
    // ...
    /**
    * @ORM\Column(type="string")
    * @NotBlank
    */
    private $name;
    // ...
    }

    View Slide

  22. The CollectionType is
    too permissive about
    editing nested entities
    Problem 1

    View Slide

  23. What about using
    EntityType instead?
    Solution 1

    View Slide

  24. class PostType extends AbstractType
    {
    public function buildForm(...)
    {
    $builder
    // ...
    ->add('tags', EntityType::class, [
    'class' => Tag::class,
    'choice_label' => 'name',
    'multiple' => true,
    ])
    ;
    }
    }

    View Slide

  25. The EntityType does
    not allow adding new
    nested entities
    Problem 2

    View Slide

  26. What about using
    both EntityType and
    CollectionType?
    Solution 2

    View Slide

  27. class PostType extends AbstractType
    {
    public function buildForm(...)
    {
    $builder
    // ...
    ->add('current_tags', CollectionType::class, [
    'property_path' => 'tags',
    'class' => Tag::class,
    'choice_label' => 'name',
    'multiple' => true,
    ])
    ->add('new_tags', CollectionType::class, [
    'entry_type' => TagType::class,
    'by_reference' => false,
    'allow_add' => true,
    'mapped' => false,
    ])
    ;
    }
    }

    View Slide

  28. Using Form events?
    How can we update the post from
    this unmapped field?
    $builder
    // …
    ->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) {
    $post = $event->getData();
    $newTags = $event->getForm()->get('new_tags')->getData();
    foreach ($newTags as $tag) {
    $post->addTag($tag);
    }
    })
    ;

    View Slide

  29. $builder
    // ...
    ->add('new_tags', CollectionType::class, [
    'entry_type' => TagType::class,
    'entry_options' => [
    'empty_data' => function(FormInterface $form) {
    $tag = new Tag();
    $tag->setName($form->get('name')->getData());
    $form->getParent()->getParent()->getData()->addTag($tag);
    },
    ],
    'by_reference' => false,
    'allow_add' => true,
    'mapped' => false,
    ])
    ;
    Or using “empty_data” option

    View Slide

  30. It just works!

    View Slide

  31. But maybe not on PHP 7.0+…
    Fatal error: Uncaught TypeError: Argument 1
    passed to Tag::setName() must be of the
    type string, null given
    declare('strict_types', 0/1);

    View Slide

  32. class Tag
    {
    // ...
    /**
    * @ORM\Column(type="string")
    * @Assert\NotBlank
    */
    private $name = '';
    public function setName(string $name)
    {
    $this->name = $name;
    }
    public function getName() : string
    {
    return $this->name;
    }
    // ...
    }

    View Slide

  33. Update to PHP 7.1, using nullable type hints?
    class Tag
    {
    // ...
    /**
    * @ORM\Column(type="string")
    * @NotBlank
    */
    private $name;
    public function setName(?string $name)
    {
    $this->name = $name;
    }
    public function getName() : ?string
    {
    return $this->name;
    }
    // ...
    }

    View Slide

  34. Allow null in the model? It will be validated anyway…
    class Tag
    {
    // ...
    /**
    * @ORM\Column(type="string")
    * @Assert\NotBlank
    */
    private $name = '';
    public function setName(string $name = null)
    {
    $this->name = $name;
    }
    public function getName() : string
    {
    return $this->name;
    }
    // ...
    }

    View Slide

  35. Nope. Again, use “empty_data”
    Symfony 3.1+ only

    class TagType extends AbstractType
    {
    public function buildForm(...)
    {
    $builder->add('name', TextType::class, [
    'empty_data' => '',
    ]);
    }
    public function configureOptions(OptionsResolver $resolver)
    {
    $resolver->setDefault('data_class', Tag::class);
    }
    }

    View Slide

  36. Use it globally with a FormTypeExtension
    namespace AppBundle\Form\Extension;
    use Symfony\Component\Form\AbstractTypeExtension;
    use Symfony\Component\Form\Extension\Core\Type\TextType;
    use Symfony\Component\OptionsResolver\OptionsResolver;
    class TextTypeExtension extends AbstractTypeExtension
    {
    public function configureOptions(OptionsResolver $resolver)
    {
    $resolver->setDefault('empty_data', '');
    }
    public function getExtendedType()
    {
    return TextType::class;
    }
    }

    View Slide

  37. Don’t forget to define the
    FormTypeExtension as a service
    app.form.text_type_extension:
    class: AppBundle\Form\Type\Extension\TextTypeExtension
    tags:
    - name: form.type_extension
    extended_type: Symfony\Component\Form\Extension\Core\Type\TextType

    View Slide

  38. It just works with all PHP versions!

    View Slide

  39. Alright, but I’d prefer to have one field…

    Problem 3

    View Slide

  40. MODELS
    FORMS
    Simple
    Complex
    Complex
    OK
    I got it
    WTF?
    I thought I got it

    View Slide

  41. MODELS
    FORMS
    Simple
    Complex
    Complex
    OK
    I got it
    WTF?
    I thought I got it
    UX constraint

    View Slide

  42. Problem 3: mix both fields
    class PostType extends AbstractType
    {
    public function buildForm(...)
    {
    $builder
    // ...
    ->add('current_tags', EntityType::class, [...])
    ->add('new_tags', CollectionType::class, [...])
    ;
    }
    }

    View Slide

  43. using FormType inheritance mechanism
    class TagsType extends AbstractType
    {
    public function getParent()
    {
    return ChoiceType::class;
    }
    }
    Solution 3

    View Slide

  44. First of all, we’ll need the Tag repository
    use Doctrine\Common\Persistence\ManagerRegistry;
    use Doctrine\ORM\EntityRepository;
    class TagsType extends AbstractType
    {
    /**
    * @var EntityRepository
    */
    private $repo;
    public function __construct(ManagerRegistry $doctrine)
    {
    $this->repo = $doctrine->getRepository(Tag::class);
    }
    public function getParent()
    {
    return ChoiceType::class;
    }
    }

    View Slide

  45. So we need to declare the
    FormType as a service
    app.form.tags_type:
    class: AppBundle\Form\Type\TagsType
    arguments: ['@doctrine']
    tags:
    - name: form.type

    View Slide

  46. use Symfony\Component\Form\ChoiceList\Loader\ChoiceLoaderInterface;
    class TagsType extends AbstractType implements ChoiceLoaderInterface
    {
    // ...
    public function loadChoiceList($value = null)
    {
    // returns a Symfony\Component\Form\ChoiceList\ChoiceListInterface
    }
    public function loadChoicesForValues(array $values, $value = null)
    {
    // returns an array of entities for the given values
    }
    public function loadValuesForChoices(array $choices, $value = null)
    {
    // returns an array of values for the given entities
    }
    }
    We’ll need a ChoiceLoader

    View Slide

  47. use Symfony\Component\Form\ChoiceList\ArrayChoiceList;
    class TagsType extends AbstractType implements ChoiceLoaderInterface
    {
    private $repo;
    private $choiceList;
    // ...
    public function loadChoiceList($value = null)
    {
    if (null !== $this->choiceList) {
    return $this->choiceList;
    }
    $tags = $this->repo->findAll();
    $tagValue = function (Tag $tag = null) {
    return null === $tag ? '' : $tag->getName();
    };
    return new ArrayChoiceList($tags, $tagValue);
    }
    // ...

    View Slide

  48. Handling pre set data
    public function loadValuesForChoices(array $choices, $value = null)
    {
    if (empty($choices)) {
    return [];
    }
    if (null !== $this->choiceList) {
    return $this->choiceList->getValuesForChoices($choices);
    }
    $tagNames = [];
    foreach ($choices as $tag) {
    $tagNames[] = $tag->getName();
    }
    return $tagNames;
    }

    View Slide

  49. Handling submitted data
    public function loadChoicesForValues(array $values, $value = null)
    {
    if (empty($values)) {
    return [];
    }
    if (null !== $this->choiceList) {
    return $this->choiceList->getChoicesForValues($values);
    }
    return $this->repo->findTags($values);
    }

    View Slide

  50. Handling submitted data
    class TagRepository extends EntityRepository
    {
    /**
    * @param string[] $names
    *
    * @return Tag[]
    */
    public function findTags(array $names)
    {
    return $this->createQueryBuilder('t')
    ->where('t.name IN(:names)')
    ->setParameter('names', $names)
    ->getQuery()
    ->getResult()
    ;
    }
    }

    View Slide

  51. We’ll need a view DataTransformer
    use Symfony\Component\Form\DataTransformerInterface;
    class TagsType extends AbstractType implements
    ChoiceLoaderInterface, DataTransformerInterface
    {
    // ...
    public function transform($data)
    {
    // get a string from tag names array
    }
    public function reverseTransform($value)
    {
    // get an array of tag names from submitted string
    }
    }

    View Slide

  52. class TagsType ...
    {
    // ...
    public function transform($data)
    {
    if (empty($data)) {
    return '';
    }
    return implode(', ', $data);
    }
    public function reverseTransform($value)
    {
    if (null === $value) {
    return [];
    }
    return explode(',', $value);
    }
    // ...

    View Slide

  53. So we need to trim values
    public function loadChoicesForValues(array $values, $value = null)
    {
    if (empty($values)) {
    return [];
    }
    if (null !== $this->choiceList) {
    return $this->choiceList->getChoicesForValues($values);
    }
    return $this->repo->findTags(array_map(function ($value) {
    return trim($value);
    }, $values));
    }

    View Slide

  54. Let’s override ChoiceType
    default configuration
    class TagsType ...
    {
    // ...
    public function configureOptions(OptionsResolver $resolver)
    {
    $resolver->setDefaults([
    'choice_loader' => $this,
    'multiple' => true,
    ]);
    }
    // ...
    }

    View Slide

  55. Let’s extend ChoiceType
    form building
    use Symfony\Bridge\Doctrine\Form\DataTransformer\CollectionToArrayTransformer;
    class TagsType ...
    {
    // ...
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
    $builder->addModelTransformer(new CollectionToArrayTransformer());
    $builder->addViewTransformer($this);
    }
    // ...
    }

    View Slide

  56. Let’s override ChoiceType view
    class TagsType ...
    {
    // ...
    public function finishView(FormView $view, FormInterface $form, array $options)
    {
    // remove the trailing "[]"
    $view->vars['full_name'] = substr($view->vars['full_name'], 0, -2);
    }
    // ...
    }

    View Slide

  57. Let’s override ChoiceType view
    "block_prefixes" =>
    array:4 [▼
    0 => "form"
    1 => "choice"
    2 => "tags"
    3 => "_post_tags"
    ]

    View Slide

  58. Let’s override ChoiceType view
    {% form_theme form _self %}


    {% block choice_widget %}

    {{ form_widget(form) }}

    {% endblock %}

    View Slide

  59. Problem 3: mix both fields
    class PostType extends AbstractType
    {
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
    $builder
    // ...
    ->add('current_tags', EntityType::class, [
    'property_path' => 'tags',
    'class' => Tag::class,
    'choice_label' => 'name',
    'multiple' => true,
    ])
    ->add('new_tags', CollectionType::class, [
    'entry_type' => TagType::class,
    'entry_options' => [
    'empty_data' => function(FormInterface $form) {...},
    ],
    'by_reference' => false,
    'allow_add' => true,
    'mapped' => false,
    ])
    ;
    }
    }

    View Slide

  60. Solution 3: mix both fields
    class PostType extends AbstractType
    {
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
    $builder
    // ...
    ->add('tags', TagsType::class)
    ;
    }
    }

    View Slide

  61. Mission complete!

    View Slide

  62. https://leaverou.github.io/awesomplete

    View Slide



  63. Just add this in your base template

    View Slide

  64. We need our input to get
    those attributes

    View Slide

  65. Lets’ extend ChoiceType view vars
    class TagsType ...
    {
    // ...
    public function finishView(FormView $view, FormInterface $form, array $options)
    {
    // remove the trailing "[]"
    $view->vars['full_name'] = substr($view->vars['full_name'], 0, -2);
    $class = '';
    if (isset($view->vars['attr']['class'])) {
    $class = $view->vars['attr']['class'];
    }
    $class .= ' awesomplete';
    $view->vars['attr']['class'] = $class;
    $tagNames = $form->getConfig()->getAttribute('choice_list')->getValues();
    $view->vars['attr']['data-list'] = implode(', ', $tagNames);
    $view->vars['attr']['data-multiple'] = '';
    }
    // ...
    }

    View Slide

  66. Thank you!

    View Slide