Symfony2: Comment créer une Contrainte de Validation Doctrine Personnalisée

Une des tâches les plus courantes que vous devez effectuer lors de la validation d’un formulaire est de vérifier les données d’un champ dans votre base de données comme le cas de vérification de l’unicité personnalisée.

Dans ce Tutorial, le but de notre contrainte est de vérifier qu’un code postal a été utilisé avec une autre entité.

La première étape est la création de la classe de la contrainte :

// src/Nm/CoreBundle/Validator/ZipCodeExist.php

namespace Nm\CoreBundle\Validator;

use Symfony\Component\Validator\Constraint;

/**
 * @Annotation
 */

class ZipCodeExist extends Constraint
{

    public $message = "Le code '%value%' n'est pas utilisé";
    public $entity;
    public $property;

    public function validatedBy()
    {
        return 'validator.zip.exist';
    }

    public function requiredOptions()
    {
        return array('entity', ' property');
    }

    public function targets()
    {
        return self::PROPERTY_CONSTRAINT;
    }
}

Comme vous pouvez voir, la déclaration de la classe est simple. Nous avons défini le message d’erreur et deux autres options nécessaires l’Entité et sa propriété. La méthode validatedBy() retourne le nom du service que nous allons déclarer juste après qui se chargera de valider la contrainte. Enfin, la fonction des targets() indique que la contrainte sera appliqué seulement sur les propriétés.

// src/Nm/CoreBundle/Validator/ZipCodeExistValidator.php

namespace Nm\CoreBundle\Validator;

use Doctrine\ORM\EntityManager;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;

class ZipCodeExistValidator extends ConstraintValidator
{

    private $entityManager;

    public function __construct(EntityManager $entityManager)
    {
        $this->entityManager = $entityManager;
    }

    public function validate($value, Constraint $constraint)
    {
        $entities = $this->entityManager->getRepository("CoreBundle:Restaurant")
                ->findByZipCode($value);
        
        if (empty($entities)) {
            
            $this->context->addViolation($constraint->message, array('%value%' => $value));

            return false;
        }

        return true;
    }

}

La méthode validate() vérifie la validité de la contrainte et renvoie par conséquent TRUE ou FALSE et ajoute le message d’erreur.

Ici, notre contrainte fonctionne contrairement à celle de symfony @UniqueEntity, dans notre cas la valeur est valide seulement lorsqu’elle est déjà utilisée dans l’entité Restaurant.

ZipCodeExistValidator a une dépendance c’est Doctrine EntityManager. Nous aurons besoin alors de le déclarer en tant que service :

<!-- src/Nm/CoreBundle/Resources/config/services.xml -->
    <parameters>
        <parameter key="nm.validator.zip.exist.class">Nm\CoreBundle\Validator\ZipCodeExistValidator</parameter>
    </parameters>
    <services>
        <service id="nm.validator.zip.exist" class="%nm.validator.zip.exist.class%">
            <argument type="service" id="doctrine.orm.entity_manager" />
            <tag name="validator.constraint_validator" alias="validator.zip.exist" />
        </service>
    </services>

La dernière étape est l’utilisation de notre validateur personnalisé, c’est très facile, tout comme ceux fournis par Symfony2 lui-même :

// src/Nm/CoreBundle/Entity/Address.php

use Symfony\Component\Validator\Constraints as Assert;
use Nm\CoreBundle\Validator as NmAssert;

class Address
{
    // ...


    /**
     * @var string
     * @Assert\Length(max="5", min="5", minMessage="Code postal invalide", maxMessage="Code postal invalide")
     * @Assert\Regex("/[0-9]{2}[0-9]{3}/", message="Code postal invalide")
     * 
     * @NmAssert\ZipCodeExist(message="Aucun de nos restaurants ne peux livré à cette adresse")
     * 
     * @ORM\Column(name="zip_code", length=5)
     */
    private $zipCode

    // ...
}

Symfony2: Connexion automatique avec FosUserBundle

En général, la connexion avec FosUserBundle se fait à travers un formulaire, mais récemment j’avais besoin d’une tâche pas assez récurrente, dans ce cas l’utilisateur peut se connecter automatiquement en cliquant sur un lien envoyé par email.

Tout d’abord, j’ai ajouté à ma entité User un attribut qui va jouer le rôle d’un jeton de connexion, ce jeton est généré automatiquement lors de la persiste de l’objet User via les events PrePersist et PreUpdate :


use FOS\UserBundle\Model\User as BaseUser;

/**
 * User
 *
 * @ORM\Table(name="users")
 * @ORM\Entity(repositoryClass="Nm\UserBundle\Repository\UserRepository")
 * @ORM\HasLifecycleCallbacks
 */
class User extends BaseUser
{
     // ...

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

    /**
     * @ORM\PrePersist()
     * @ORM\PreUpdate()
     */
    public function preUpload()
    {
        if (null === $this->loginToken) {
            $this->loginToken = sha1(uniqid(mt_rand(), true));
        }
    }
}

Après, j’ai créé l’action de la connexion automatique, sa route prendra en paramètre le loginToken :


use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;

//...

    /**
     * @Route("/auto-login/{loginToken}", name="auto_login")
     */
    public function autoLogin(Request $request, User $user)
    {
         $firewallName = $this->container->getParameter('fos_user.firewall_name');
        
        $token = new UsernamePasswordToken($user, $user->getPassword(), $firewallName, $user->getRoles());
        $this->get('security.context')->setToken($token);
        $request->getSession()->set('_security_main', serialize($token));
        $url = $this->generateUrl('fos_user_registration_confirmed');
        $response = new RedirectResponse($url);
                    
        return $response;
    }

[Symfony2] Réderiction aprés téléchargment d’un fichier

Il s’agit d’une petite astuce pour rediriger l’utilisateur après le téléchargement d’un fichier, on ajoute l’option « refresh » aux headers, voici un exemple avec Symfony2 :


//Fonction perso pour récupérer le contenue d'un fichier.
$content = $this->getFileContent($file);

$response = new Response();

$response->setContent($content);

$response->headers->set('Content-Type', "application/force-download");
$response->headers->set('Content-disposition', "filename=$file");
$response->headers->set('refresh', "1;".$this->generateUrl('homepage'));

return $response;

[Symfony2][DoctrineExtension] Créer des slugs pour les caractéres non latin (Arabe particulièrement).

Le comportement Sluggable de la bibliothèque Doctrine extensions permet de créer facilement des URLs SEO-Friendly.
La mise en place du bundle est facile et ne nécessite pas beaucoup de configuration.

Dans le cas des url non-latin tel que l’arabe, le chinois, ou le japonais, le behavior Sluggable translittère les mots en latin, par exemple le mot « إقتصاد » sera translittéré en « qtsd » ce qui n’a aucun sens.

Dans cet article, nous allons voir comment installer et configurer le Bundle StofDoctrineExtensions pour bien créer des slugs pour les langues non-latin.

Installation du StofDoctrineExtensionsBundle

Le bundle StofDoctrineExtensionsBundle intègre la bibliothèque DoctrineExtensions .
L’installation se fait à travers le gestionnaire des dépendances composer :

composer require "doctrine/doctrine-fixtures-bundle:dev-master"
composer require "stof/doctrine-extensions-bundle:dev-master"

Par la suite, nous activons le bundle dans le fichier AppKernel.php

// app/AppKernel.php
// ...

public function registerBundles()
{
    $bundles = array(
        // ...
        new Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle(),
    );

    // ...
}

À la fin, nous ajoutons la configuration de bundle à la fin de notre fichier config.yml :

stof_doctrine_extensions:
    default_locale: en_US
    orm:
        default:
            sluggable:   true

Utilisation du comportement Sluggable

Dans notre exemple, nous allons créer un slug pour la propriété « name » de notre entity Post.

// src/Nm/CoreBundle/Entity/Post.php
// ...

use Gedmo\Mapping\Annotation as Gedmo;
// ...

class Post
{
    // ...

    /**
     * @Gedmo\Slug(fields={"name"}, updatable=false)
     * @ORM\Column(length=255, unique=true)
     */
    protected $slug;
}

Ce code permet de créer automatiquement des slugs sur la propriété « name ».
En ajoutant « updatable=false », cela indiquera que le slug ne sera jamais mis à jour une fois créée, même si nous changons la valeur de « name ».
C’est une bonne idée parce que le slug sera utilisé dans l’URL de l’événement, que nous ne voulons pas changer.

Par la suite nous mettons à jour notre base de données :

php app/console  doctrine:schema:update --force

Si vous avez des anciennes données vous allez vous trouver avec une erreur de contrainte d’intégrité, il faut donc vider la table de ces données et relancer la mise à jour, pour cela on peut supprimer toutes les tables et les recréer à nouveau à travers la console de symfony pour contourner ce problème :

php app/console doctrine:schema:drop
php app/console doctrine:schema:create

À ce niveau, l’installation et la configuration du comportement sluggable sont terminées. Vous pouvez faire les tests et voir que tout est fonctionnel.

Adaptation du comportement Sluggable pour les url non-latin

Comme je vous ai dit au début, le but de cet article est d’adapter ce behavior pour créer des slugs pour les langues non-latin.

Beaucoup de CMS, tel-que Drupal et WordPress possède un système de slugify puissant.
Pour notre cas, nous allons utiliser les fonctions utf8_uri_encode et slugify de wordpress.

Nous commencons par créer un service personnalisé et par la suite nous l’activons pour surcharger celui par défaut de DoctrineExtensions.

Maintenant vous pouvez slugify votre url facilement en gardant votre langue par défaut. Dans le cas de l’Arabe le mot « مال و أعمال » sera « مال-و-أعمال ».

[Symfony 2] Implémentation du moteur de recherche Zend Lucene

Dans l’article précédant, nous avons vu comment installer EWZSearchBundle, le bundle d’intégration de Zend Lucene. La documentation officielle de bundle est très pauvre, donc pour un newbie, c’est difficile de se trouver. Dans cet article, on va voir en détails comment utiliser ce bundle.

Indexation

Pour pouvoir utiliser le moteur de recherche, on doit construire en premier un fichier d’index qui contiendra les textes qui seront utilisés dans les recherches.

Le fichier index sera enregistré sous le path définis dans la configuration de bundle. L’indexation sera faite au fur et à mesure de l’enregistrement des données.

Création d’un index

Pour ce tutorial, on va utiliser comme exemple l’Entity Poste :

class Poste
{
    /**
     * @var integer $id
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

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

    /**
     * @var text $body
     *
     * @ORM\Column(name="body", type="text")
     */
    private $body;
}

L’indexation d’un post est simple : la clé primaire est stockée pour un référencement ultérieur lors de la recherche des postes et les colonnes title et body sont indexées, mais pas stockées dans l’index, car nous allons utiliser les objets réels pour afficher les résultats.

Donc à chaque fois qu’un post est créé, modifié ou supprimé, l’index doit être mis à jour.
Nous allons modifiers les actions createAction, updateAction et deleteAction de notre controlleur PosteController

Tout d’abort, nous ajoutons les indépendances suivante au début de PosteController.php :

use EWZ\Bundle\SearchBundle\Lucene\Document;
use EWZ\Bundle\SearchBundle\Lucene\Field;

Par la suite, nous ajoutons une méthode de création des indexes, elle sera utilisé dans les actions createAction et updateAction.

Zend Lucene a 5 façons pour indexer des champs :

  • Keyword : Le champ est stocké ET indexé. Cela signifie qu’il peut être aussi bien cherché dans l’index qu’affiché dans les résultats de la recherche.
  • UnIndexed : Le champ ne peut pas être utilisé dans la recherche. En revanche, il peut être retourné dans les résultats.
  • Text : Le champ est stocké, indexé et « tokenizé » (devisé en plusieurs mots).
  • Binary : Le champ n’est ni « tokenizé », ni indexé, mais il est stocké dans le but d’être retourné dans les résultats de recherche.
  • UnStored : Le champ est « tokenizé » et indexé, mais pas stocké dans l’index..

Dans notre exemple, nous allons utilisé, seulement « Keyword » pour stoker l’id du Post, et « UnStored » pour enregistrer « title » et « body » du post.

Voici la méthode de création d’index :

private function createIndex($post)
{
    $search = $this->get('ewz_search.lucene');

    $document = new Document();
    $document->addField(Field::Keyword('key', $post->getId()));
    $document->addField(Field::UnStored('title', $post->getTitle()));
    $document->addField(Field::UnStored('body', $post->getBody()));

    $search->addDocument($document);
    $search->updateIndex();
}

Comme on a déjà dit, on ajoute cette fonction dans createAction et updateAction après la validation de formulaire comme suit :

        if ($form->isValid()) {
            $this->createIndex($entity);
            $em->persist($entity);
            $em->flush();

             return $this->redirect($this->generateUrl('posts_show', array(
                        'id' => $entity->getId()
                     )));
            $query = $form->getData();

        }

Lancer une recherchet

Maintenant que nous avons tout mis en place, nous passons à l’implémentation de l’action recherche, la méthode $this->get(‘ewz_search.lucene’)->find(), interroge l’index pour trouver des mots correspondantes à notre mot cherchée :

    /**
     * 
     * @param \Symfony\Component\HttpFoundation\Request $request
     * @Route("/search", name="search_poste")
     * @Template()
     */
    public function searchAction(Request $request)
    {
        $form = $this->createFormBuilder()->add('search', 'text')->getForm();

        $posts = new \Doctrine\Common\Collections\ArrayCollection;

        if($request->getMethod() === "POST") {

            $form->handleRequest($request);

            $query = $form->getData();

            $results = $this->get('ewz_search.lucene')->find($query['search']);

            $em = $this->getDoctrine()->getManager();

            foreach($results as $hit)
            {
                $document = $hit->getDocument();
                $post = $em->getRepository('BlobBundle:Poste')->find($document->key);
                $posts->add($post);
            }
        }

        return array(
            "posts" => $posts,
            "form" => $form->createView()
         );
    }

Voici une simple Template twig pour l’action :

<form action="{{ path("search_poste") }}" method="post" {{ form_enctype(form) }}>
    {{ form_errors(form) }}
    {{ form_rest(form) }}
    <input type="submit" />
</form>

{% if posts.count > 0 %}
    <h1>Search result</h1>
    <table class="records_list">
        <thead>
            <tr>
                <th>Id</th>
                <th>Title</th>
                <th>Body</th>
                <th>Actions</th>
            </tr>
        </thead>
        <tbody>
        {% for entity in posts %}
            <tr>
            <td><a href="{{ path('poste_show', { 'id': entity.id }) }}">{{ entity.id }}</a></td>
            <td>{{ entity.title }}</td>
            <td>{{ entity.body }}</td>
            <td>
                <ul>
                    <li>
                       <a href="{{ path('poste_show', { 'id': entity.id }) }}">show</a>
                    </li>
                    <li>
                        <a href="{{ path('poste_edit', { 'id': entity.id }) }}">edit</a>
                    </li>
                </ul>
            </td>
        </tr>
        {% endfor %}
        </tbody>
</table>
{% endif %}

Supprimer un index

On arrive à la fin, il nous reste que la mise à jours de l’index après la surpression d’un post, nous allons créer une methode deleteIndex:


    
    private function deleteIndex($post) 
    {
        $search = $this->get('ewz_search.lucene');

        $document = new Document();
        $document->addField(Field::Keyword('key', $post->getId()));

        $search->deleteDocument($document);
    }

Cet méthode sera appelé dans l’action deleteAction() juste après la suppression du poste :


        if ($form->isValid()) {
            $em = $this->getDoctrine()->getManager();
            $entity = $em->getRepository('BlobBundle:Poste')->find($id);

            if (!$entity) {
                throw $this->createNotFoundException('Unable to find Poste entity.');
            }

            $em->remove($entity);
            $em->flush();
            
            $this->deleteIndex($entity);
        }

[Symfony2] Collection des Bundles pour accélérer votre développement

Depuis la naissance de Symfony2, un grand nombre de bundles ont été créés pour faciliter la tâche au développeurs.
Voici ma collection des bundles indispensable pour chaqu’un de mes projets Symfony :

1. FOSUserBundle : un bundle pour la gestion des utilisateurs, il implémente beaucoup de fonctionnalités tel que inscription, authentification, récupération mot de passe …

2. DoctrineExtensions : un bundle très pratique intègre une série d’extensions Doctrine comme Sluggable, Translatable, Timetampable, Sortable …

3. KnpPaginatorBundle : un système de pagination simple d’utilisation et performant.

4. GenemuFormBundle : Bundle Symfony plein de FormType à insérer dans les formulaires. il facilite l’utilisation des champs de formulaire de type Select2, tinyMCE, ReCaptcha, Rating …

5. KnpMenuBundle : c’est un Bundle qui permet de gérer assez facilement les menus d’un site web. Faire un menu statique est très simple, mais parfois, le développeur a besoin de construire un menu (ou un sous-menu) en fonction de contenus en base de données. Ce Bundle va donc vous être très utile.

[Symfony2][FOSUserBundle] Utiliser l’adresse mail pour se connecter

FOSUserBundle a son propre provider pour que vous pouvez utiliser non seulement le nom d’utilisateur, mais aussi l’email pour ce connecter. Il suffit de changer une ligne dans security.yml


# app/config/security.yml
security:
    providers:
        fos_userbundle:
            id: fos_user.user_provider.username_email

Par contre si vous voulez utiliser seulement l’adresse mail pour se connecter, il faut passer par une autre méthode. Selon la documentation d’une ancienne version de FosUserBundle, la méthode le plus « propre » et la moins risquer et de passer par un provider personnalisé.


<?php
namespace FOS\UserBundle\Security;

class EmailUserProvider extends UserProvider
{
    protected function findUser($username)
    {
        return $this->userManager->findUserByUsernameOrEmail($username);
    }
}

Comme vous pouvez remarquer notre provider personnalisé hérite de celui de FosUserBundle, et on a surchargé seulement une seule méthode.

Maintenant, nous allons l’ajouter comme un service à notre application :


<parameters>
    <parameter key="gmu.user_provider.email.class">Gmu\UserBundle\Security\GmuUserProvider</parameter>
  </parameters>
  <services>
    <service id="gmu.user_provider.email">
      <argument type="service" id="fos_user.user_manager" />
    </service>
  </services>

Nous avons injecté FOSUserManager parce que la classe parente en a besoin. Après cela, nous indiquant qu’on va utiliser notre service comme le provider par défaut :


# app/config/security.yml
security:
    providers:
        fos_userbundle:
            id: gmu.user_provider.email