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

Symfony Forms - Use cases & optimization

6a6c4901485bdf61d2008722569e9ba4?s=47 Jules Pietri
September 16, 2016

Symfony Forms - Use cases & optimization

Let's take a look at a simple use case where forms can be so useful but sometimes hard to implement. With the last PHP7 allowing strict types on object methods, with the Form component refactoring and new features, we will take a moment to share some updated best practices and tricks.

1. ManyToMany relationship between a post and some tags

2. Handling extra data and use it in the model

3. Work around PHP 7 scalar type hints

4. Using form types inheritance mechanism

6a6c4901485bdf61d2008722569e9ba4?s=128

Jules Pietri

September 16, 2016
Tweet

More Decks by Jules Pietri

Other Decks in Programming

Transcript

  1. Welcome to SymfonyLive London 2016 Symfony Forms Use cases &

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

  3. Devs Symfony

  4. But mostly, devs don’t like forms…

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

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

    about forms
  7. MODELS FORMS Simple Complex Complex OK I got it WTF?

    I thought I got it
  8. On the road to mastering Form types with a simple

    use case 1. ManyToMany relationship between a post and some tags 2. Handling extra data and use it in the model 3. Work around PHP 7 scalar type hints 4. Using form types inheritance mechanism
  9. class Post { /** * @ORM\Id * @ORM\GeneratedValue * @ORM\Column(type="integer")

    */ private $id; /** * @ORM\Column(type="string") * @Assert\NotBlank() */ private $title; /** * @ORM\Column(type="string") */ private $slug; /** * @ORM\Column(type="string") * @Assert\NotBlank(message="post.blank_summary") */ private $summary; /** * @ORM\Column(type="text") * @Assert\NotBlank(message="post.blank_content") * @Assert\Length(min = "10", minMessage = "post.too_short_content") */ private $content; /** * @ORM\Column(type="string") * @Assert\Email() */ private $authorEmail; /** * @ORM\Column(type="datetime") * @Assert\DateTime() */ private $publishedAt; } Back to basics: a simple post
  10. does that sound familiar? class PostType extends AbstractType { public

    function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add('title', null, [ 'attr' => ['autofocus' => true], 'label' => 'label.title', ]) ->add('summary', TextareaType::class, [ 'label' => 'label.summary', ]) ->add('content', null, [ 'attr' => ['rows' => 20], 'label' => 'label.content', ]) ->add('authorEmail', null, [ 'label' => 'label.author_email', ]) ->add('publishedAt', DateTimePickerType::class, [ 'label' => 'label.published_at', ]) ; } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'data_class' => Post::class, ]); } } A simple form type,
  11. public function newAction(Request $request) { $post = new Post(); $post->setAuthorEmail($this->getUser()->getEmail());

    $form = $this->createForm(PostType::class, $post) ->add('saveAndCreateNew', SubmitType::class); $form->handleRequest($request); // ... A simple action
  12. /** * @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; // ... } First we need a tag entity Unique by its name A non blank name
  13. 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); } } Don’t add it twice No need for a setter
  14. class Post { // ... /** *@ORM\ManyToMany( * targetEntity=“AppBundle\Entity\Tag”, *

    inversedBy=“posts”, * cascade={“persist”} *) */ private $tags; // … public function getTags() { return $this->tags; } public function setTags($tags) { $this->tags = $tags; } 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); } } Adding a “tags” field to the post
  15. 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); } } WOW! Sometimes it really is simple.
  16. class PostType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array

    $options) { $builder // ... ->add('tags', CollectionType::class, [ 'entry_type' => TagType::class, 'by_reference' => false, 'allow_add' => true, 'prototype_data' => new Tag(), ]) ; } }
  17. /** * @ORM\Entity * @UniqueEntity(fields={“name"}) */ class Tag { //

    ... /** * @ORM\Column(type=“string") * @NotBlank */ private $name; // ... } But what if a tag already exists or is modified?
  18. class PostType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array

    $options) { $builder // ... ->add('tags', EntityType::class, [ 'class' => Tag::class, 'choice_label' => 'name', 'multiple' => true, ]) ; } }
  19. 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, 'by_reference' => false, 'allow_add' => true, 'mapped' => false, ]) ; } }
  20. Problem: How can we update the post from this unmapped

    field?
  21. Using Form events? PRE_SUBMIT => submitted data array POST_SUBMIT =>

    hydrated post, no more submitted data
  22. class PostType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array

    $options) { $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, ]) ; } }
  23. It just works!

  24. 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);
  25. 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; } // ... }
  26. Wait for PHP 7.1? class Tag { // ... /**

    * @ORM\Column(type="string") * @NotBlank */ private $name; public function setName(?string $name) { $this->name = $name; } public function getName() : ?string { return $this->name; } // ... } Nullable type hint
  27. Allow null in the model? It must 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; } // ... }
  28. class TagType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array

    $options) { $builder->add('name', TextType::class, ['empty_data' => '']); } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefault('data_class', Tag::class); } } Nope. Again, use “empty_data” Symfony 3.1+ only ⚠
  29. Use a form type extension 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; } }
  30. Don’t forget to define it as a service app.form.text_type_extension: class:

    AppBundleFormExtensionTextTypeExtension arguments: ['@appdoctrinetag_repository'] tags: - name: form.type_extension extended_type: SymfonyComponentFormExtensionCoreTypeTextType
  31. It just works! And with all PHP version!

  32. None
  33. Alright, but I’d prefer to have one field…

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

    I thought I got it
  35. Alright, but I’d prefer to have one field…

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

    I thought I got it UX constraint
  37. class PostType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array

    $options) { $builder // ... ->add('tags', EntityType::class, [ '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, ]) ; Problem: how can we mix both fields?
  38. One way to do it: using form type inheritance mechanism

    class TagsType extends AbstractType { public function getParent() { return ChoiceType::class; } }
  39. use Doctrine\ORM\EntityRepository; class TagsType extends AbstractType { private $repo; public

    function __construct(EntityRepository $repo) { $this->repo = $repo; } public function getParent() { return ChoiceType::class; } } First of all, we’ll need the repository
  40. app.form.tags_type: class: AppBundleFormTagsType arguments: [‘@doctrine.orm.entity_manager’] tags: - { name: form.type

    } Then declare our form type as a service
  41. 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 } }
  42. 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 = null) { return null === $tag ? '' : $tag->getName(); }; return new ArrayChoiceList($tags, $tagValue); }
  43. 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; }
  44. public function loadChoicesForValues(array $values, $value = null) { if (empty($values))

    { return []; } if (null !== $this->choiceList) { return $this->choiceList->getChoicesForValues($values); } $tags = []; foreach ($values as $tagName) { if (empty($name)) { continue; } $tag = $this->repo->findOneBy(['name' => $name]); if (null === $tag) { $tag = new Tag(); $tag->setName($name); } $tags[] = $tag; } return $tags; }
  45. 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 } }
  46. class TagsType extends AbstractType implements ChoiceLoaderInterface, DataTransformerInterface { // ...

    public function transform($data) { if (empty($data)) { return ''; } return implode(', ', $data); } }
  47. use Symfony\Component\Form\Exception\UnexpectedTypeException; class TagsType extends AbstractType implements ChoiceLoaderInterface, DataTransformerInterface {

    // ... public function reverseTransform($value) { if (null === $value) { return []; } if (!is_string($value)) { throw new UnexpectedTypeException($value, 'string or null'); } return explode(',', $value); } No space in the separator
  48. class TagsType extends AbstractType implements ChoiceLoaderInterface { // ... public

    function loadChoicesForValues(array $values, $value = null) { if (empty($values)) { return array(); } $tags = []; foreach ($values as $name) { $name = trim($name); $tag = $this->repo->findOneBy([‘name' => $name]); if (null === $tag) { $tag = new Tag(); $tag->setName($name); } $tags[] = $tag; } return $tags; } }
  49. class TagsType extends AbstractType implements ChoiceLoaderInterface, DataTransformerInterface { // ...

    public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'choice_loader' => $this, 'multiple' => true, ]); } // ... } Let’s override ChoiceType configuration
  50. use Symfony\Bridge\Doctrine\Form\DataTransformer\CollectionToArrayTransformer; class TagsType extends AbstractType implements ChoiceLoaderInterface, DataTransformerInterface {

    // ... public function buildForm(FormBuilderInterface $builder, array $options) { $builder->addModelTransformer(new CollectionToArrayTransformer()); $builder->addViewTransformer($this); } // ... } Let’s extend ChoiceType form building
  51. class TagsType extends AbstractType implements ChoiceLoaderInterface, DataTransformerInterface { // ...

    public function finishView(FormView $view, FormInterface $form, array $options) { // remove the trailing "[]" $view->vars['full_name'] = substr($view->vars['full_name'], 0, -2); } // ... } Let’s override ChoiceType view
  52. "block_prefixes" => array:4 [▼ 0 => "form" 1 => "choice"

    2 => "tags" 3 => "_post_tags" ] Let’s override ChoiceType view
  53. {% form_theme form _self %}
 
 {% block choice_widget %}


    {{ form_widget(form) }}
 {% endblock %} Let’s override ChoiceType view
  54. class PostType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array

    $options) { $builder // ... ->add('tags', EntityType::class, [ '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, ]) ; Problem: how can we mix both fields?
  55. class PostType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array

    $options) { $builder // ... ->add('tags', TagsType::class) ; } } WOW! Simple again :)
  56. Mission complete!

  57. None
  58. https://leaverou.github.io/awesomplete

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

    in your base template
  60. We need our input to get these attributes

  61. 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; $view->vars['attr']['data-list'] = implode(', ', $view->vars['choices']); } Lets’ extend ChoiceType view vars
  62. Thank you!!