Symfony : connaître tous les rôles d’un utilisateur (selon la hiérarchie configuré)

Exemple rapide

namespace App\Security\Voter;

use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Role\RoleHierarchyInterface;

class UserVoter extends Voter
{

    public const EDIT = 'USER_EDIT';

    public function __construct(private RoleHierarchyInterface $roleHierarchy) {
    }

    protected function supports(string $attribute, $subject): bool
    {
        return \in_array($attribute, [self::EDIT, self::TRANSFER, self::DELETE, self::SEE_NDE]) && $subject instanceof User;
    }

    protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
    {
        /** @var User $user */
        $user = $token->getUser();
        //
        $subjectRoles = $this->roleHierarchy->getReachableRoleNames($subject->getRoles());
        $userRoles = $this->roleHierarchy->getReachableRoleNames($user->getRoles());
        //je ne peux pas modifier un utilisateur ayant plus de rôles que moi
        if(count($subjectRoles) > count($userRoles)) {
            return false;
        }
        return true;
    }
}

Symfony : Se débarrasser de « sensio/framework-extra-bundle »

Note rapide

remplacer les

use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;

par

use Symfony\Component\Security\Http\Attribute\IsGranted;

et l’utilisation

* @IsGranted("editProject", subject="entity")

par

#[IsGranted('editProject', 'entity')]

remplacer les

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Entity;

par

use Symfony\Bridge\Doctrine\Attribute\MapEntity;

et l’utilisation

/**
* @Entity("project", expr="repository.findDetails(id)")
*/

public function emptyElements(Project $entity)

par

public function emptyElements(
        #[MapEntity(disabled: true, expr: 'repository.findDetails(id)')]
       Project $entity
        )

pour les paramConverter, ce cas peut etre supprimé directement

* @ParamConverter("day", options={"format": "Y-m-d"})

Une fois tous les élements de ce type supprimé/remplacer on peux supprimer le sensio/framework-extra-bundle du fichier composer.json et mettre à jour

Symfony 6.1 : Corriger la dépréciation « The « Symfony\Component\Console\Command\Command::$defaultName » property is considered final…

Depuis Symfony 6.1, si vous avez des commandes sous la forme suivante :

class MaCommandeCustomCommand extends Command {

    protected static $defaultName = 'app:ma-commande-custom';
    ...
}

Vous allez remarquer les dépréciations suivantes :
– The « Symfony\Component\Console\Command\Command::$defaultName » property is considered final. You should not override it in « … ».
– Since symfony/console 6.1: Relying on the static property « $defaultName » for setting a command name is deprecated. Add the « Symfony\Component\Console\Attribute\AsCommand » attribute to the « … » class instead.

Il suffit de passer sous le format suivant pour corriger ces erreurs :

use Symfony\Component\Console\Attribute\AsCommand;
...

#[AsCommand(
   name: 'app:ma-commande-custom',
)]
class MaCommandeCustomCommand extends Command {
    ...
}

Symfony 5 : Limiter la possibilité de « switch_user » en fonction du rôle de l’utilisateur cible.

Il existe dans Symfony une fonctionnalité permettant à certains utilisateurs de « se connecter en tant que » n’importe quel autre utilisateur (Documentation). Cette fonctionnalité est très utile pour que les administrateurs puissent se connecter sur le compte d’un client et vérifier les éventuels dysfonctionnements qui le concerne.

Cependant, dans son fonctionnement par défaut, dés que l’on attribut le rôle « ROLE_ALLOWED_TO_SWITCH » à un utilisateur il peux se connecter sur tout les autres utilisateurs existants. Cela peux s’avérer critique pour la sécurité de l’application, l’utilisateur pouvant alors gagner tous les privilèges. Concrètement si l’on prends un cas assez simple d’une application avec 3 rôles utilisateurs principaux : « ROLE_USER », « ROLE_ADMIN », « ROLE_SUPERADMIN » et que l’on définis la hiérarchie comme ceci :

security:
   # ...
    firewalls
:
        main
:
           # on active la fonctionnalité "switch_user"
            switch_user
: true  
    #
    role_hierarchy
:
        ROLE_ADMIN
:      [ROLE_USER, ROLE_ALLOWED_TO_SWITCH]
        ROLE_SUPER_ADMIN
: ROLE_ADMIN

Cela signifie que les administrateur et les superadmin pourront se connecter sur n’importe quel utilisateur. Mais cela implique que les simples administrateurs pourront devenir « superadmin » et donc avoir des droits auxquels in n’ont normalement pas accès. Nous allons voir comment limiter cela.

L’objectif est le suivant : les administrateurs « ROLE_ADMIN » peuvent se connecter sur tous les autres utilisateurs (user et administrateur) mais pas sur les superadmin. Pour les superadmin rien ne change, ils ont tous les accès.
La documentation de symfony explique la marche à suivre dans sa section limiting user switching. En suivant ce modèle on va donc modifier notre configuration sur 2 points : la propriété « switch_user » à qui on affecte un attribut « CAN_SWITCH_USER » et on supprime de notre hiérarchie le « ROLE_ALLOWED_TO_SWITCH » qui n’a plus d’utilité :

security:
   # ...
    firewalls
:
        main
:
            switch_user
: { role: CAN_SWITCH_USER }
    #
    role_hierarchy
:
        ROLE_ADMIN
:      ROLE_USER
        ROLE_SUPER_ADMIN
: ROLE_ADMIN

Pour la suite, on se base toujours sur la documentation pour créer le « Voter » correspondant, mais on se trouve confronté à un problème. On veux connaitre le rôle de l’utilisateur cible pour limiter l’accès si il est superadmin. Pour faire les choses proprement avec une hiérarchie des rôles réellement prise en charge on va utiliser notre service personnalisé « Securizer » permettant d’utiliser la méthode « isGranted » sur n’importe quel utilisateur. Des précisions sur l’utilité de ce service sont expliqué dans l’article précédent. Mais pour faire simple il suffit de créer le service suivant :

namespace App\Service;

use App\Entity\User;//l'entité user de notre aplication
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;

class Securizer {

    private $accessDecisionManager;

    public function __construct(AccessDecisionManagerInterface $accessDecisionManager) {
        $this->accessDecisionManager = $accessDecisionManager;
    }

    public function isGranted(User $user, $attribute, $object = null) {
        $token = new UsernamePasswordToken($user, 'none', 'none', $user->getRoles());
        return ($this->accessDecisionManager->decide($token, [$attribute], $object));
    }

}

et de l’utiliser das notre Voter pour l’attribut « CAN_SWITCH_USER » qui est maintenant facile à mettre en place en adaptant l’exemple de la documentation, le code complet est le suivant :

<?php
namespace App\Security\Voter;

use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\Security;
use App\Service\Securizer;
use App\Entity\User;

class SwitchUserVoter extends Voter {

    private $security;
    private $securizer;

    public function __construct(Security $security, Securizer $securizer) {
        $this->security = $security;
        $this->securizer = $securizer;
    }

    protected function supports($attribute, $subject) {
        return in_array($attribute, ['CAN_SWITCH_USER']) && $subject instanceof User;
    }

    protected function voteOnAttribute($attribute, $subject, TokenInterface $token) {
        $user = $token->getUser();
        //l'utilisateur doit être connecté et la cible doit être un utilisateur
        if (!$user instanceof User || !$subject instanceof User) {
            return false;
        }
        //on ne peux pas se connecter en tant que sois même, ça n'a aucun sens
        if($user->getId() == $subject->getId()){
            return false;
        }
        //l'utilisateur doit avoir le ROLE_ADMIN
        if (!$this->security->isGranted('ROLE_ADMIN')) {
            return false;
        }
        //Impossible si je ne suis pas SUPERADMIN et que le sujet l'est
        if (!$this->security->isGranted('ROLE_SUPERADMIN') && $this->securizer->isGranted($subject, 'ROLE_SUPERADMIN')) {
            return false;
        }
        //sinon c'est ok.
        return true;
    }

}

Tout est maintenant prêt est fonctionnel. Pour résumer en appliquant la seconde configuration YAML indiqué dans cette article et en créant les fichiers correspondant aux deux portions de code suivants, la sécurité concernant le « switch_user » de notre application est en place, et facilement personnalisable.

Symfony 5 : Utiliser la fonction « isGranted » sur n’importe quel objet utilisateur

Cet article est écrit pour la version 5 de Symfony mais à priori il devrait aussi fonctionner au moins pour les version 3 et 4 du framework

Sous Symfony la fonction permettant de savoir si l’utilisateur en train de parcourir l’application possède un certain rôle est « isGranted » elle est directement disponible à l’utilisation dans les templates twig, via les annotations, depuis le services « Symfony\Component\Security\Core\Security » ou directement dans les controllers de la façon suivante :

if ($this->isGranted('ROLE_ADMIN')) {
   //l'utilisateur à les droits admin
}

Par contre il n’existe pas de façon directe d’utiliser cette fonction sur un objet utilisateur différent de l’utilisateur courant.
Étant donnée le fonctionnement des rôles sous symfony, qui peuvent hériter des droits d’un autre rôle il ne faut pas utiliser un simple

if(in_array('ROLE_ADMIN', $user->getRoles())){
   //mauvaise pratique
}

En effet dans ce cas, si un utilisateur à le rôle ‘ROLE_SUPERADMIN’ qui hérite de ‘ROLE_ADMIN’ le contrôle ci dessus ne lui accordera pas les droits.
On imagine ici la hiérarchie des rôle suivante :

role_hierarchy:
    ROLE_ADMIN
: ROLE_USER
    ROLE_SUPERADMIN
: ROLE_ADMIN

Pour résoudre ce problème, nous allons créer la fonction « isGranted » prenant en paramètre un utilisateur et le rôle à vérifier dans un service « Securizer », je vous le livre directement prêt à utiliser :

namespace App\Service;

use App\Entity\User;//l'entité user de notre aplication
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface;

class Securizer {

    private $accessDecisionManager;

    public function __construct(AccessDecisionManagerInterface $accessDecisionManager) {
        $this->accessDecisionManager = $accessDecisionManager;
    }

    public function isGranted(User $user, $attribute, $object = null) {
        $token = new UsernamePasswordToken($user, 'none', 'none', $user->getRoles());
        return ($this->accessDecisionManager->decide($token, [$attribute], $object));
    }

}

Grâce à la configuration par défaut de Symfony on peux sans plus attendre utiliser ce service dans notre application et vérifier correctement les droits de n’importe quel utilisateur en prenant bien en compte la hiérarchie des rôles configuré dans le fichier « security.yaml ». Par exemple dans un controller on peux désormais faire :

public function monController(App\Service\SecurizerSecurizer $securizer) {
   //récupération d'un utilisateur $user de n'importe quelle manière, par exemple via une requête en base de données.
   //verification des droits
   if($securizer->isGranted($user, 'ROLE_ADMIN')){
       //l'utilisateur à les droits admin
   }
}

Il y’a sans doute de nombreux cas ou ce besoin de contrôle des rôles sur un utilisateur autre que celui connecté se fait sentir (d’où la mise en place de cette fonctionnalité sous la forme d’un service), particulièrement lors de l’utilisation de Voters pour sécuriser la gestion des droits de l’application. Dans mon cas j’ai d’abord eu besoin de cette fonctionnalité pour contrôler finement la possibilité de se « connecter en tant que » (switch_user) pour mes administrateurs.
Voir les détails dans le prochain article : Limiter la possibilité de « switch_user » en fonction du rôle de l’utilisateur cible.

Source

Symfony 4 : Les base d’une gestion des utilisateurs (inscription, connexion, droits d’accès)

Quand on démarre le développement d’une site sous Symfony, l’une des première chose à faire concerne la gestion des utilisateurs. En effet dans la plupart des cas notre application devra permettre à des utilisateurs de s’inscrire puis de se connecter au site afin d’avoir accès à des sections particulières (espace membre, espace administrateur). Je reprends dans cet article les différents éléments à mettre en place pour avoir une base de travail fonctionnelle.

On va rendre possible une inscription (email / mot de passe), puis une connexion (idem). Une fois connecté on aura accès a un espace membre.

Nous partirons d’une installation « website » de symfony 4 lancé avec la commande

composer create-project symfony/website-skeleton my-project

La première chose à faire sera de créer la base de données, et de la configurer dans le fichier « /.env », par exemple :

DATABASE_URL=mysql://root:@localhost/ma_base

Entrons dans le vif du sujet, avec la création de l’entité « User », dans « /src/Entity/User.php ». Elle contiendra les champs email, password, isActive, roles et étendra la classe « UserInterface » de symfony. Ce qui nous donne avec les getters/setters et les méthodes obligatoires à implémenter, le code suivant :

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

/**
 * @ORM\Table(name="user")
 * @UniqueEntity(fields="email")
 * @ORM\Entity()
 */

class User implements UserInterface, \Serializable {

    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */

    private $id;

    /**
     * @ORM\Column(type="string", length=255, unique=true)
     * @Assert\NotBlank()
     * @Assert\Email()
     */

    private $email;

    /**
     * @Assert\NotBlank()
     * @Assert\Length(max=250)
     */

    private $plainPassword;

    /**
     * The below length depends on the "algorithm" you use for encoding
     * the password, but this works well with bcrypt.
     *
     * @ORM\Column(type="string", length=64)
     */

    private $password;

    /**
     * @ORM\Column(name="is_active", type="boolean")
     */

    private $isActive;

    /**
     * @ORM\Column(name="roles", type="array")
     */

    private $roles = array();

    public function __construct() {
        $this->isActive = true;
        // may not be needed, see section on salt below
        // $this->salt = md5(uniqid('', true));
    }

    public function getUsername() {
        return $this->email;
    }

    public function getSalt() {
        // you *may* need a real salt depending on your encoder
        // see section on salt below
        return null;
    }

    public function getPassword() {
        return $this->password;
    }

    function setPassword($password) {
        $this->password = $password;
    }

    public function getRoles() {
        if (empty($this->roles)) {
            return ['ROLE_USER'];
        }
        return $this->roles;
    }

    function addRole($role) {
        $this->roles[] = $role;
    }

    public function eraseCredentials() {
       
    }

    /** @see \Serializable::serialize() */
    public function serialize() {
        return serialize(array(
            $this->id,
            $this->email,
            $this->password,
            $this->isActive,
                // see section on salt below
                // $this->salt,
        ));
    }

    /** @see \Serializable::unserialize() */
    public function unserialize($serialized) {
        list (
                $this->id,
                $this->email,
                $this->password,
                $this->isActive,
                // see section on salt below
                // $this->salt
                ) = unserialize($serialized);
    }

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

    function getEmail() {
        return $this->email;
    }

    function getPlainPassword() {
        return $this->plainPassword;
    }

    function getIsActive() {
        return $this->isActive;
    }

    function setId($id) {
        $this->id = $id;
    }

    function setEmail($email) {
        $this->email = $email;
    }

    function setPlainPassword($plainPassword) {
        $this->plainPassword = $plainPassword;
    }

    function setIsActive($isActive) {
        $this->isActive = $isActive;
    }

}

Une fois ce fichier enregistré, on peux mettre à jour notre base de données afin d’y créer la table « User ». Pour cela on lance les 2 commandes suivantes :

php bin/console doctrine:migration:diff
php bin/console doctrine:migration:migrate

Il est temps de passer à la configuration. Rendons nous dans le fichier « /config/packages/security.yaml » et remplissons le de la façon suivante :

security:
   # encoder
    encoders
:
        App\Entity\User
:
            algorithm
: bcrypt
   
    # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
    providers
:
        our_db_provider
:
            entity
:
                class
: App\Entity\User
                property
: email
       
    firewalls
:
        main
:
            pattern
:   ^/
            http_basic
: ~
            provider
: our_db_provider
            anonymous
: ~
            form_login
:
                login_path
: login
                check_path
: login
            logout
:
                path
:  /logout
                target
: /
           
    role_hierarchy
:
        ROLE_ADMIN
:      ROLE_USER
        #ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]

    # Easy way to control access for large sections of your site
    # Note: Only the *first* access control that matches will be used
    access_control
:
        - { path
: ^/admin, roles: ROLE_ADMIN }
        - { path
: ^/member, roles: ROLE_USER }

Comme on peux le comprendre on a mis en place la configuration pour l’encodage du mot de passe, on indique que nos utilisateurs passe par l’entité « App\Entity\User » et que le login est le champ « email ». On configure aussi les liens /login et /logout à utiliser, les différents roles possible (ROLE_USER, ROLE_ADMIN) et bien sur les droits d’accès en fonctions des rôles. Ici toutes les routes commençant par « /member » ne seront accessible que pour les membres connectés, et celle commençant par « /admin » pour les membres connecté ayant le rôle « ROLE_ADMIN ».

Pour faire fonctionner la déconnexion « logout », il faut simplement créer la route. On se rend donc dans le fichier « /config/routes.yaml » pour y mettre ce code :

logout:
    path
: /logout

Il nous reste à créer la page contenant le formulaire d’inscription, celle contenant le formulaire de connexion et tout sera fonctionnel.

Pour l’inscription, on va d’abord créer le formulaire dans un fichier à part qui sera « /src/Form/UserType.php ». J’ai ajoutés des classes css de bootstrap pour le bouton submit, car on verra plus bas que je propose d’utiliser bootstrap 4 pour l’affichage, mais il s’agit bien sur uniquement d’un exemple.

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;

class UserType extends AbstractType {

    public function buildForm(FormBuilderInterface $builder, array $options) {
        $builder
                ->add('email', EmailType::class)
                ->add('plainPassword', RepeatedType::class, array(
                    'type' => PasswordType::class,
                    'first_options' => array('label' => 'Mot de passe'),
                    'second_options' => array('label' => 'Confirmation du mot de passe'),
                ))
                ->add('submit', SubmitType::class, ['label'=>'Envoyer', 'attr'=>['class'=>'btn-primary btn-block']])
        ;
    }
}

Nous allons utiliser ce formulaire dans le controller « /src/Controller/RegistrationController.php ». La page permettant l’inscription sera accessible sur l’url « /register », ici on enregistre et on valide directement le compte, on peut aussi attribuer le rôle utilisateur dans ce controlleur. C’est une base qui pourras être amélioré (envoie d’un email de confirmation…etc)

namespace App\Controller;

use App\Form\UserType;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;

class RegistrationController extends Controller {

    /**
     * @Route("/register")
     */

    public function registerAction(Request $request, UserPasswordEncoderInterface $passwordEncoder) {
        // 1) build the form
        $user = new User();
        $form = $this->createForm(UserType::class, $user);
        // 2) handle the submit (will only happen on POST)
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            // 3) Encode the password (you could also do this via Doctrine listener)
            $password = $passwordEncoder->encodePassword($user, $user->getPlainPassword());
            $user->setPassword($password);
            //on active par défaut
            $user->setIsActive(true);
            //$user->addRole("ROLE_ADMIN");
            // 4) save the User!
            $entityManager = $this->getDoctrine()->getManager();
            $entityManager->persist($user);
            $entityManager->flush();
            // ... do any other work - like sending them an email, etc
            // maybe set a "flash" success message for the user
            $this->addFlash('success', 'Votre compte à bien été enregistré.');
            //return $this->redirectToRoute('login');
        }
        return $this->render('registration/register.html.twig', ['form' => $form->createView(), 'mainNavRegistration' => true, 'title' => 'Inscription']);
    }

}

Dans la foulée, on enchaine sur le controller « /src/Controller/SecurityController.php » avec la page « /login » qui permettra la connexion, cette fois on créer directement le formulaire dans le controller, cela permet de voir une autre manière de faire que pour l’enregistrement.

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;

class SecurityController extends Controller {

    /**
     * @Route("/login", name="login")
     */

    public function login(Request $request, AuthenticationUtils $authenticationUtils) {
        // get the login error if there is one
        $error = $authenticationUtils->getLastAuthenticationError();
        // last username entered by the user
        $lastUsername = $authenticationUtils->getLastUsername();
        //
        $form = $this->get('form.factory')
                ->createNamedBuilder(null)
                ->add('_username', null, ['label' => 'Email'])
                ->add('_password', \Symfony\Component\Form\Extension\Core\Type\PasswordType::class, ['label' => 'Mot de passe'])
                ->add('ok', \Symfony\Component\Form\Extension\Core\Type\SubmitType::class, ['label' => 'Ok', 'attr' => ['class' => 'btn-primary btn-block']])
                ->getForm();
        return $this->render('security/login.html.twig', [
                    'mainNavLogin' => true, 'title' => 'Connexion',
                    //
                    'form' => $form->createView(),
                    'last_username' => $lastUsername,
                    'error' => $error,
        ]);
    }

}

Tout ce qui concerne la partie « technique » et maintenant en place et fonctionnel. Pour aller jusqu’au bout on va aussi voir la mise en place des templates pour pouvoir tester le tout (avec un joli affichage à l’aide de bootstrap 4). Pour avoir accès à nos route « /register » et « /login » de nos controllers il nous faut créer les 2 templates appelés par ceux-ci. Les 2 sont très simples et assez proches.

« /templates/registration/register.html.twig »

{% extends 'base.html.twig' %}

{% block body %}
    {{form(form)}}
{% endblock %}

« /templates/security/login.html.twig »

{% extends 'base.html.twig' %}

{% block body %}

    {% if error %}
        <div class="alert alert-danger">
            <ul class="list-unstyled mb-0">
                <li><span class="initialism form-error-icon badge badge-danger">Error</span>
                    <span class="form-error-message">{{ error.messageKey|trans(error.messageData, 'security') }}</span>
                </li>
            </ul>
        </div>
    {% endif %}
    {{form(form)}}
{% endblock %}

Ces 2 templates dépendent de « base.html.twig », c’est à dire le fichier « /templates/base.html.twig », ci dessous je vous en met un exemple. comme vu précédemment il utilise bootstrap 4. Pour aller jusqu’au bout j’ai inclus ici un menu vers différente page de l’application : les page d’inscription/connnexion ou de déconnexion si on est connecté. Ainsi qu’une page d’accueil, et un espace membre et/ou espace admin en fonction du rôle de l’utilisateur connecté. Pour que tout fonctionne il faudra donc créer les controllers pour ces 3 pages ainsi que leur vues, on voit ça juste après, à la fin de l’article.

<!DOCTYPE html>
<html lang="{{ app.request.locale }}">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1"/>
        <title>
            {% block title %}
                {% if title is defined %}{{title}}{% else %}Title{% endif %} | Nom du site
            {% endblock %}
        </title>
        {% block stylesheets %}
            {# bootstrap #}
            <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
            {# custom #}
            <link rel="stylesheet" href="{{ asset('assets/css/styles.css') }}">
        {% endblock %}
        <link rel="icon" type="image/x-icon" href="{{ asset('favicon.ico') }}" />
    </head>
    <body>
    <body id="{% block body_id %}{% endblock %}">

        {% block header %}
            <header id="header" class="bck1 border-bottom">
                <div class="container">
                    <h1>{% if title is defined %}{{title}}{% else %}Header{% endif %}</h1>
                </div>
            </header>
        {% endblock %}

        {% block nav %}
            <nav id="main-nav" class="container navbar navbar-expand-lg navbar-light bg-light border rounded mb-3">
                <ul class="navbar-nav">
                    <li class="nav-item {% if mainNavHome is defined %}active{% endif %}">
                        <a class="nav-link" href="{{path('app_homepage_index')}}">Accueil</a>
                    </li>
                    {% if is_granted('ROLE_USER') %}
                        <li class="nav-item {% if mainNavMember is defined %}active{% endif %}">
                            <a class="nav-link" href="{{path('app_member_index')}}">Espace membre</a>
                        </li>
                    {% endif %}
                    {% if is_granted('ROLE_ADMIN') %}
                        <li class="nav-item {% if mainNavAdmin is defined %}active{% endif %}">
                            <a class="nav-link" href="{{path('app_admin_homepage_index')}}">Espace admin</a>
                        </li>
                    {% endif %}
                </ul>
                <ul class="navbar-nav ml-auto">
                    {% if is_granted('ROLE_USER') %}
                        <li class="nav-item">
                            <a class="nav-link" href="{{path('logout')}}">Déconnexion</a>
                        </li>
                    {% else %}
                        <li class="nav-item {% if mainNavLogin is defined %}active{% endif %}">
                            <a class="nav-link" href="{{path('login')}}">Connexion</a>
                        </li>
                        <li class="nav-item {% if mainNavRegistration is defined %}active{% endif %}">
                            <a class="nav-link" href="{{path('app_registration_register')}}">Inscription</a>
                        </li>
                    {% endif %}
                </ul>
            </nav>
        {% endblock %}

        {% block flash %}
            {% for flash_message in app.session.flashbag.get('success') %}
                <div class="container alert alert-success" role="alert">
                    {{ flash_message }}
                </div>
            {% endfor %}
            {% for flash_message in app.session.flashbag.get('info') %}
                <div class="container alert alert-info" role="alert">
                    {{ flash_message }}
                </div>
            {% endfor %}
            {% for flash_message in app.session.flashbag.get('warning') %}
                <div class="container alert alert-warning" role="alert">
                    {{ flash_message }}
                </div>
            {% endfor %}
            {% for flash_message in app.session.flashbag.get('danger') %}
                <div class="container alert alert-danger" role="alert">
                    {{ flash_message }}
                </div>
            {% endfor %}
        {% endblock %}

        <div class="container bck1 padded">
            {% block body %}
            {% endblock %}
        </div>

        {% block footer %}
            <footer id="footer" class="bck1 border-top">
                <div class="container">
                    <h4>Footer</h4>
                </div>
            </footer>
        {% endblock %}

        {% block javascripts %}{% endblock %}
    </body>
</html>

Le layout présenté ci-dessus inclus en plus de bootstrap un fichier css custom permettant d’ajouter nos propres classe de présentation. Voici les quelques lignes ajouté pour ma part dans « /public/assets/css/styles.css »

/*base*/
body{background: #ebeff2;}
.bck1{background: #fff;}

/*generique*/
.padded{padding: 15px;}

/*header*/
header#header {margin-bottom: 20px;padding: 10px;}
header#header h1 {text-align: center;color: #5cb85c;}

/*footer*/
footer#footer{margin-top: 20px;padding: 10px;}

Et pour que nos formulaires s’affichent en suivant les conventions de bootstrap 4 on remercie symfony et sa communauté, une seule ligne de configuration suffit ! Dans « /config/packages/twig.yaml » :

twig:
    form_themes
: ['bootstrap_4_layout.html.twig']

Pour la page d’accueil le simple controller « /src/Controller/HomepageController.php » suffira :

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Routing\Annotation\Route;

class HomepageController extends Controller {

    /**
     * @Route("/")
     */

    public function index() {
        return $this->render('homepage/index.html.twig', ['mainNavHome'=>true, 'title'=>'Accueil']);
    }

}

Rien de spécial pour la vue il suffit de créer le fichier correspandant « /templates/homepage/index.html.twig » de la même façon qu’on l’a fait pour « inscription » et « login », avec le contenu que l’on souhaite.

Le dernier points à voir, c’est la création des « espace membre » et « espace administrateur » limité selon les rôles de l’utilisateur. Il suffit pour cela, en accord avec la configuration faite dans « /config/packages/security.yaml » de faire commencer les routes par « /member » ou « /admin » selon ce que l’on souhaite. Voici pour l’espace utilisateur (fichier « /src/Controller/MemberController.php ») :

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Routing\Annotation\Route;

/** @Route("/member") */
class MemberController extends Controller {

    /**
     * @Route("/")
     */

    public function index() {
        return $this->render('member/index.html.twig', ['mainNavMember'=>true, 'title'=>'Espace Membre']);
    }

}

Exactement pareil pour l’admin, sauf que dans un soucis d’organisation je place le fichier dans un dossier admin, ce qui donne « src/Controller/Admin/HomepageController.php : « 

namespace App\Controller\Admin;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Routing\Annotation\Route;

/** @Route("/admin") */
class HomepageController extends Controller {

    /**
     * @Route("/")
     */

    public function index() {
        return $this->render('admin/homepage/index.html.twig', ['mainNavAdmin' => true, 'title' => 'Espace Admin']);
    }

}

Je ne rajoute pas le code des « views » car rien de particulier et donc même processus que pour la page d’accueil.

C’est terminé pour ce long article à réutiliser à souhait lors du démarrage d’un projet symfony 4

Symfony : Afficher des dates en français dans Twig

—————————————————– Version 2023 ——————————————-
L’article n’étant plus d’actualité, voici une mise à jour rapide :
Il faut à présent utiliser le filtre format_datetime de twig

Pour cela il est nécessaire d’installer

composer require twig/intl-extra

et

composer require twig/extra-bundle

Ensuite on peux l’utiliser dans twig sur un objet date pour par exemple afficher « Novembre 2023 »

{{ maDate|format_datetime(pattern='MMMM Y') }}

—————————————————– Version 2018 ——————————————-

Lorsque travaille sous Symfony, avec des templates twig, on peut afficher des dates (généralement stocké coté PHP sous forme de DateTime()) grâce au filtre « date » de la façon suivante :

{{maDate|date('d/m/Y')}}//affiche la date au format 22/01/2018

Mais si l’on veux un affichage textuel en français du type « lundi 22 janvier 2018 » il nous faut utiliser une extension twig.
Pour installer cette extensions (ainsi que d’autres) dans votre projet, il faut lancer la commande composer suivante :

composer require twig/extensions

Nous devons ensuite configurer l’extension « intl » qui nous intéresse, dans le fichier de configurations des services :

services:
    twig.extension.intl
:
        class
: Twig_Extensions_Extension_Intl
        tags
:
            - { name
: twig.extension }

Le filtre « localizeddate » est maintenant utilisable et permet d’afficher la date au format que l’on souhaite, et dans la langue que l’on souhaite (en utilisant par défaut la langue configuré dans notre projet symfony) En voici 2 exemples.

{{entity.dateEvent|localizeddate('none', 'none', null, null, 'EEEE')}}//lundi
{{entity.dateEvent|localizeddate('none', 'none', null, null, 'MMMM Y')}}//Janvier 2018

Pour plus d’information veuillez vous référez au documentations suivantes :
http://twig-extensions.readthedocs.io/en/latest/intl.html#localizeddate
http://userguide.icu-project.org/formatparse/datetime#TOC-Date-Time-Format-Syntax

Symfony : Utilisation du « Simple Cache »

Depuis sa version 3.1, Symfony dispose d’un Cache Component permettant comme son nom l’indique de gérer un système de cache assez facilement. Depuis la version 3.3 du framework, une version simplifié est disponible.

C’est un jeu d’enfant de mettre une information en cache, la récupérer et la supprimer

use Symfony\Component\Cache\Simple\FilesystemCache;
#
$cache = new FilesystemCache();
$cache->set('mon.information', 'Je place ce texte en cache'); //mise en cache
$cache->get('mon.information'); //récupération
$cache->delete('mon.information'); //suppression

Cela peut être utilisé par exemple pour stocker des paramètres de configuration.
Afin de permettre à l’utilisateur de notre application de modifier ces paramètres depuis un back-office on les stocke en base de données. Cependant pour ne pas avoir a faire une requête à chaque fois que l’on doit utiliser un des paramètre, on les met en cache.

Voici un cas concret d’utilisation :
– Mon utilisateur peut depuis le back-office créer des « Organization » (entité), il leur donne un nom et peux cocher 4 différents bloc d’options possible pour cette « organization ».
– J’ai plus tard dans mon application besoin de connaitre la liste des « Organization » ayant telle option.
Pour des question d’optimisation (moins de requêtes) mais aussi pour des questions pratiques je vais utiliser le système de cache.
Ci dessous le service « CacheManager » que je vais utiliser :

namespace AppBundle\Service;

use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Cache\Simple\FilesystemCache;
use AppBundle\Entity\Organization;

class CacheManager {

    private $em;

    public function __construct(EntityManagerInterface $em) {
        $this->em = $em;
    }

    /**
     * Met à jour les informations en cache.
     * Stocke un tableau contenant les identifiants des "organization" ouvertes aux différentes options
     */

    public function updateOrganizationOptions() {
        $cache = new FilesystemCache();
        $entities = $this->em->getRepository(Organization::class)->findAll();
        $option1 = [];
        $option2 = [];
        $option3 = [];
        $option4 = [];
        foreach ($entities as $entity) {
            if ($entity->getOptions1()) {
                $option1[] = $entity->getId();
            }
            if ($entity->getOptions2()) {
                $option2[] = $entity->getId();
            }
            if ($entity->getOptions3()) {
                $option3[] = $entity->getId();
            }
            if ($entity->getOptions4()) {
                $option4[] = $entity->getId();
            }
        }
        $cache->set('organization.ids.options1', $option1);
        $cache->set('organization.ids.options2', $option2);
        $cache->set('organization.ids.options3', $option3);
        $cache->set('organization.ids.options4', $option4);
    }

    public static function getOrganizationOptions($num = 1) {
        $cache = new FilesystemCache();
        $options = $cache->get('organization.ids.options' . $num);
        return $options;
    }

}

J’utilise ce service pour mettre à jour le cache des options à la création ou la modification d’une « organization ».
Dans les controllers correspondants c’est fait de la façon suivante :

//traitement classique (si le formulaire posté par l’utilisateur est valide, j'enregistre les données)
//+
$CM = $this->get('app.cache.manager');//appel du service
$CM->updateOrganizationOptions();//mise à jour du cache

N’oubliez pas que si les données peuvent être modifiées depuis plusieurs endroits, vous pouvez utiliser les Event listeners de doctrine pour automatiser le traitement et être sur d’avoir toujours les informations à jour.

Je peux maintenant récupérer ma liste d’organisations ouverte à telle option n’importe ou grâce à ma méthode statique :

CacheManager::getOrganizationOptions(1)

Dans la réalité il s’agissait pour moi de proposer les différents champs correspondants au options dans un formulaire. Formulaire dans lequel on choisissait une organisation dans une liste déroulante, en fonction du choix certaines options apparaissaient donc. Idem pour le contrôle lors de la soumission du formulaire afin de valider l’entité sans passer dans l’organization liée.

Symfony : Obtenir un enregistrement aléatoire avec doctrine

Doctrine ne permet pas par défaut d’obtenir un enregistrement aléatoire, ce qui est pourtant en principe très simple avec mysql. Par contre il est possible de faire une requête native et de demander a doctrine de « mapper » les résultats. Il devient alors facile de récupérer notre enregistrement aléatoire sous le format habituel (entité doctrine). Voici ci-dessous la méthode d’un repository permettant de la faire.

class ExempleRepository extends \Doctrine\ORM\EntityRepository {

    public function findRandom() {
        $rsm = new \Doctrine\ORM\Query\ResultSetMappingBuilder($this->getEntityManager());
        $rsm->addRootEntityFromClassMetadata(\AppBundle\Entity\Exemple::class, 'exemple');
        $sql = "SELECT * FROM exemple ORDER BY RAND() LIMIT 1";
        return $this->getEntityManager()->createNativeQuery($sql, $rsm)->getOneOrNullResult();
    }

}

Symfony : Automatiser des actions avec les « Event listeners » de doctrine

Le framework Symfony couplé à l’ORM doctrine permet de créer des « Event Listeners », c’est à dire connaitre dés qu’il y’a un événement (création/modification/suppression) sur une entité. C’est un outil très intéressant pour automatiser des actions quand certaines propriétés d’une entité sont mis à jour.

Prenons un exemple concret : Dans les semaines précédentes, nous avons vu comment récupérer les coordonnées d’une adresse grâce à l’API google map. Afin de limiter les appels à l’API google map (payants si un certain quota et dépassé) nous allons enregistrer ces coordonnées en base de données pour pouvoir les réutiliser directement. Il nous faut cependant les enregistrer/mettre à jour à chaque fois qu’une adresse est crée ou modifiée. C’est ici que nous faisons intervenir les Event Listener.

Commençons par configurer nos listeners dans les services (fichiers services.yml du dossier /app/config/) de symfony.

services:
   #...
    #LISTENER DOCTRINE#
    app.listener.doctrineevent
:
        class
: AppBundle\EventListener\DoctrineEvent
        tags
:
            - { name
: doctrine.event_listener, event: prePersist, lazy: true }
            - { name
: doctrine.event_listener, event: preUpdate, lazy: true }

La configuration indique que nous allons écouter les événements « PrePersist » et « PreUpdate ». Ce qui permettra d’effectuer notre action lors de la création d’une adresse et lors de sa modification. Créons la classe qui va gérer tout ça. Comme indiqué il s’agit du fichier : AppBundle\EventListener\DoctrineEvent

Pour appréhender la suite, consultez les commentaires dans le code

namespace AppBundle\EventListener;

use Doctrine\Common\EventSubscriber;
use Doctrine\Common\Persistence\Event\LifecycleEventArgs;
//les adresses de nos utilisateur sont stockés dans une entité "Contact"
use AppBundle\Entity\Contact;

class DoctrineEvent implements EventSubscriber {

    public function getSubscribedEvents() {
        return array('prePersist', 'preUpdate');//les événements écoutés
    }

    public function prePersist(LifecycleEventArgs $args) {
        $entity = $args->getEntity();
        //Si c'est bien une entité Contact qui va être "persisté"
        if ($entity instanceof Contact) {
            $entity->updateGmapData();//on met à jour les coordonnées via l'appel à google map
        }
    }

    public function preUpdate(LifecycleEventArgs $args) {
        $entity = $args->getEntity();
        $changeset = $args->getEntityManager()->getUnitOfWork()->getEntityChangeSet($entity);
        //Si c'est bien une entité Contact qui va être modifié
        if ($entity instanceof Contact) {
            //Si il y'a eu une mise a jour sur les propriétés en relation avec l'adresse (ici "address", "city" et "postalCode")
            if (array_key_exists("address", $changeset) || array_key_exists("city", $changeset) || array_key_exists("postalCode", $changeset)) {
                $entity->updateGmapData();//on met à jour les coordonnées via l'appel à google map
            }
        }
    }

}

De cette manière nos coordonnées seront toujours à jour. Il manque pour que l’exemple soit complet la fonction « updateGmapData() » de notre entité Contact. Cela ne concerne pas le sujet des Event Listener, mais je vous en met un exemple qui utilise bien sur la fonction « geocodeAddress() » détaillé ici

    public function updateGmapData() {
        $data = \AppBundle\Utils\GmapApi::geocodeAddress($this->getAddress() . ' ' . $this->getZipcode() . ' ' . $this->getCity());
        $this->setGmapLat($data['lat']);
        $this->setGmapLng($data['lng']);
        $this->setGmapAddress($data['address']);
        $this->setGmapPostalCode($data['postal_code']);
        $this->setGmapCity($data['city']);
        $this->setGmapDepartment($data['department']);
        $this->setGmapRegion($data['region']);
        $this->setGmapCountry($data['country']);
    }

Symfony : Afficher toutes les erreurs d’un formulaire dans une liste

Mise à jour le 03/04/2020 pour fonctionner avec Symfony 5

Par défaut l’affichage des erreurs sur les formulaires dans symfony se fait de la manière suivante :

  • Les erreurs globales (qui ne concerne pas un champ particulier) sont affichés en haut.
  • Les erreurs concernant chaque champs, sont affiché au niveau du champ.

Dans certains cas, par exemple sur des longs formulaires, il peut être intéressant de retrouver la liste de toutes les erreurs au mêmes endroits. Voici ci-dessous un code permettant de le faire (pour un formulaire « form »)

{% if not form.vars.valid %}
    <ul class="alert alert-danger">
        {# on affiche en premier les erreurs globales (cas classiques) #}
        {% for error in form.vars.errors %}
            <li>{{error.message}}</li>
        {% endfor %}
        {# ainsi que les erreurs concernant chaque champs #}
        {% for child in form.children %}
            {# si le champ à des erreur #}
            {% if child.vars.errors is defined %}
                {% for error in child.vars.errors %}
                    <li>"{{child.vars.label}}" : {{error.message}} </li> {# on ajoute le label du champ devant l'erreur pour plus de précisions. #}
                {% endfor %}
            {% endif %}
        {% endfor %}
    </ul>
{% endif %}

Symfony : modifier les champs d’un formulaire en fonction de son objet ou d’options

Depuis le controller, c’est un appel du formulaire classique, on ajoute juste les éventuelles options si besoins

$form = $this->createForm(ExampleFormType::class, $example, array('custom' => true));

Dans « ExampleFormType » définition de l’option « custom » et utilisation des « Form Events » pour adapter les champs

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;

class ExampleFormType extends AbstractType {

    public function buildForm(FormBuilderInterface $builder, array $options) {
        $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use($options) {
            $example = $event->getData(); //recuperation de l'objet sur lequel le formulaire se base
            $form = $event->getForm(); //recuperation du formulaire
            //en fonction du type de l'utilisateur les champs sont différents
            if ($example->getStatus() == 1 && $options['custom']) {
                $form->add('firstName', null, array('label' => 'Prénom'));
                //....
            } else {
                $form->add('lastName', null, array('label' => 'Nom'));
                //....
            }
        });
    }

    public function configureOptions(OptionsResolver $resolver) {
        $resolver->setDefaults(array(
            'data_class' => 'AppBundle\Entity\Example',
            'custom' => false
        ));
    }

}

Savoir si un champs est définis dans twig

{% if form.firstName is defined %}
    {{form_row(form.firstName)}}
{% endif %}

Symfony : Relations entre entités

Exemple de relation entre entités Doctrine dans Symfony

Relation « unique » (ManyToOne) entre « Child » et « School »

    //coté Child

    /**
     * @ORM\ManyToOne(targetEntity="AppBundle\Entity\School", inversedBy="children")
     * @ORM\JoinColumn(name="school_id", referencedColumnName="id", nullable=false)
     */

    protected $school;
    //coté School

    /**
     * @ORM\OneToMany(targetEntity="AppBundle\Entity\Child", mappedBy="school")
     */

    protected $children;

Relation « multiple » (ManyToMany) entre « Child » et « Member »

    //coté Child

    /**
     * @ORM\ManyToMany(targetEntity="AppBundle\Entity\Member", inversedBy="children")
     */

    protected $members;
    //coté Member

    /**
     * @ORM\ManyToMany(targetEntity="AppBundle\Entity\Child", mappedBy="members")
     */

    protected $children;