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

Symfony Forms - Use cases & optimization

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

Jules Pietri

September 16, 2016
Tweet

More Decks by Jules Pietri

Other Decks in Programming

Transcript

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

    of the Form Component https://speakerdeck.com/webmozart https://webmozart.io
  2. 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
  3. 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
  4. 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,
  5. 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
  6. /** * @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
  7. 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
  8. 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
  9. 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.
  10. 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(), ]) ; } }
  11. /** * @ORM\Entity * @UniqueEntity(fields={“name"}) */ class Tag { //

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

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

    I thought I got it UX constraint
  23. 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?
  24. One way to do it: using form type inheritance mechanism

    class TagsType extends AbstractType { public function getParent() { return ChoiceType::class; } }
  25. 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
  26. 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 } }
  27. 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); }
  28. 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; }
  29. 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; }
  30. 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 } }
  31. class TagsType extends AbstractType implements ChoiceLoaderInterface, DataTransformerInterface { // ...

    public function transform($data) { if (empty($data)) { return ''; } return implode(', ', $data); } }
  32. 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
  33. 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; } }
  34. class TagsType extends AbstractType implements ChoiceLoaderInterface, DataTransformerInterface { // ...

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

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


    {{ form_widget(form) }}
 {% endblock %} Let’s override ChoiceType view
  39. 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?
  40. class PostType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array

    $options) { $builder // ... ->add('tags', TagsType::class) ; } } WOW! Simple again :)
  41. 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