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

Symfony Forms - One use case, many optimizations

6a6c4901485bdf61d2008722569e9ba4?s=47 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.

6a6c4901485bdf61d2008722569e9ba4?s=128

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
  2. Jules Pietri Symfony/PHP developer @SensioLabs @julespietri @HeahDude

  3. Devs Symfony

  4. But when it comes to forms…

  5. Haaave you met @webmozart? Bernhard Schussek is the core developer

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

    of the Form Component https://speakerdeck.com/webmozart https://webmozart.io
  7. “Make simple cases simple, make complex cases possible” @webmozart, talking

    about Form component
  8. MODELS FORMS Simple Complex Complex

  9. MODELS FORMS Simple Complex Complex

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

    I thought I got it
  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
  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 // ... }
  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, ]); } }
  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); // ...
  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; // ... }
  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); } }
  17. class Post { // ... /** * @ORM\ManyToMany( * targetEntity="AppBundle\Entity\Tag",

    * inversedBy="posts", * cascade={"persist"} * ) */ private $tags; /* * @var Collection|Tag[] */ public function getTags() { return $this->tags; } // ...
  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); } }
  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); } }
  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
  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; // ... }
  22. The CollectionType is too permissive about editing nested entities Problem

    1
  23. What about using EntityType instead? Solution 1

  24. class PostType extends AbstractType { public function buildForm(...) { $builder

    // ... ->add('tags', EntityType::class, [ 'class' => Tag::class, 'choice_label' => 'name', 'multiple' => true, ]) ; } }
  25. The EntityType does not allow adding new nested entities Problem

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

  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, ]) ; } }
  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); } }) ;
  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
  30. It just works!

  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);
  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; } // ... }
  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; } // ... }
  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; } // ... }
  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); } }
  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; } }
  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
  38. It just works with all PHP versions!

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

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

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

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

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

    function getParent() { return ChoiceType::class; } } Solution 3
  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; } }
  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
  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
  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); } // ...
  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; }
  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); }
  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() ; } }
  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 } }
  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); } // ...
  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)); }
  54. Let’s override ChoiceType default configuration class TagsType ... { //

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

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


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

    public function buildForm(FormBuilderInterface $builder, array $options) { $builder // ... ->add('tags', TagsType::class) ; } }
  61. Mission complete!

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

  63. <link rel="stylesheet" href="awesomplete.css" /> <script src="awesomplete.js"></script> Just add this in

    your base template
  64. We need our input to get those attributes

  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'] = ''; } // ... }
  66. Thank you!