Symfony2 + FOSUserBundle – Comment étendre la commande par défaut de création d’un utilisateur

La gestion des utilisateurs dans un projet symfony2 est une tâche répétitive qu’on confie la plupart du temps à un bundle qui gère parfaitement bien la chose : FOSUserBundle.
Le bundle fournit une couche d’abstraction via les UserManager et GroupManager qui permet au développeur de stocker ses utilisateurs via Doctrine ORM, MongoDB / CouchDB ODM ou encore via Propel.
Il fournit également tout un lot de commandes bien utiles, et notamment une qui permet d’ajouter en CLI un utilisateur en base en lui définissant ses attributs obligatoires. On peut éventuellement lui ajouter ou révoquer un ou plusieurs rôles, également via des commandes dédiées.

Une entité User de base est définie dans le bundle, entité que le développeur s’empresse en général de modifier pour lui rajouter les attributs nécessaires à son application.
Les champs email et username sont déjà obligatoires, on souhaite souvent rajouter par exemple :

  • Firstname
  • Lastname

On modifie alors notre entité User et on se retrouve à quelque chose du genre :

<?php

namespace Acme\AcmeUserBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use FOS\UserBundle\Entity\User as BaseUser;

/**
 * @ORM\Table(name="users")
 */
class User extends BaseUser
{
    /**
     * @var integer $id
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected  $id;

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

    /**
     * @var string $first_name
     *
     * @ORM\Column(name="first_name", type="string", length=255)
     */
    private $first_name;
    
    // ...
}
&#91;/sourcecode&#93;

Dans mon exemple, les attributs <strong>firstname</strong> et <strong>lastname</strong> sont mappés sur des champs not nullable.
Et c'est justement le fait que ces champs soient requis qui va nous poser problème lors de l'exécution de la commande de création d'un nouvel utilisateur.

<code>> php app/console fos:user:create
Please choose a username:test
Please choose an email:test@test.fr
Please choose a password:testtest
</code>

Et on se choppe, c'est normal, une grosse exception :

<code>[Doctrine\DBAL\DBALException]
[PDOException] 
SQLSTATE[23000]: Integrity constraint violation: 1048 Column 'last_name' cannot be null
</code>

La solution est donc de surcharger la commande du bundle pour y ajouter les contraintes propres à notre application, à savoir, la saisie du prénom et du nom de famille de l'utilisateur.
La commande <strong>CreateUser</strong> se trouve dans <code>FOS\UserBundle\Command\CreateUserCommand</code>.
On commence donc par créer la notre, dans notre propre UserBundle en la faisant étendre de celle du FOSUserBundle :


<?php

namespace Acme\AcmeUserBundle\Command;

use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use FOS\UserBundle\Command\CreateUserCommand as BaseCommand;

class CreateUserCommand extends BaseCommand
{
    // ...
}
&#91;/sourcecode&#93;

On souhaite rajouter deux nouveaux arguments obligatoires dans le configure et on le fait de la manière suivante :

&#91;sourcecode language="php"&#93;
<?php

// ...
class CreateUserCommand extends BaseCommand
{
    /**
     * @see Command
     */
    protected function configure()
    {
        parent::configure();
        $this
            ->setName('acme:user:create')
            ->getDefinition()->addArguments(array(
                new InputArgument('firstname', InputArgument::REQUIRED, 'The firstname'),
                new InputArgument('lastname', InputArgument::REQUIRED, 'The lastname')
            ))
        ;
        $this->setHelp(<<<EOT
// L'aide qui va bien
EOT
            );
    }

    // ...

    protected function interact(InputInterface $input, OutputInterface $output)
    {
        parent::interact($input, $output);
        if (!$input->getArgument('firstname')) {
            $firstname = $this->getHelper('dialog')->askAndValidate(
                $output,
                'Please choose a firstname:',
                function($firstname) {
                    if (empty($firstname)) {
                        throw new \Exception('Firstname can not be empty');
                    }

                    return $firstname;
                }
            );
            $input->setArgument('firstname', $firstname);
        }
        if (!$input->getArgument('lastname')) {
            $lastname = $this->getHelper('dialog')->askAndValidate(
                $output,
                'Please choose a lastname:',
                function($lastname) {
                    if (empty($lastname)) {
                        throw new \Exception('Lastname can not be empty');
                    }

                    return $lastname;
                }
            );
            $input->setArgument('lastname', $lastname);
        }
    }
    // ...
}

Et enfin, on surcharge la méthode execute. FOSUserBundle utiliser la classe UserManipulator via le service user_manipulator.
Libre à vous de créer votre propre UserManipulator ou de placer votre code de création directement au sein de la méthode, en passant bien par le UserManager, comme ceci :

<?php
    // ...

    /**
     * @see Command
     */
    protected function execute(InputInterface $input, OutputInterface $output)
    {
        /** @var \FOS\UserBundle\Model\UserManager $user_manager */
        $user_manager = $this->getContainer()->get('fos_user.user_manager');

        /** @var \Acme\AcmeUserBundle\Entity\User $user */
        $user = $user_manager->createUser();
        $user->setUsername($input->getArgument('username'));
        $user->setEmail($input->getArgument('email'));
        $user->setPlainPassword($input->getArgument('password'));
        $user->setEnabled(!$input->getOption('inactive'));
        $user->setSuperAdmin((bool)$input->getOption('super-admin'));
        $user->setFirstName($input->getArgument('firstname'));
        $user->setLastName($input->getArgument('lastname'));

        $user_manager->updateUser($user);

        $output->writeln(sprintf('Created user <comment>%s</comment>', $username));
    }
    // ...
}

Ci-dessous, la commande complète, à adapter selon vos besoins :

<?php

namespace Acme\AcmeUserBundle\Command;

use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use FOS\UserBundle\Model\User;
use FOS\UserBundle\Command\CreateUserCommand as BaseCommand;

class CreateUserCommand extends BaseCommand
{
    /**
     * @see Command
     */
    protected function configure()
    {
        parent::configure();
        $this
            ->setName('acme:user:create')
            ->getDefinition()->addArguments(array(
                new InputArgument('firstname', InputArgument::REQUIRED, 'The firstname'),
                new InputArgument('lastname', InputArgument::REQUIRED, 'The lastname')
            ))
        ;
        $this->setHelp(<<<EOT
// L'aide qui va bien
EOT
            );
    }

    /**
     * @see Command
     */
    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $username   = $input->getArgument('username');
        $email      = $input->getArgument('email');
        $password   = $input->getArgument('password');
        $firstname  = $input->getArgument('firstname');
        $lastname   = $input->getArgument('lastname');
        $inactive   = $input->getOption('inactive');
        $superadmin = $input->getOption('super-admin');

        /** @var \FOS\UserBundle\Model\UserManager $user_manager */
        $user_manager = $this->getContainer()->get('fos_user.user_manager');

        /** @var \Acme\AcmeUserBundle\Entity\User $user */
        $user = $user_manager->createUser();
        $user->setUsername($username);
        $user->setEmail($email);
        $user->setPlainPassword($password);
        $user->setEnabled((Boolean) !$inactive);
        $user->setSuperAdmin((Boolean) $superadmin);
        $user->setFirstName($firstname);
        $user->setLastName($lastname);

        $user_manager->updateUser($user);

        $output->writeln(sprintf('Created user <comment>%s</comment>', $username));
    }

    /**
     * @see Command
     */
    protected function interact(InputInterface $input, OutputInterface $output)
    {
        parent::interact($input, $output);
        if (!$input->getArgument('firstname')) {
            $firstname = $this->getHelper('dialog')->askAndValidate(
                $output,
                'Please choose a firstname:',
                function($firstname) {
                    if (empty($firstname)) {
                        throw new \Exception('Firstname can not be empty');
                    }

                    return $firstname;
                }
            );
            $input->setArgument('firstname', $firstname);
        }
        if (!$input->getArgument('lastname')) {
            $lastname = $this->getHelper('dialog')->askAndValidate(
                $output,
                'Please choose a lastname:',
                function($lastname) {
                    if (empty($lastname)) {
                        throw new \Exception('Lastname can not be empty');
                    }

                    return $lastname;
                }
            );
            $input->setArgument('lastname', $lastname);
        }
    }
}

Symfony2 – Doctrine Sortable Behaviour

Inclure le bundle sf2 de stof dans son composer

Commencer par ajouter cette petite ligne dans votre composer :

"require": {
...
"stof/doctrine-extensions-bundle": "dev-master",
...

Activation du bundle dans le fichier AppKernel

Composer update , puis rajouter ce nouveau bundle dans l’AppKernel :

<?php

use Symfony\Component\HttpKernel\Kernel;
use Symfony\Component\Config\Loader\LoaderInterface;

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

    return $bundles;
  }
}
&#91;/sourcecode&#93;

<h2>Ajout des annotations</h2>

Ensuite, on ajoute les annotations qui vont bien :


<?php
namespace Entity;

use Gedmo\Mapping\Annotation as Gedmo;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Table(name="items")
 * @ORM\Entity(repositoryClass="Gedmo\Sortable\Entity\Repository\SortableRepository")
 */
class Item
{
    /** @ORM\Id @ORM\GeneratedValue @ORM\Column(type="integer") */
    private $id;

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

    /**
     * @Gedmo\SortablePosition
     * @ORM\Column(name="position", type="integer")
     */
    private $position;

    /**
     * @Gedmo\SortableGroup
     * @ORM\Column(name="category", type="string", length=128)
     */
    private $category;

    public function getId()
    {
        return $this->id;
    }

    public function setName($name)
    {
        $this->name = $name;
    }

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

    public function setPosition($position)
    {
        $this->position = $position;
    }

    public function getPosition()
    {
        return $this->position;
    }

    public function setCategory($category)
    {
        $this->category = $category;
    }

    public function getCategory()
    {
        return $this->category;
    }
    
    // ...
}

Ajout du listener

Et pour que tout cela fonctionne correctement, ne pas oublier d’ajouter l’event subscriber à notre event manager dans la méthode Boot de notre bundle.
Sinon, la mise à jour du champ position n’est jamais faite…
Si vous avez oublié de surcharger la méthode boot de votre bundle et que vous avez tenté d’utiliser la classe SortableRepository, une belle exception devrait vous avoir mis la puce à l’oreille :

This repository can be attached only to ORM sortable listener

<?php

namespace Acme\AcmeBundle;

use Symfony\Component\HttpKernel\Bundle\Bundle;

class AcmeAcmeBundle extends Bundle
{
  public function boot()
  {
    // get the doctrine 2 entity manager
    $em = $this->container->get('doctrine.orm.default_entity_manager');

    // get the event manager
    $evm = $em->getEventManager();
    $evm->addEventSubscriber(new \Gedmo\Sortable\SortableListener);
  }
}

Pour des exemples d’utilisation, RDV ici : http://gediminasm.org/article/sortable-behavior-extension-for-doctrine2

Symfony2 – Génération dynamique de formulaire en fonction de l’entitée liée

La génération dynamique de formulaires est assez peu documentée sur le site de symfony2.
Une entrée dans le cookbook nous permet quand même de se mettre sur la bonne voie. Je vous invite à la lire à cette adresse.

Attention, la toute prochaine version 2.1 de symfony introduit des changements notables dans le framework de formulaire. Une rétrocompatibilité temporaire est assurée avec la version 2.0 mais plusieurs méthodes sont aujourd’hui marquées deprecated. Par exemple, la méthode getDefaultsOptions est désormais obsolète et on doit surcharger setDefaultOptions qui prend en paramètre un objet de type OptionsResolverInterface.

J’utilise personnellement la version 2.1 du master et les extraits de codes ne sont pas tous exploitables tel quel sur une version 2.0 stable.

Le besoin

L’objectif est de pouvoir construire un formulaire et d’y ajouter ou non un champ en fonction de l’entitée qui est passée au moment de l’instanciation du formulaire.

Par exemple, si on construit un formulaire d’édition d’une entité catégorie, on veut pouvoir ajouter un champ entity pour nous permettant de lister les catégories parentes sélectionnables pour la catégorie en cours d’édition. Par contre, cette liste ne doit pas être affichée si notre entitée n’a pas de catégorie parente (si elle est à la racine de notre arbo par exemple).

On veut également pouvoir filtrer les catégories qui s’affichent dans notre liste, de manière à n’afficher que les catégories qui ont un niveau hiérarchiques supérieur à la catégorie en cours d’édition, histoire de ne pas affecter à notre catégorie un parent lui même enfant de notre catégorie.

Implémentation

La solution à ce type de problématique est de passer par l’ajout d’un event listener au sein de la méthode buildForm(...). C’est ce listener qui va nous permettre de récupérer l’entitée injectée dans notre formulaire via le controller.

// src/Acme/AcmeBundle/Form/Type/CategoryType.php
$builder->addEventListener(FormEvents::PRE_SET_DATA, function(FormEvent $event) use ($builder) {
  /** @var Category $category */
  $category = $event->getData();
  // Si on n'a pas accès à l'entitée liée ou si notre entitée n'a pas de catégorie parente, on n'ajoute pas d'autre champ.
  if (!$category instanceof Category || !$category->getParent()) {
      return;
  }
  // On ajoute un nouveau champ de type entity pour sélectionner la catégorie parente.
  $event->getForm()->add($builder->getFormFactory()->createNamed('parent', 'entity', null, array(
    'class' => 'AcmeAcmeBundleBundle:Category',
    'required' => false,
    'query_builder' => function(EntityRepository $er) use ($category) {
      return $er->createQueryBuilder('c')
        ->where('c.level <= :level') // On ne veut récupérer que les catégories qui sont au dessus de celle en cours d'édition.
        ->setParameter('level', $category->getParent()->getLevel());
    }
  )));
});

Ci-dessous, le code source complet de ma classe de gestion du formulaire d’édition de catégorie :

// src/Acme/AcmeBundle/Form/Type/CategoryType.php
namespace Acme\AcmeBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormBuilderInterface;
use Doctrine\ORM\EntityRepository;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;

use Acme\AcmeBundle\Entity\Category;

class CategoryType extends AbstractType
{
  /**
   * @param \Symfony\Component\OptionsResolver\OptionsResolverInterface $resolver
   */
  public function setDefaultOptions(OptionsResolverInterface $resolver)
  {
    $resolver->setDefaults(array(
      'data_class' => 'Acme\AcmeBundle\Entity\Category',
    ));
  }

  /**
   * @param \Symfony\Component\Form\FormBuilderInterface $builder
   * @param array $options
   */
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    $builder->add('title', 'text');
    $builder->addEventListener(FormEvents::PRE_SET_DATA, function(FormEvent $event) use ($builder) {
      /** @var Category $category */
      $category = $event->getData();
      // Si on n'a pas accès à l'entitée liée ou si notre entitée n'a pas de catégorie parente, on n'ajoute pas d'autre champ.
      if (!$category instanceof Category || !$category->getParent()) {
          return;
      }
      // On ajoute un nouveau champ de type entity pour sélectionner la catégorie parente.
      $event->getForm()->add($builder->getFormFactory()->createNamed('parent', 'entity', null, array(
        'class' => 'AcmeAcmeBundleBundle:Category',
        'required' => false,
        'query_builder' => function(EntityRepository $er) use ($category) {
          return $er->createQueryBuilder('c')
            ->where('c.level <= :level') // On ne veut récupérer que les catégories qui sont au dessus de celle en cours d'édition.
            ->setParameter('level', $category->getParent()->getLevel());
        }
      )));
    });
  }

  /**
   * @return string
   */
  public function getName()
  {
    return 'category';
  }
}

Attention, la fonction anonyme utilisée au sein de la méthode addEventListener prend en entrée un FormEvent et non pas un DataEvent comme dans la version 2.0 de Symfony.

En espérant que cela puisse être utile à certains…

Configuration GIT (utilisateur, adresse email, éditeur par défaut…)

Juste parce que je ne m’en rappelle jamais !

# Configuration du user
git config user.name "Olivier Balais"
git config user.email obalais@overnetcity.com

# Divers
git config core.editor "vim"
git config core.fileMode false
git config --add color.ui true
git config push.default current

# Configuration des alias
git config alias.st status

Une commande bien utile lorsqu’on a oublié de configurer son user et qu’on a déjà fait un ou plusieurs commits :

git commit --amend --author "Olivier Balais <obalais@overnetcity.com>"

Et rajouter l’option --global pour que la configuration soit commune à tous les dépôts :

# Configuration du user
git config --global user.name "Olivier Balais"
git config --global user.email obalais@overnetcity.com

# Divers
git config --global core.editor "vim"
git config --global core.fileMode false
git config --add color.ui true
git config --global push.default current

# Configuration des alias
git config --global alias.st status