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. Welcome to SymfonyLive London 2016
    Symfony Forms
    Use cases & Optimisation

    View Slide

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

    View Slide

  3. Devs Symfony

    View Slide

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

    View Slide

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

    View Slide

  6. “Make simple cases simple,
    make complex cases possible”
    @webmozart, talking about forms

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

  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,

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  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.

    View Slide

  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(),
    ])
    ;
    }
    }

    View Slide

  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?

    View Slide

  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,
    ])
    ;
    }
    }

    View Slide

  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,
    ])
    ;
    }
    }

    View Slide

  20. Problem:
    How can we update the post from this unmapped field?

    View Slide

  21. Using Form events?
    PRE_SUBMIT => submitted data array
    POST_SUBMIT => hydrated post, no more submitted data

    View Slide

  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,
    ])
    ;
    }
    }

    View Slide

  23. It just works!

    View Slide

  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);

    View Slide

  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;
    }
    // ...
    }

    View Slide

  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

    View Slide

  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;
    }
    // ...
    }

    View Slide

  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

    View Slide

  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;
    }
    }

    View Slide

  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

    View Slide

  31. It just works! And with all PHP version!

    View Slide

  32. View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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?

    View Slide

  38. One way to do it: using form type inheritance mechanism
    class TagsType extends AbstractType
    {
    public function getParent()
    {
    return ChoiceType::class;
    }
    }

    View Slide

  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

    View Slide

  40. app.form.tags_type:
    class: AppBundleFormTagsType
    arguments: [‘@doctrine.orm.entity_manager’]
    tags:
    - { name: form.type }
    Then declare our form type as a service

    View Slide

  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
    }
    }

    View Slide

  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);
    }

    View Slide

  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;
    }

    View Slide

  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;
    }

    View Slide

  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
    }
    }

    View Slide

  46. class TagsType extends AbstractType implements ChoiceLoaderInterface, DataTransformerInterface
    {
    // ...
    public function transform($data)
    {
    if (empty($data)) {
    return '';
    }
    return implode(', ', $data);
    }
    }

    View Slide

  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

    View Slide

  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;
    }
    }

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  52. "block_prefixes" => array:4 [▼
    0 => "form"
    1 => "choice"
    2 => "tags"
    3 => "_post_tags"
    ]
    Let’s override ChoiceType view

    View Slide

  53. {% form_theme form _self %}


    {% block choice_widget %}

    {{ form_widget(form) }}

    {% endblock %}
    Let’s override ChoiceType view

    View Slide

  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?

    View Slide

  55. class PostType extends AbstractType
    {
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
    $builder
    // ...
    ->add('tags', TagsType::class)
    ;
    }
    }
    WOW! Simple again :)

    View Slide

  56. Mission complete!

    View Slide

  57. View Slide

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

    View Slide



  59. Just add this in your base template

    View Slide

  60. We need our input to get these attributes

    View Slide

  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

    View Slide

  62. Thank you!!

    View Slide