Share
Embed
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
-
Welcome to SymfonyCon Berlin 2016 Symfony Forms One use case,
many optimizations -
Haaave you met @webmozart? Bernhard Schussek is the core developer
of the Form Component https://speakerdeck.com/webmozart https://webmozart.io -
Haaave you met @webmozart? Bernhard Schussek is the core developer
of the Form Component https://speakerdeck.com/webmozart https://webmozart.io -
“Make simple cases simple, make complex cases possible” @webmozart, talking
about Form component -
MODELS FORMS Simple Complex Complex OK I got it WTF?
I thought I got it -
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 -
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 // ... } -
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, ]); } } -
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); // ... -
/** * @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; // ... } -
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); } } -
class Post { // ... /** * @ORM\ManyToMany( * targetEntity="AppBundle\Entity\Tag",
* inversedBy="posts", * cascade={"persist"} * ) */ private $tags; /* * @var Collection|Tag[] */ public function getTags() { return $this->tags; } // ... -
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); } } -
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); } } -
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 -
But what if a tag already exists or is modified?
/** * @ORM\Entity * @UniqueEntity(fields={"name"}) */ class Tag { // ... /** * @ORM\Column(type="string") * @NotBlank */ private $name; // ... } -
class PostType extends AbstractType { public function buildForm(...) { $builder
// ... ->add('tags', EntityType::class, [ 'class' => Tag::class, 'choice_label' => 'name', 'multiple' => true, ]) ; } } -
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, ]) ; } } -
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); } }) ; -
$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 -
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); -
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; } // ... } -
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; } // ... } -
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; } // ... } -
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); } } -
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; } } -
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 -
MODELS FORMS Simple Complex Complex OK I got it WTF?
I thought I got it -
MODELS FORMS Simple Complex Complex OK I got it WTF?
I thought I got it UX constraint -
Problem 3: mix both fields class PostType extends AbstractType {
public function buildForm(...) { $builder // ... ->add('current_tags', EntityType::class, [...]) ->add('new_tags', CollectionType::class, [...]) ; } } -
using FormType inheritance mechanism class TagsType extends AbstractType { public
function getParent() { return ChoiceType::class; } } Solution 3 -
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; } } -
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 -
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 -
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); } // ... -
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; } -
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); } -
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() ; } } -
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 } } -
class TagsType ... { // ... public function transform($data) {
if (empty($data)) { return ''; } return implode(', ', $data); } public function reverseTransform($value) { if (null === $value) { return []; } return explode(',', $value); } // ... -
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)); } -
Let’s override ChoiceType default configuration class TagsType ... { //
... public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'choice_loader' => $this, 'multiple' => true, ]); } // ... } -
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); } // ... } -
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); } // ... } -
Let’s override ChoiceType view "block_prefixes" => array:4 [▼ 0 =>
"form" 1 => "choice" 2 => "tags" 3 => "_post_tags" ] -
Let’s override ChoiceType view {% form_theme form _self %}
{% block choice_widget %} {{ form_widget(form) }} {% endblock %} -
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, ]) ; } } -
Solution 3: mix both fields class PostType extends AbstractType {
public function buildForm(FormBuilderInterface $builder, array $options) { $builder // ... ->add('tags', TagsType::class) ; } } -
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'] = ''; } // ... }