Ici, on attaque les choses sérieuses avec des events, envoi de mails, services, validateurs et surtout, découplage maximum et réutilisabilité du code.
L’inscription
Un utilisateur doit pouvoir s’inscrire. Pour ce faire, nous allons déjà commencer par créer notre entité Registration.php
<?php
namespace AppBundle\User\Registration;
use Symfony\Component\Validator\Constraints as Assert;
use AppBundle\Validator\Constraints as CustomAssert;
use AppBundle\Entity\User;
class Registration
{
/**
* @Assert\NotBlank()
* @CustomAssert\UniqueAttribute(
* repository="AppBundle\Entity\User",
* property="username"
* )
*/
private $username;
/**
* @Assert\NotBlank()
* @Assert\Length(min=8)
*/
private $password;
/**
* @Assert\NotBlank()
* @Assert\Email()
* @CustomAssert\UniqueAttribute(
* repository="AppBundle\Entity\User",
* property="email"
* )
*/
private $email;
/**
* @param string $username
*/
public function setUsername($username)
{
$this->username = $username;
}
/**
* @param string $email
*/
public function setEmail($email)
{
$this->email = $email;
}
/**
* @param string $password
*/
public function setPassword($password)
{
$this->password = $password;
}
/**
* @return string
*/
public function getUsername()
{
return $this->username;
}
/**
* @return string
*/
public function getEmail()
{
return $this->email;
}
/**
* @return string
*/
public function getPassword()
{
return $this->password;
}
/**
* @return User
*/
public function getUser()
{
$user = new User();
$user->setUsername($this->username);
$user->setEmail($this->email);
$user->setPlainPassword($this->password);
$user->setIsActive(true);
return $user;
}
}
Cette entité va nous servir pour le formulaire d’inscription. La fonction getUser trouvera son utilité un peu plus tard, lorsque le formulaire sera soumis.
Créer un validateur d’email et de username unique
Nous devons vérifier à l’enregistrement s’il n’existe pas en base un autre utilisateur ayant le même email ou le même username.
Cette vérification est possible dans le contrôleur, mais les validateurs de contrainte personnalisés sont faits pour ça.
<?php
namespace AppBundle\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Exception\MissingOptionsException;
/**
* @Annotation
*/
class UniqueAttribute extends Constraint
{
public $message = 'The %property% "%string%" is already in use.';
public $repository;
public $property;
public function __construct($options = null)
{
parent::__construct($options);
if (null === $this->repository || null === $this->property) {
throw new MissingOptionsException(sprintf('The options "repository" and "property" must be given for constraint %s', __CLASS__), array('repository', 'property'));
}
}
public function validatedBy()
{
return 'validator_unique_attribute';
}
}
<?php
namespace AppBundle\Validator\Constraints;
use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class UniqueAttributeValidator extends ConstraintValidator
{
/**
* @param ObjectManager $manager
*/
protected $manager;
/**
* @param ObjectManager $manager
*/
public function __construct(ObjectManager $manager)
{
$this->manager = $manager;
}
/**
* @param mixed $value
* @param Constraint $constraint
*/
public function validate($value, Constraint $constraint)
{
if(!$constraint instanceof UniqueAttribute) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__.'\UniqueAttribute');
}
// throws exception if not successful
$repository = $this->manager->getRepository($constraint->repository);
if(count($repository->findBy(array($constraint->property => $value)))) {
$this->context->addViolation(
$constraint->message,
array(
'%property%' => $constraint->property,
'%string%' => $value
)
);
}
}
}
Mettre à jour le fichier de services:
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<parameters>
<parameter key="app.validator_unique_attribute.class">AppBundle\Validator\Constraints\UniqueAttributeValidator</parameter>
</parameters>
<services>
<service id="app.validator_unique_attribute"
class="%app.validator_unique_attribute.class%">
<argument type="service" id="doctrine.orm.entity_manager" />
<tag name="validator.constraint_validator" alias="validator_unique_attribute"/>
</service>
</services>
</container>
Le RegistrationType
<?php
namespace AppBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class RegistrationType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('username', 'text');
$builder->add('email', 'email');
$builder->add('password', 'repeated', array(
'first_name' => 'password',
'second_name' => 'confirm',
'type' => 'password',
));
$builder->add('Register', 'submit');
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => 'AppBundle\User\Registration\Registration',
]);
}
public function getName()
{
return 'registration_form';
}
}
Le RegisterController
Il faut à présent soumettre ce formulaire, enregistrer l’utilisateur, lancer l’event d’envoi du mail. Il n’est pas question de mettre tout cela dans le contrôleur. Car bien évidemment, ceci serait possible:
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Symfony\Component\HttpFoundation\Request;
use AppBundle\Form\Type\RegistrationType;
use AppBundle\User\Registration\Registration;
/**
* Controller used to manage the application security.
* See http://symfony.com/doc/current/cookbook/security/form_login_setup.html.
*/
class RegisterController extends Controller
{
/**
* @Route("/register", name="security_register_form")
* @Method({"GET", "POST"})
*/
public function registerAction(Request $request)
{
$form = $this->createForm(new RegistrationType(), new Registration());
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$user = $form->getData()->getUser();
$factory = $this->container->get('security.encoder_factory');
$encoder = $factory->getEncoder($user);
$user->encodePassword($encoder);
$this->getDoctrine()->getManager()->persist($user);
$this->getDoctrine()->getManager()->flush();
// code de l'envoi de l'email
return $this->redirectToRoute('security_login_form');
}
return $this->render('security/register.html.twig', [
'form' => $form->createView(),
]);
}
}
Vous voyez à présent que la fonction getUser() de Registration nous permet de créer un User et de les setter les valeurs passées en POST sans avoir à le faire dans le contrôleur.
Nous faisons faire énormément de choses à notre fonction:
- Traitement des infos de la requête
- Encodage du mot de passe
- Persistance et flush du User créé avec les valeurs de la request
- Envoi de l’email
Il va falloir éclater toutes ces responsabilités en plusieurs fonctions. En effet, que se passerait-il si nous voulions faire d’autres choses qu’envoyer un mail (par exemple loguer l’enregistrement). De même, et si nous voulions que ce soit autre chose que l’entité User qui soit sauvée? Il faudrait revenir sur le contrôleur, et ce n’est pas une solution viable, car il faut au maximum éviter de revenir sur du code déjà écrit et testé.
Occupons-nous d’abord de créer un manager de User (qui le créera, le persistera et flushera, encodera le mot de passe, etc.) ainsi que son interface.
<?php
namespace AppBundle\User\Manager;
use AppBundle\Entity\UserInterface;
interface UserManagerInterface
{
/**
* @param UserInterface $user
*
* @return void
*/
public function createUser(UserInterface $user);
}
<?php
use Doctrine\Common\Persistence\ObjectManager;
use AppBundle\Entity\UserInterface;
use AppBundle\User\Manager\UserManagerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
class UserManager implements UserManagerInterface
{
/**
* @var ObjectManager
*/
protected $objectManager;
/**
* @var EncoderFactoryInterface
*/
protected $encoderFactory;
/**
* @var EventDispatcherInterface
*/
protected $dispatcher;
/**
* @param ObjectManager $manager
* @param EncoderFactoryInterface $encoderFactory
* @param EventDispatcherInterface $dispatcher
*/
public function __construct(
ObjectManager $manager,
EncoderFactoryInterface $encoderFactory,
EventDispatcherInterface $dispatcher
) {
$this->objectManager = $manager;
$this->encoderFactory = $encoderFactory;
$this->dispatcher = $dispatcher;
}
/**
* @param UserInterface $user
*
* @return UserInterface
*/
public function createUser(UserInterface $user)
{
$user->encodePassword($this->encoderFactory->getEncoder($user));
$this->persistAndFlushUser($user);
}
/**
* @param UserInterface $user
*/
private function persistAndFlushUser(UserInterface $user)
{
$this->objectManager->persist($user);
$this->objectManager->flush();
}
}
Vous remarquerez que c’est UserInterface qui est passé en argument des fonctions createUser et persistAndFlushUser, et non User, ceci toujours dans le but de respecter la norme O de SOLID. Nous pouvons ici passer n’importe quelle instance d’objet d’une classe qui implémente UserInterface.
Ici, je passe déjà le dispatcher pour lancer l’événement de l’envoi d’e-mail (nous allons créer cet événement plus tard)
Du côté du services.xml
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<parameters>
<parameter key="app.validator_unique_attribute.class">AppBundle\Validator\Constraints\UniqueAttributeValidator</parameter>
<parameter key="app.user.manager.class">AppBundle\User\Manager\UserManager</parameter>
</parameters>
<services>
<service id="app.validator_unique_attribute"
class="%app.validator_unique_attribute.class%">
<argument type="service" id="doctrine.orm.entity_manager" />
<tag name="validator.constraint_validator" alias="validator_unique_attribute"/>
</service>
<service id="app.user.manager"
class="%app.user.manager.class%">
<argument type="service" id="doctrine.orm.entity_manager" />
<argument type="service" id="security.encoder_factory" />
<argument type="service" id="event_dispatcher" />
</service>
</services>
</container>
Le code de mon contrôleur RegisterController devient ceci:
public function registerAction(Request $request)
{
$form = $this->createForm(new RegistrationType(), new Registration());
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->container->get('app.user.manager')->createUser($form->getData()->getUser());
// code de l'envoi de l'email
return $this->redirectToRoute('security_login_form');
}
return $this->render('security/register.html.twig', [
'form' => $form->createView(),
]);
}
Mais c’est loin d’être suffisant. Notre code est toujours fortement couplé avec une entité de UserInterface (et si je veux enregistrer autre chose qu’une instance de cette classe?)… et le contrôleur peut encore être allégé. Que cela ne tienne, nous allons créer un RegisterFormHandler qui implémentera une FormHandlerInterface.
Le FormHandleInterface
Elle ne comporte qu’une seule méthode, handle, qui prend en paramètre FormInterface, une request et un tableau d’options qui peut être vide.
<?php
namespace AppBundle\Form\Handler;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
interface FormHandlerInterface
{
/**
* handles the form
*
* @param FormInterface $form
* @param Request $request
* @param array $options
*/
public function handle(FormInterface $form, Request $request, array $options = null);
}
Le RegisterFormHandler
<?php
namespace AppBundle\User\Registration;
use AppBundle\Form\Handler\FormHandlerInterface;
use AppBundle\User\Manager\UserManagerInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
class RegistrationFormHandler implements FormHandlerInterface
{
/**
*
* @var UserManagerInterface
*/
private $handler;
/**
* @param UserManagerInterface $userManager
*/
public function __construct(UserManagerInterface $userManager)
{
$this->handler = $userManager;
}
/**
* @param FormInterface $form
* @param Request $request
* @param array $options
*
* @return bool
*/
public function handle(FormInterface $form, Request $request, array $options = null)
{
$form->handleRequest($request);
if (!$form->isValid()) {
return false;
}
$this->handler->createUser($form->getData()->getUser());
return true;
}
}
Plutôt simple, celui-ci va implémenter FormHandlerInterface, traiter la requête et appeler une fonction du UserManager pour créer le user. Sa fonction handle nous renvoie un booléen pour nous dire si la requête était bien valide.
Mise à jour du services.xml
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<parameters>
<parameter key="app.validator_unique_attribute.class">AppBundle\Validator\Constraints\UniqueAttributeValidator</parameter>
<parameter key="app.user.manager.class">AppBundle\User\Manager\UserManager</parameter>
<parameter key="app.registration.handler.class">AppBundle\User\Registration\RegistrationFormHandler</parameter>
</parameters>
<services>
<service id="app.validator_unique_attribute"
class="%app.validator_unique_attribute.class%">
<argument type="service" id="doctrine.orm.entity_manager" />
<tag name="validator.constraint_validator" alias="validator_unique_attribute"/>
</service>
<service id="app.user.manager"
class="%app.user.manager.class%">
<argument type="service" id="doctrine.orm.entity_manager" />
<argument type="service" id="security.encoder_factory" />
<argument type="service" id="event_dispatcher" />
</service>
<service id="app.registration.handler"
class="%app.registration.handler.class%">
<argument type="service" id="app.user.manager" />
</service>
</services>
</container>
Mise à jour du RegisterController
Et là, notre registerAction s’allège encore :
public function registerAction(Request $request)
{
$form = $this->createForm(new RegistrationType(), new Registration());
if ($this->getRegistrationFormHandler()->handle($form, $request)) {
return $this->redirect($this->generateUrl('security_login_form'));
}
return $this->render('security/register.html.twig', [
'form' => $form->createView(),
]);
}
/**
* @return \AppBundle\Form\Handler\FormHandlerInterface
*/
protected function getRegistrationFormHandler()
{
return $this->container->get('app.registration.handler');
}
Les events
Il nous reste une dernière chose à faire: créer un mail pour informer l’utilisateur que son compte a bien été créé et qu’il peut à présent se connecter sur la plateforme.
Inutile de vous dire que nous n’allons pas faire cela dans le contrôleur. C’est le userManager qui va se charger de dispatcher l’événement et nous allons dire à Symfony que lorsque cette événement est dispatché, il faut que tous ses listeners qui sont branchés dessus exécutent une action en particulier (c’est le principe du Design Pattern Observer). Pour cela, Symfony propose deux systèmes: les listeners et les subscribers. Ceci fera l’objet d’un autre article.
Créer un évent
Il s’agit simplement d’une classe concernant l’entité dont nous voulons récupérer des infos (le sujet, celui qui est observé par les listeners, en a besoin). Je vais simplement l’appeler UserDataEvent (mais vous pouvez lui donner le nom de l’événement, comme NewAccountUserEvent par exemple, mais je vais rester générique ici)
<?php
namespace AppBundle\Event;
use Symfony\Component\EventDispatcher\Event;
use Symfony\Component\Security\Core\User\UserInterface;
class UserDataEvent extends Event
{
/**
* @var UserInterface
*/
protected $user;
/**
* @param UserInterface $user
*/
public function __construct(UserInterface $user)
{
$this->user = $user;
}
/**
* @return UserInterface
*/
public function getUser()
{
return $this->user;
}
}
Etablir la liste des events de l’application
Ceci se faire dans un fichier et est, en ce qui me concerne, une bonne pratique pour connaître tous les events de votre application.
<?php
namespace AppBundle;
final class AppEvents
{
const NEW_ACCOUNT_CREATED = 'app.new_account_created';
}
Dispatcher l’événement
La fonction createUser du UserManager peut à présent dispatcher l’événement:
public function createUser(UserInterface $user)
{
$user->encodePassword($this->encoderFactory->getEncoder($user));
$this->persistAndFlushUser($user);
$this->dispatcher->dispatch(
AppEvents::NEW_ACCOUNT_CREATED,
new UserDataEvent($user)
);
}
Créer le listener d’envoi de mail
Il va prendre en argument switft_mailer, le templating de twig, un template et une adresse mail:
<?php
namespace AppBundle\EventListener;
use AppBundle\Event\UserDataEvent;
use Twig_Environment;
class SendConfirmationMailListener
{
/**
* @var \Swift_Mailer
*/
protected $mailer;
/**
* @var \Twig_Environment
*/
protected $templating;
/**
* @var array
*/
protected $template;
/**
* @var string $from
*/
protected $from;
/**
* @param \Swift_Mailer $mailer
* @param Twig_Environment $templating
* @param $template
* @param $from
*/
public function __construct(\Swift_Mailer $mailer, Twig_Environment $templating, $template, $from)
{
$this->mailer = $mailer;
$this->templating = $templating;
$this->template = $template;
$this->from = $from;
}
/**
* @param UserDataEvent $event
*/
public function onNewAccountCreated(UserDataEvent $event)
{
$message = \Swift_Message::newInstance()
->setCharset('UTF-8')
->setSubject($this->templating->loadTemplate($this->template)->renderBlock('subject', []))
->setFrom($this->from)
->setTo($event->getUser()->getEmail())
->setBody($this->templating->loadTemplate($this->template)->renderBlock('body', [
'username' => $event->getUser()->getUsername()
])
);
$this->mailer->send($message);
}
}
Vous pourriez vous demander comment la méthode onNewAccountCreated peut récupérer les informations de l’event? C’est simplement lors du dispatch: nous avons passé un second argument qui était ce fameux UserDataEvent.
Création du mail
Nous définissons ici deux blocks qui sont renseignés dans la fonction onNewAccountCreated:
{# src/AppBundle/Ressources/views/Mail #}
{% block subject %}Welcome{% endblock %}
{% block body %}
Hello {{ username }}
{% endblock %}
Enregistrement du listener au niveau du fichier services.xml
A présent que nous dispatchons l’événement à la création d’un utilisateur et que nous avons créé l’événement en question, il est temps de raccorder l’écouteur à celui qui l’informe par le fichier des services:
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<parameters>
<parameter key="app.user.manager.class">AppBundle\User\Manager\UserManager</parameter>
<parameter key="app.validator_unique_attribute.class">AppBundle\Validator\Constraints\UniqueAttributeValidator</parameter>
<parameter key="app.registration.handler.class">AppBundle\User\Registration\RegistrationFormHandler</parameter>
<parameter key="app.send_confirmation_mail_listener.class">AppBundle\EventListener\SendConfirmationMailListener</parameter>
</parameters>
<services>
<service id="app.validator_unique_attribute"
class="%app.validator_unique_attribute.class%">
<argument type="service" id="doctrine.orm.entity_manager" />
<tag name="validator.constraint_validator" alias="validator_unique_attribute"/>
</service>
<service id="app.send_confirmation_mail_listener"
class="%app.send_confirmation_mail_listener.class%">
<argument type="service" id="mailer"/>
<argument type="service" id="twig" />
<argument>AppBundle:Mail:confirmation_mail.html.twig</argument>
<argument>%mail_from%</argument>
<tag name="kernel.event_listener" event="app.new_account_created" method="onNewAccountCreated" />
</service>
<service id="app.user.manager"
class="%app.user.manager.class%">
<argument type="service" id="doctrine.orm.entity_manager" />
<argument type="service" id="security.encoder_factory" />
<argument type="service" id="event_dispatcher" />
</service>
<service id="app.registration.handler"
class="%app.registration.handler.class%">
<argument type="service" id="app.user.manager" />
</service>
</services>
</container>
Ici, nous indiquons à Symfony que nous avons un listener (avec le tag kernel.event_listener), qui se déclenche à l’événement app.new_account_created, et que nous voulons exécuter la méthode onNewAccountCreated lorsqu’il est dispatché.
Ici, mail_from est un paramètre que j’ai rajouté dans parameters.yml.
Le template
{% extends 'base.html.twig' %}
{% form_theme form "form_table_layout.html.twig" %}
{% block body_id 'registration' %}
{% block main %}
<h1>Registration</h1>
{{ form(form) }}
{% endblock %}
Le code github se trouve ici: https://github.com/jpsymfony/authentication-demo
Sa version mise à jour (il faudra chercher un peu pour retrouver les classes car l’arborescence a changé) en Symfony 3.2 est ici: https://github.com/jpsymfony/symfony3-generic-project-symfony3-architecture

Commentaires