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

Transcript

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

More Decks by Jules Pietri

Other Decks in Programming