Dans ce tutoriel nous allons voir comment créer un système de Tags que l'on pourra associer à différents contenus. Cela sera l'occasion de découvrir la relation ManyToMany, mais aussi de voir la création d'un type de formulaire personnalisé. L'objectif est de permettre à l'utilisateur de rentrer les tags sous forme d'une simple liste de mots séparés par une virgule.

Les entités

La première étape est donc la création des entités. Pour les tags on va simplement définir la structure sans préciser de relations.

<?php
namespace Grafikart\TagBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;

/**
 * Tag
 *
 * @ORM\Table(name="tag")
 * @ORM\Entity(repositoryClass="Grafikart\TagBundle\Repository\TagRepository")
 */
class Tag
{
    /**
     * @var int
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(name="name", type="string", length=255)
     */
    private $name;

    // ... GETTER / SETTER

    public function __toString()
    {
        return $this->name;
    }
}

Par contre, sur les entités de notre application on va rajouter la relation avec les tags. Afin d'avoir un code réutilisable on pourra passer par la création d'un trait.

<?php
namespace Grafikart\TagBundle\Concern;

trait Taggable {

    /**
     * @var array
     *
     * @ORM\ManyToMany(targetEntity="Grafikart\TagBundle\Entity\Tag", cascade={"persist"})
     */
    private $tags;

    /**
     * Add tag
     *
     * @param \Grafikart\TagBundle\Entity\Tag $tag
     *
     * @return mixed
     */
    public function addTag(\Grafikart\TagBundle\Entity\Tag $tag)
    {
        $this->tags[] = $tag;

        return $this;
    }

    /**
     * Remove tag
     *
     * @param \Grafikart\TagBundle\Entity\Tag $tag
     */
    public function removeTag(\Grafikart\TagBundle\Entity\Tag $tag)
    {
        $this->tags->removeElement($tag);
    }

    /**
     * Get tags
     *
     * @return \Doctrine\Common\Collections\Collection
     */
    public function getTags()
    {
        return $this->tags;
    }

}

Le cascade={"persist"} permettra de persister automatiquement les tags associés à un article lors d'une mise à jour de ces derniers.

Le formulaire

Maintenant que la relation est définie, il faut être capable de renseigner les tags au niveau de nos articles. Par défaut, si vous rajoutez un champs pour les tags au niveau de votre FormBuilder, Symfony utilisera le type CollectionType qui se présentera comme un <select multiple> obligeant les utilisateurs à créer les tags en amont. On souhaite utiliser un simple champs texte.

On va donc commencer par créer un nouveau type de champs :

<?php
namespace Grafikart\TagBundle\Form\Type;

use Doctrine\Common\Persistence\ObjectManager;
use Grafikart\TagBundle\Form\DataTransformer\TagsTransformer;
use Symfony\Bridge\Doctrine\Form\DataTransformer\CollectionToArrayTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class TagsType extends AbstractType {

    /**
     * @var ObjectManager
     */
    private $manager;

    public function __construct(ObjectManager $manager)
    {
        $this->manager = $manager;
    }

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->addModelTransformer(new CollectionToArrayTransformer(), true)
            ->addModelTransformer(new TagsTransformer($this->manager), true);
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefault('attr', [
            'class' => 'tag-input'
        ]);
        $resolver->setDefault('required', false);
    }

    public function getParent (): string {
        return TextType::class;
    }

}

Quelques points importants :

  • Dans la méthode getParent() on précise que notre champs va se comporter comme un champs texte
  • Dans la méthode configureOptions() on rajoute une classe qui nous permettra de cibler le champs en question en JavaScript. De la même façon on retire l'attribut required par défaut vu qu'un article peut ne pas avoir de tags
  • Dans la méthode buildForm() on renseigne un nouveau transformer qui permettra de générer la liste des Tags depuis la chaine entée dans le champs.

Le TagsTransformer

Un transformer permet, comme son nom l'indique, de transformer les données dans un sens ou dans l'autre. Ce transformer aura donc 2 buts :

  • A partir d'une chaine rentrée dans le formulaire, le transformer devra construire un tableau de Tags.
  • A partir d'un tableau de tags, le transformer devra générer une chaine de caractère.
<?php
namespace Grafikart\TagBundle\Form\DataTransformer;

use Doctrine\Common\Persistence\ObjectManager;
use Grafikart\TagBundle\Entity\Tag;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;

class TagsTransformer implements DataTransformerInterface
{

    /**
     * @var ObjectManager
     */
    private $manager;

    public function __construct(ObjectManager $manager)
    {
        $this->manager = $manager;
    }

    public function transform($value): string
    {
        return implode(',', $value);
    }

    public function reverseTransform($string): array
    {
        $names = array_unique(array_filter(array_map('trim', explode(',', $string))));
        $tags = $this->manager->getRepository('TagBundle:Tag')->findBy([
            'name' => $names
        ]);
        $newNames = array_diff($names, $tags);
        foreach ($newNames as $name) {
            $tag = new Tag();
            $tag->setName($name);
            $tags[] = $tag;
        }
        return $tags;
    }
}

La méthode transform() est plutôt simple. La méthode reverseTransform() quant à elle va analyser les tags rentré par l'utilisateur et créer de nouvelles entités qui seront persistées gràce au cascade={"persist"}.