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 full-size slide

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

    View full-size slide

  3. Devs Symfony

    View full-size slide

  4. But when it comes to forms…

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  8. MODELS
    FORMS
    Simple
    Complex
    Complex

    View full-size slide

  9. MODELS
    FORMS
    Simple
    Complex
    Complex

    View full-size slide

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

    View full-size 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 full-size 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 full-size 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 full-size 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 full-size 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 full-size 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 full-size 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 full-size 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 full-size 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 full-size 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 full-size 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 full-size slide

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

    View full-size slide

  23. What about using
    EntityType instead?
    Solution 1

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size 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 full-size 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 full-size 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 full-size slide

  30. It just works!

    View full-size 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 full-size 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 full-size 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 full-size 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 full-size 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 full-size 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 full-size 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 full-size slide

  38. It just works with all PHP versions!

    View full-size slide

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

    Problem 3

    View full-size slide

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

    View full-size slide

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

    View full-size 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 full-size slide

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

    View full-size 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 full-size 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 full-size 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 full-size 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 full-size 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 full-size 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 full-size 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 full-size 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 full-size 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 full-size 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 full-size slide

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

    View full-size 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 full-size 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 full-size slide

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

    View full-size slide

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


    {% block choice_widget %}

    {{ form_widget(form) }}

    {% endblock %}

    View full-size 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 full-size slide

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

    View full-size slide

  61. Mission complete!

    View full-size slide

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

    View full-size slide



  63. Just add this in your base template

    View full-size slide

  64. We need our input to get
    those attributes

    View full-size 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 full-size slide