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. Haaave you met @webmozart? Bernhard Schussek is the core developer

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

    of the Form Component https://speakerdeck.com/webmozart https://webmozart.io
  3. 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
  4. 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 // ... }
  5. 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, ]); } }
  6. 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); // ...
  7. /** * @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; // ... }
  8. 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); } }
  9. class Post { // ... /** * @ORM\ManyToMany( * targetEntity="AppBundle\Entity\Tag",

    * inversedBy="posts", * cascade={"persist"} * ) */ private $tags; /* * @var Collection|Tag[] */ public function getTags() { return $this->tags; } // ...
  10. 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); } }
  11. 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); } }
  12. 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
  13. But what if a tag already exists or is modified?

    /** * @ORM\Entity * @UniqueEntity(fields={"name"}) */ class Tag { // ... /** * @ORM\Column(type="string") * @NotBlank */ private $name; // ... }
  14. class PostType extends AbstractType { public function buildForm(...) { $builder

    // ... ->add('tags', EntityType::class, [ 'class' => Tag::class, 'choice_label' => 'name', 'multiple' => true, ]) ; } }
  15. 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, ]) ; } }
  16. 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); } }) ;
  17. $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
  18. 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);
  19. 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; } // ... }
  20. 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; } // ... }
  21. 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; } // ... }
  22. 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); } }
  23. 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; } }
  24. 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
  25. MODELS FORMS Simple Complex Complex OK I got it WTF?

    I thought I got it UX constraint
  26. Problem 3: mix both fields class PostType extends AbstractType {

    public function buildForm(...) { $builder // ... ->add('current_tags', EntityType::class, [...]) ->add('new_tags', CollectionType::class, [...]) ; } }
  27. using FormType inheritance mechanism class TagsType extends AbstractType { public

    function getParent() { return ChoiceType::class; } } Solution 3
  28. 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; } }
  29. 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
  30. 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
  31. 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); } // ...
  32. 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; }
  33. 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); }
  34. 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() ; } }
  35. 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 } }
  36. class TagsType ... { // ... public function transform($data) {

    if (empty($data)) { return ''; } return implode(', ', $data); } public function reverseTransform($value) { if (null === $value) { return []; } return explode(',', $value); } // ...
  37. 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)); }
  38. Let’s override ChoiceType default configuration class TagsType ... { //

    ... public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'choice_loader' => $this, 'multiple' => true, ]); } // ... }
  39. 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); } // ... }
  40. 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); } // ... }
  41. Let’s override ChoiceType view "block_prefixes" => array:4 [▼ 0 =>

    "form" 1 => "choice" 2 => "tags" 3 => "_post_tags" ]
  42. Let’s override ChoiceType view {% form_theme form _self %}
 


    {% block choice_widget %}
 {{ form_widget(form) }}
 {% endblock %}
  43. 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, ]) ; } }
  44. Solution 3: mix both fields class PostType extends AbstractType {

    public function buildForm(FormBuilderInterface $builder, array $options) { $builder // ... ->add('tags', TagsType::class) ; } }
  45. 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'] = ''; } // ... }