TD2 – Découverte du framework Symfony 2/2 Gestion des utilisateurs et finitions

Introduction

La seconde partie de la découverte de ce framework va se concentrer sur la gestion de nos utilisateurs. Comme c’est un aspect assez courant sur les applications web, Symfony possède bien sûr divers outils et processus permettant de mettre en place rapidement et facilement un système de gestion pour nos utilisateurs.

Enfin, dans une seconde partie, nous effectuerons quelques finitions sur le site comme ajouter des auteurs pour les publications et des pages personnelles pour les utilisateurs. Nous verrons aussi comment inclure d’autres templates dans un template Twig et enfin, nous personnaliserons nos pages d’erreur.

On rappelle que les commandes doivent être exécutées à l’intérieur de votre conteneur Docker.

Barre de débogage

Vous avez sans doute remarqué une barre d’outil s’affichant sur chaque page de votre application (si elle ne s’affiche pas, il faut cliquer sur le logo Symfony, en bas à droite). Cette barre s’affiche car nous sommes dans un environnement de développement (nous en reparlerons plus tard). Cette barre est très utile car elle nous fournit beaucoup d’informations :

Bref, cet outil nous permet de connaître en détail l’état de notre application. Prenez le temps de l’explorer !

Les utilisateurs

Il est temps d’ajouter des utilisateurs à notre site. Avec un framework, cette étape est généralement assez simplifiée et partiellement automatisée.

Création de l’entité

Dans Symfony, il existe une commande interactive pour initier un système de gestion d’utilisateurs qui va générer la base des classes dont nous aurons besoin et va également configurer différentes choses, comme l’algorithme de hachage des mots de passe.

Cette commande se nomme make:user :

php bin/console make:user

Une fois exécutée, elle va vous demander :

Une fois complétée, la commande va générer une classe pour l’entité et une autre pour son repository puis va mettre à jour le fichier config/packages/security.yaml.

L’entité générée est vraiment basique et contient le strict nécessaire (id, propriété unique pour la connexion, mot de passe haché et rôles) mais il est tout à fait possible de la compléter en utilisant la commande make:entity.

  1. À l’aide de la commande make:user, initiez le système de gestion d’utilisateurs, avec les contraintes suivantes :

    • Nom de l’entité : Utilisateur.
    • Stockage des informations dans la base.
    • Nom de la propriété unique : login.
    • Les mots de passe sont hachés et vérifiés.
  2. Utilisez la commande make:entity pour mettre à jour et compléter la classe Utilisateur avec les champs suivants :

    • adresseEmail : string, 255 caractères maximum, non null.
    • nomPhotoProfil : text, null autorisé.
  3. Tout en haut de la classe, vous pouvez observer un attribut nommé UniqueConstraint. Cet attribut permet de faire en sorte que, dans la base de données, deux utilisateurs différents n’aient pas le même login. On souhaite faire la même chose avec l’adresse email. Afin de rendre l’adresse email unique, ajoutez une nouvelle annotation UniqueConstraint nommée UNIQ_IDENTIFIER_EMAIL et visant l’attribut adresseEmail.

  4. Prenez le temps d’observer le code des classes générées ainsi que le fichier security.yaml.

  5. Mettez à jour la structure de la base de données avec les commandes make:migration puis doctrine:migrations:migrate. Allez observer votre base de données pour constater la présence de la nouvelle table.

À ce stade, tout est prêt pour gérer nos utilisateurs. Il n’y a plus qu’à gérer la phase d’inscription et de connexion. Mais revenons d’abord sur certains éléments importants qui ont été générés.

Au niveau de la classe Utilisateur :

Au niveau du fichier security.yaml :

Formulaire d’inscription

Création du formulaire

Nous allons maintenant mettre en place un formulaire d’inscription pour nos utilisateurs !

À la différence du formulaire que nous avons créé pour les publications, celui-ci contiendra deux champs qui ne seront pas liés directement à la classe Utilisateur :

Vous aurez aussi besoin de nouvelles assertions :

Pour rappel, pour ajouter un champ qui ne fait pas partie de l’entité (et lui ajouter des assertions) on le configure ainsi dans la classe du formulaire :

$builder
    //Champ qui n'est pas lié à l'entité: "mapped => false"
    ->add('monChamp', TextType::class, [
        "mapped" => false,
        //Les assertions
        "constraints" => [
            new NotBlank(),
            new Length(...)
        ],
        "attr" => [
            //Attributs HTML éventuels
        ],
        "label" => "..." //Titre du label associé au champ...
    ])
;

L’exemple d’assertion File donné plus tôt se transformerait ainsi dans le tableau du champ constraints :

new File(maxSize: '2M', extensions: ['mp3', 'wav', 'ogg'])

Vous l’aurez remarqué, nous utilisons la syntaxe des arguments nommés que nous avions évoqués lors du premier TD lors de l’introduction des attributs.

Ensuite, au niveau de la classe Utilisateur, nous pouvons utiliser un attribut

#[UniqueEntity('propriete')]

Cet attribut se place juste au-dessus du nom de la classe et permet de signifier à l’application qu’une valeur d’une propriété de la classe est unique (pas de doublons entre les utilisateurs pour cet attribut, comme le “unique” en base de données). Cela peut paraître redondant avec l’attribut ORM\UniqueConstraint lié à la base de données, mais cela permet de détecter cette erreur plus tôt, au niveau de l’application, et ainsi de la gérer par nous-même plutôt qu’obtenir une page d’erreur liée à la base de données que l’utilisateur n’est pas censé voir.

Par exemple :

#[UniqueEntity('champ1', message: "Cette valeur est déjà prise!")]
#[UniqueEntity('champ3')]
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_CHAMP_1', fields: ['champ1'])]
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_CHAMP_3', fields: ['champ3'])]
class Exemple {

    #[ORM\Column]
    private ?string $champ1 = null;

    #[ORM\Column]
    private ?string $champ2 = null;

    #[ORM\Column]
    private ?string $champ3 = null;

}

Pour bien que vous compreniez la différence, l’attribut [ORM\UniqueConstraint(...)] va créer une contrainte UNIQUE INDEX au niveau de la base de données tandis que #[UniqueEntity] est similaire aux autres assertions et est vérifié lors de l’appel à la méthode isValid du formulaire. Avec #[UniqueEntity], la vérification est faite avant toute tentative d’enregistrement en base de données et on est ainsi sûr de ne pas exécuter une requête d’insertion qui produira une erreur (pour cause de doublons).

Concernant notre formulaire, contrairement à celui de la page principale, celui-ci va contenir des balises <label>. Pour rappel, une balise <label> est censée afficher le “nom” d’un champ. Quand on clique sur le label, le “focus” (curseur) est déplacé dans l’input visé à condition d’avoir spécifié l’id de l’input dans l’attribut for.

Avec Symfony, on peut générer le <label> lié à un champ avec {{ form_label(form.champ) }} à condition que le champ label soit spécifié dans les options du champ. Cela est pratique dans le cas où nous n’avons pas besoin d’attribuer un id spécifique pour notre champ généré avec form_widget. Symfony le génèrera automatiquement et le label pointera automatiquement vers le bon id du champ visé.

Enfin, il existe une fonction utile qui permet de générer tout ce qui est relatif à un champ : l’affichage des erreurs éventuelles, le label et le champ. Pour cela, on utilise : {{ form_row(form.champ) }}. On l’utilisera quand il n’y aura aucun code HTML intermédiaire à placer entre les différentes zones du champ.

  1. À l’aide de la commande make:form créez une classe de formulaire InscrireUtilisateurType pour l’entité Utilisateur. Dans cette nouvelle classe, supprimez les champs password et nomPhotoProfil et roles (qui ne sont pas envoyés et gérés par l’utilisateur) puis ajoutez trois nouveaux champs : plainPassword, fichierPhotoProfil et inscription.

  2. Configurez le type des champs ainsi :

    • login : TextType, options ["label" => "Login"]
    • adresseEmail : EmailType, options ["label" => "Adresse email"]
    • plainPassword : PasswordType, options ["label" => "Mot de passe", "mapped" => false]
    • fichierPhotoProfil : FileType, options ["label" => "Photo de profil", "mapped" => false, "required" => false]
    • inscription : SubmitType (bouton d’envoi), options ["label" => "S'inscrire"]

    Quelques imports utiles à faire dans InscrireUtilisateurType :

    use Symfony\Component\Form\Extension\Core\Type\EmailType;
    use Symfony\Component\Form\Extension\Core\Type\FileType;
    use Symfony\Component\Form\Extension\Core\Type\PasswordType;
    use Symfony\Component\Form\Extension\Core\Type\SubmitType;
    use Symfony\Component\Form\Extension\Core\Type\TextType;
    

    Comme nous l’avons vu, mapped permet de spécifier qu’un champ n’est pas mappé dans l’entité cible. L’option required permet de gérer le fait que le champ soit obligatoire ou non côté HTML (pas d’envoi tant que le champ n’est pas complété). Par défaut, la valeur est à true. Enfin, on remarquera qu’il n’y pas besoin de spécifier mapped sur un champ de type Submit qui est automatiquement non mappé. Aussi, le label de ce champ est en fait le texte du bouton.

  3. Ajoutez différentes assertions (et attributs) dans le tableau associatif d’options pour implémenter les contraintes suivantes :

    1. Au niveau de la classe Utilisateur :

      • login : non blanc, entre 4 et 20 caractères. Configurez aussi des messages d’erreurs (si login trop court ou trop long)

      • adresseEmail : non blanc, adresse email valide. Configurez un message d’erreur en cas d’adresse non valide (paramètre message).

      • Deux entités différentes ne doivent pas avoir le même login et/ou la même adresseEmail. Faites le nécessaire pour implémenter ces contraintes au niveau de la classe Utilisateur. Spécifiez un message d’erreur si ces contraintes ne sont pas respectées.

      Classes à importer :

      use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
      use Symfony\Component\Validator\Constraints as Assert;
      
    2. Au niveau de InscrireUtilisateurType :

      • plainPassword : non blanc, entre 8 et 30 caractères, et doit respecter l’expression régulière (regex) suivante : #^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d\w\W]{8,30}$# (au moins une minuscule, une majuscule et un chiffre). Configurez des messages d’erreurs pour la taille du mot de passe et aussi si l’expression régulière n’est pas validée (juste message).

      • fichierPhotoProfil : taille maximum 10 mégaoctets, formats autorisés : jpg et png. Configurez des messages d’erreurs dans le cas où la taille n’est pas respectée (maxSizeMessage) ou que le format n’est pas respecté (extensionsMessage). Classes à importer :

      use Symfony\Component\Validator\Constraints\File;
      use Symfony\Component\Validator\Constraints\Length;
      use Symfony\Component\Validator\Constraints\NotBlank;
      use Symfony\Component\Validator\Constraints\NotNull;
      use Symfony\Component\Validator\Constraints\Regex;
      
  4. Dans le dossier templates, créez un dossier utilisateur puis à l’intérieur de ce nouveau répertoire, un template nommé inscription.html.twig :

    • Comme toutes nos futures pages, ce template doit étendre le template base.html.twig.

    • Le titre de la page doit être Inscription.

    Nous finirons de compléter ce template un peu plus loin.

  5. À l’aide de la commande make:controller, créez un nouveau contrôleur UtilisateurController, effacez la méthode index générée par défaut ainsi que le template index.html.twig généré dans le dossier utilisateur.

  6. Créez une route nommée inscription, ayant pour chemin /inscription et accessible avec les méthodes GET et POST. Dans le code de cette route, initialisez un formulaire avec InscrireUtilisateurType. Le formulaire utilisera la méthode POST et son action pointe vers la route inscription. Renvoyez une réponse générant une page avec le template créé à l’étape 4, en passant le formulaire en paramètre (n’hésitez pas à vous inspirer du code de la route feed). Comme pour les publications, nous utiliserons la même route pour afficher (GET) et traiter (POST) le formulaire.

  7. Dans votre template, redéfinissez le bloc de contenu (page_content) en incluant et en complétant le squelette suivant afin d’afficher le formulaire :

    <main>
        {{ form_start(..., {'attr': {'class': 'center basic-form'}}) }}
            <fieldset>
                <legend>Inscription</legend>
                <div class="access-container">
                    <!-- Affichage du label du login -->
                    {{ form_label(...) }}
                    <p class="help-input-form">Entre 4 et 20 caractères</p>
                    <!-- Affichage des erreurs du login -->
                    {{ form_errors(...) }}
                    <!-- Affichage de l'input du login -->
                    {{ form_widget(...) }}
                </div>
                <div class="access-container">
                    <!-- Affichage du label du mot de passe -->
                    {{ form_label(...) }}
                    <p class="help-input-form">Entre 8 et 30 caractères, au moins une minuscule, une majuscule et un nombre</p>
                    <!-- Affichage des erreurs du mot de passe -->
                    {{ form_errors(...) }}
                    <!-- Affichage de l'input du mot de passe -->
                    {{ form_widget(...) }}
                </div>
                <div class="access-container">
                    <!-- Affichage du champ adresse email (label + erreurs + input) -->
                    {{ form_row(...) }}
                </div>
                <div class="access-container">
                    <!-- Affichage du champ photo de profil (label + erreurs + input) -->
                    {{ form_row(...) }}
                </div>
                <!-- Affichage du bouton d'envoi, contenant le texte (label) "S'inscrire" -->
                {{ form_widget(..., {'attr': {'class': 'basic-form-submit'}}) }}
            </fieldset>
            {{ form_rest(...) }}
        {{ form_end(...) }}
    </main>
    
  8. Accédez à votre nouvelle page et vérifiez que le formulaire s’affiche correctement.

  9. Modifiez base.html.twig afin d’inclure un lien (généré) vers votre nouvelle page d’inscription dans le menu de navigation, toujours en utilisant la fonction adéquate pour générer le lien à partir du nom de la route (ici, inscription).

Traitement du formulaire d’inscription - Créer un service dédié

Maintenant que nous pouvons afficher notre formulaire d’inscription, il faut pouvoir le traiter ! Mais ce n’est pas aussi simple que pour les publications, car :

Cela peut représenter un volume important de code (notamment en cas d’évolution de l’application). De manière générale, Symfony conseille de garder les actions des contrôleurs le plus léger possible et de déléguer le gros de la logique métier à des services dédiés. Nous utilisons déjà plusieurs services que nous injectons dans nos actions de contrôleurs (repositories, entity manager…) mais jusqu’ici, nous n’en avons pas créé.

Pour gérer ces deux aspects, nous nous proposons de créer un service, afin de ne pas surcharger le contrôleur (et de toute façon, ce n’est pas vraiment son rôle de faire ces étapes, normalement…). Nous pourrions aussi réutiliser ce service en cas de mise à jour d’un utilisateur, par exemple.

Pour créer un service, il suffit de créer une classe dans src/Service (par convention). Nous pourrons ensuite l’injecter dans une des méthodes du contrôleur comme nous le faisons pour les autres services. Il est d’ailleurs tout à fait possible d’injecter et d’utiliser d’autres services dans notre service (par exemple, nous allons avoir besoin d’accéder à la structure de données contenant les messages flash).

Par exemple, imaginons un service qui utilisera le service EntityManagerInterface et ExempleRepository. Je peux le créer simplement ainsi :

#src/Service/ExempleService.php

class ExempleService {

    public function __construct(
        private EntityManagerInterface $entityManager,
        private ExempleRepository $exempleRepository
    ) {}

    public function maFonction() : void {
        $ex = $this->exempleRepository->findAll();
        $this->entityManager->persist(...)
    }
}

La syntaxe avec private utilisée pour définir ce constructeur ne doit pas vous être inconnue. Pour rappel, avec cette syntaxe, on signifie qu’on souhaite enregistrer directement les paramètres comme attributs de la classe (en précisant, au passage, leur visibilité). Ainsi, il n’y a pas de code basique à écrire pour déclarer manuellement ces attributs et les affecter dans le constructeur. Le corps du constructeur peut rester vide. En fait, on peut voir cela comme une version compacte entre la déclaration et l’affectation d’un attribut de la classe.

Ainsi, si j’ai une classe avec le constructeur suivant :

class Exemple {

    public function __construct(
        private string $attr
    ) {}
}

J’ai accès à un attribut $this->attr dans ma classe (qui sera affecté lors de la construction de l’objet). On peut bien entendu mettre d’autres niveaux de visibilité, comme public ou bien protected.

Concernant le service, une fois votre classe construite, vous pouvez l’injecter où vous le souhaitez (dans un contrôleur, ou bien dans un autre service) et vous en servir :

#[Route('/exemple', name: 'route_exemple', methods: ["GET"])]
public function methodeExemple(ExempleService $exempleService): Response
{
    $exempleService->maFonction();
}

Dans notre nouveau service, nous allons utiliser certaines fonctions de l’objet de type File pour nous permettre de facilement déplacer le fichier uploadé par l’utilisateur dans notre système :

// $fichier est le fichier uploadé
// $destination est le dossier vers lequel le fichier sera déplacé
do {
    $fileName = bin2hex(random_bytes(16)) . '.' . $fichier->guessExtension();
} while(file_exists($destination.'/'.$fileName));
$fichier->move($destination, $fileName);

L’utilisation de bin2hex(random_bytes(16)) permet de générer un identifiant (chaîne de caractère) aléatoire. La méthode vérifie l’unicité du fichier par une boucle (même si des collisions sont extrêmement peu probables). Ainsi, on devrait obtenir un nom de fichier unique, pour l’image de profil de l’utilisateur. La méthode guessExtension permet d’obtenir l’extension du fichier (png, jpg…). Enfin, move déplace le fichier vers un dossier de destination.

Ensuite, le service UserPasswordHasherInterface permet de hacher un mot de passe, en utilisant l’algorithme configuré dans security.yaml (dans notre cas auto, donc, le meilleur algorithme de hachage disponible).

// $this->passwordHasher est de type UserPasswordHasherInterface
// $utilisateur est de type UserInterface (classe implémentée par notre entité Utilisateur)
$hashed = $this->passwordHasher->hashPassword($utilisateur, $plainPassword);
$utilisateur->setPassword($hashed);

Concernant le dossier de destination de l’image, c’est aussi un paramètre que nous allons définir hors de notre classe et qui pourra être injecté automatiquement. La syntaxe diffère un peu dans ce cas.

Tout d’abord, il faut se rendre dans config/services.yaml et définir le paramètre souhaité dans la section parameters :

parameters:
    exemple_param: 'coucou!'

Puis, quand on souhaite l’injecter dans notre service, comme pour les autres injections, cela se passe dans le constructeur, mais cette fois, en utilisant l’attribut #[Autowire('%nom_param%')]. Par exemple :

class ExempleService {
    // Les `%` autour du nom du paramètre sont importants!
    public function __construct(
        #[Autowire('%exemple_param%')] private string $exempleParam,
        private AutreServiceInterface $autreService
    ) {}
}

Enfin, dans le contrôleur, vous aurez besoin d’aller chercher les champs plainPassword et fichierPhotoProfil dans l’objet du formulaire, pour les transmettre à votre service. Pour cela, vous pouvez utiliser la méthode getData comme dans l’exemple qui suit :

$valeurChamp = $form->get("monChamp")->getData();
  1. Dans public, créez le chemin de dossiers img/utilisateurs/uploads. Vous vous demandez peut-être pourquoi nous plaçons les images uploadées dans public et pas dans assets. C’est parce que ces images ne sont pas des ressources fixes liées au fonctionnement du site (comme le css, le js, ou les images “fixes” comme les logos, notre image anonyme…). Elles n’ont pas pour vocation d’être changées par le développeur et n’ont pas besoin d’être versionnées. Dans ce TP, nous uploadons les images de profil dans le dossier public, car elles doivent être accessibles publiquement via le navigateur via une URL (qui ne passe pas par une route de notre back-end). Par contre, si nous devions faire uploader des fichiers sensibles et/ou uniquement visibles par certaines personnes, il ne faudrait surtout pas les uploader dans public, mais dans un dossier protégé, inaccessible via le navigateur (c’est alors l’application web côté back-end qui déciderait quand, comment et à qui renvoyer le fichier, avec une route dédiée).

  2. En vous plaçant à la racine du projet, donnez les droits nécessaires au serveur pour qu’il puisse créer et éditer des fichiers à l’intérieur de ce dossier :

    chown -R 1000:www-data ./public/img/utilisateurs/uploads
    chmod g+w ./public/img/utilisateurs/uploads
    
  3. Dans le fichier config/services.yaml, ajoutez un paramètre dossier_photo_profil ayant pour valeur : '%kernel.project_dir%/public/img/utilisateurs/uploads'. La partie %kernel.project_dir% désigne la racine du projet. C’est un paramètre défini par Symfony (notez qu’en utilisant % on peut utiliser la valeur d’autres paramètres pour construire un autre paramètre, comme c’est le cas ici.).

  4. Créez un dossier Service dans src puis créez et complétez la classe suivante à l’intérieur de ce nouveau répertoire :

    namespace App\Service;
    
    use App\Entity\Utilisateur;
    use Symfony\Component\DependencyInjection\Attribute\Autowire;
    use Symfony\Component\HttpFoundation\File\UploadedFile;
    use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
    
    class UtilisateurManager {
    
        public function __construct(
            //Injection du paramètre dossier_photo_profil
            //Injection du service UserPasswordHasherInterface
        ){}
    
        /**
         * Hache le mot de passe puis l'affecte au champ correspondant dans la classe de l'utilisateur
         */
        private function hacherMotDePasse(Utilisateur $utilisateur, ?string $plainPassword) : void {
            //On hache le mot de passe en clair
            //On met à jour l'attribut "password" de l'utilisateur
        }
    
        /**
         * Sauvegarde l'image de profil dans le dossier de destination puis affecte son nom au champ correspondant dans la classe de l'utilisateur
         */
        private function sauvegarderPhotoProfil(Utilisateur $utilisateur, ?UploadedFile $fichierPhotoProfil) : void {
            if($fichierPhotoProfil != null) {
                //On configure le nom de l'image à sauvegarder
                //On la déplace vers son dossier de destination
                //On met à jour l'attribut "nomPhotoProfil" de l'utilisateur
            }
        }
    
        /**
         * Réalise toutes les opérations nécessaires avant l'enregistrement en base d'un nouvel utilisateur, après soumission du formulaire (hachage du mot de passe, sauvegarde de la photo de profil...)
         */
        public function processNewUtilisateur(Utilisateur $utilisateur, ?string $plainPassword, ?UploadedFile $fichierPhotoProfil) : void {
            //On hache le mot de passe
            //On sauvegarde (et on déplace) l'image de profil
        }
    
    }
    
  5. Dans votre route inscription, faites en sorte de gérer la soumission du formulaire et de sauvegarder l’utilisateur construit à partir du formulaire dans la base de données. Cependant, avant de sauvegarder l’utilisateur, il faudra extraire plainPassword puis fichierPhotoProfil et enfin utiliser votre nouveau service avec sa méthode processNewUtilisateur.

    Enfin, lorsque l’utilisateur est enregistré, il faut ajouter un message flash (type : success) “Inscription réussie !” (on rappelle qu’il est possible d’utiliser addFlash dans un contrôleur) puis le rediriger vers la route feed (méthode redirectToRoute qui retourne un objet Response).

    Encore une fois, vous pouvez vous inspirer (en partie) du code de votre route feed, pour la création d’une publication.

  6. Testez d’inscrire un utilisateur en respectant les différentes contraintes et en précisant une image de profil. Vérifiez que :

    • Vous êtes bien redirigé vers la page principale et le message flash “Inscription réussie” apparaît.

    • L’utilisateur est enregistré dans la base de données et aucun champ n’est null.

    • L’image de profil a bien été uploadée dans le dossier public/img/utilisateurs/uploads.

  7. Tentez d’inscrire un nouvel utilisateur, sans image de profil (cela doit fonctionner).

  8. Testez les différents cas d’erreurs possibles en ne respectant pas certaines contraintes (sur le login, le mot de passe, le format de l’image de profil…) et vérifiez que les messages d’erreur s’affichent bien au niveau des champs concernés.

  9. En modifiant InscrireUtilisateurType, ajoutez des contraintes “clientes” (avec attr, comme vous l’avez fait dans PublicationType) qui permettront de générer des attributs HTML sur les balises du formulaire pour que le navigateur vérifie certaines contraintes côté client :

    • minlength et maxlength sur le login et le mot de passe.

    • pattern sur le mot de passe avec valeur : "^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d\w\W]{8,30}$"

    • accept sur la photo de profil, avec pour valeur : "image/jpeg, image/png"

     $builder
         ->add('champ1', MonType::class, [
             'attr' => [
                 //Il faut gérer les contraintes ici...
             ]
         ])
     ;
    

Traitement du formulaire - Interfaçage du service

Comme vous l’aurez peut-être constaté, certains services comme EntityManagerInterface s’utilisent au travers d’une interface, et non pas d’une classe concrète. Pour permettre une meilleure modularité et substitutions des services de votre application (si vous décidez de changer la classe qui assure tel ou tel service), il est plus judicieux de définir vos services en les accompagnant d’une interface puis d’injecter et utiliser l’interface (dans les contrôleurs et autres) plutôt que la classe concrète.

Dans ce cas, il faut éditer le fichier config/services.yaml afin de préciser quelle est la classe concrète actuellement liée à cette interface. Ce fichier permet de configurer différents aspects des services de notre application (par exemple, quand on a besoin d’injecter des paramètres de notre application dans certains services…).

Imaginons que je crée une interface ExempleServiceInterface pour mon service défini dans un exemple précédent :

#src/Service/ExempleServiceInterface.php
interface ExempleServiceInterface {
    public function maFonction() : void;
} 

#src/Service/ExempleServiceA.php
class ExempleServiceA implements ExempleServiceInterface {
    ...
}

S’il n’y a qu’une seule classe qui implémente l’interface en question, Symfony va la trouver automatiquement et faire le lien.

Ainsi, quand je veux injecter ce service, je peux maintenant utiliser son interface (ExempleA sera alors utilisé) :

 #[Route('/exemple', name: 'route_exemple', methods: ["GET"])]
public function methodeExemple(ExempleServiceInterface $exempleService): Response
{
    $exempleService->maFonction();
}

Cependant, si deux classes (ou plus) implémentent la même interface (ce qui peut arriver : par exemple, une classe gérant la connexion à une base de données MySQL contre une classe gérant la connexion à une base de données Oracle), Symfony ne saura donc pas laquelle choisir.

Par exemple, si j’ai aussi :

#src/Service/ExempleServiceB.php
class ExempleServiceB implements ExempleServiceInterface {
    ...
}

Dans ce cas, il faut éditer le fichier config/services.yaml ainsi, en précisant le service qu’on souhaite associer à l’interface :

#Dans config/services.yaml
parameters:
    ...
services:
    #Je crée le service abstrait ExempleServiceInterface qui se réfère au service concret ExempleServiceB
    App\Service\ExempleServiceInterface: '@App\Service\ExempleServiceB'
    

Si jamais je souhaite changer de classe concrète, j’ai juste à changer le fichier services.yaml. Il n’y aura pas de changements à faire dans les classes qui utilisaient mon service jusqu’à présent.

Il peut être intéressant de créer une interface pour son service, même si on ne prévoit pas immédiatement d’avoir plusieurs classes concrètes différentes pour ce service. C’est une bonne pratique car on injectera alors systématiquement le service via l’interface, dans les contrôleurs, dans les autres services. Si jamais on vient finalement à ajouter une autre classe concrète implémentant la même interface (variante de ce service), il suffira alors d’éditer services.yaml sans toucher au reste du code si on souhaite en changer.

  1. Créez une interface UtilisateurManagerInterface (toujours dans le dossier Service) contentant la signature de la méthode processNewUtilisateur puis faites-la implémenter à UtilisateurManager. Pour rappel, dans PHPStorm, vous pouvez faire cela automatiquement : clic droit sur le nom de classe (à l’intérieur de la classe) UtilisateurManager > Refactor > Extract Interface.

  2. Dans votre route inscription, utilisez votre nouvelle interface à la place du service concret.

  3. Rechargez votre page et vérifiez que l’affichage des erreurs fonctionne toujours.

  4. Pour bien vérifier que vous avez compris les explications précédentes, ajoutez une classe TestService.php dans le dossier Service qui implémente aussi UtilisateurManagerInterface. Laissez le corps de la méthode processNewUtilisateur vide. Essayez de recharger la page d’inscription. Symfony renvoie alors une erreur qui explique qu’il n’a pas pu trouver le service en question. Comme dans l’exemple, éditez le fichier services.yaml afin de faire pointer le service UtilisateurManagerInterface vers UtilisateurManager. Testez que tout fonctionne à nouveau, puis supprimez les modifications apportées dans services.yaml lors de cet exercice et supprimez également TestService.php.

Connexion et déconnexion

Formulaire de connexion

Concernant la connexion, nous n’aurons pas à créer de classe pour le formulaire. En fait, il y a juste besoin de créer un formulaire HTML classique en précisant des valeurs précises pour les champs correspondants au login et au mot de passe. Aussi, il n’y aura pas besoin de gérer explicitement le traitement du formulaire, Symfony se charge de vérifier le mot de passe et de récupérer les informations sur l’utilisateur.

Tout d’abord, on commence par créer une route, accès en GET et en POST :

#[Route('/exempleConnexion', name: 'exempleConnexion', methods: ['GET', 'POST'])]
public function connexion() : Response {
    return $this->render('monFormulaireDeConnexion.html.twig');
}

Ensuite, on crée le template Twig correspondant, contenant un formulaire de connexion :


<form action="{{ path('exempleConnexion') }}" method="post">
    <input type="text" name="_username" required/>
    <input type="password" name="_password" required/>
    <button type="submit">Connexion</button>
    <input type="hidden" name="_csrf_token" data-controller="csrf-protection" value="{{ csrf_token('authenticate') }}">
</form>

Enfin, il ne reste plus qu’à éditer le fichier config/packages/security.yaml :

security:
    ...
    firewalls:
       ...
        main:
            ...

            form_login:
                login_path: exempleConnexion
                check_path: exempleConnexion
                default_target_path: maRoute
                always_use_default_target_path: true
                enable_csrf: true
  1. Dans UtilisateurController, créez une route connexion de chemin /connexion, similaire à l’exemple donné plus tôt. Le template à préciser est utilisateur/connexion.html.twig. Nous allons le créer à la prochaine étape.

  2. Créez le template connexion.html.twig dans le dossier templates/utilisateur. Comme toutes nos pages, ce template étend base.html.twig et récrit certains blocks. La page doit avoir pour titre “Connexion”.

  3. Concernant le contenu de la page, importez et complétez le code du formulaire suivant :

    <main>
        <form action="...A compléter..." method="post" class="basic-form center">
            <fieldset>
                <legend>Connexion</legend>
                <div class="access-container">
                    <label for="login">Login</label>
                    <input id="login" type="text" name="_username" required/>
                </div>
                <div class="access-container">
                    <label for="password">Mot de passe</label>
                    <input id="password" type="password" name="_password" required/>
                </div>
                <input type="hidden" name="_csrf_token" data-controller="csrf-protection" value="...A compléter...">
                <button type="submit" class="basic-form-submit">Se connecter</button>
            </fieldset>
        </form>
    </main>
    
  4. Mettez à jour le fichier config/packages/security.yaml de manière adéquate. L’utilisateur doit être redirigé vers la route feed après s’être connecté.

  5. Mettez à jour le template base.html.twig afin d’inclure un lien vers votre page de connexion dans le menu de navigation.

  6. Accédez à votre page de connexion et tentez de vous connecter avec un compte existant, mais avec un mauvais mot de passe. Normalement, vous devriez rester sur le formulaire (aucun message d’erreur ne s’affiche, c’est normal pour le moment).

  7. Actuellement, si l’utilisateur se trompe dans son mot de passe, quand le formulaire est rechargé, le champ du login n’est pas prérempli. Il est possible d’améliorer cet aspect en récupérant le dernier login avec lequel l’utilisateur a tenté de se connecter. Pour cela, on utilise le service AuthenticationUtils :

    use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;   
    #[Route('/exempleConnexion', name: 'exempleConnexion', methods: ['GET', 'POST'])]
    public function connexion(AuthenticationUtils $authenticationUtils) : Response {
        $lastUsername = $authenticationUtils->getLastUsername();
        ....
    }
    

    Il est alors possible de simplement passer cette donnée au template et de l’utiliser pour préciser l’attribut value du champ correspondant au login. Ce champ sera alors prérempli, ce qui est pratique quand on doit soumettre de nouveau le formulaire, en cas d’erreur de mot de passe.

    Effectuez les modifications nécessaires dans votre route connexion et dans le template connexion.html.twig pour que le champ du login soit automatiquement prérempli avec le dernier login avec lequel l’utilisateur a essayé de se connecter.

  8. Essayez maintenant de vous connecter avec un bon mot de passe, vous devriez alors être redirigé vers la page principale (et votre pseudonyme devrait apparaître dans la barre de débogage). Nous allons gérer les différents messages informatifs plus tard.

Déconnexion

Maintenant, nous devons gérer la déconnexion. Cela est encore plus simple, car il n’y a même pas de méthode de route ou de formulaire à créer. Il suffit d’éditer deux fichiers :

Par la suite, quand on voudra générer le lien de la route de déconnexion du côté d’un template twig, il suffira d’utiliser la fonction prédéfinie logout_path() qui génère le lien adéquat (nous n’utiliserons donc pas path, dans ce cas).

Nous n’avons pas beaucoup évoqué la notion de firewall jusqu’ici. Un firewall est la partie de Symfony qui permet de vous authentifier, de savoir qui vous êtes selon les parties du site (les pages) auxquelles vous tentez d’accéder. Les firewall dev est un “faux” firewall utilisé en local pour avoir accès aux outils de développement (entre autres) sur la page web, notamment. Par défaut, vous possédez donc un seul véritable firewall main qui est configuré pour traiter l’accès à toutes les pages du site. Généralement, c’est amplement suffisant, mais on pourrait aussi imaginer avoir un firewall nommé api si le site proposait également une api qui permettrait d’authentifier les utilisateurs différemment, les déconnecter différemment, etc. Et il serait utilisé pour toutes les routes qui commencent par /api/, par exemple. Pour configurer les routes pour lequel un firewall est utilisé, on utilise le paramètre pattern. Comme vous le voyez, main n’en possède pas : par défaut, il permet donc de traiter toutes les routes.

  1. Paramétrez la route de déconnexion dans le fichier config/packages/security.yaml. Elle doit avoir pour chemin /deconnexion. Après s’être déconnecté, l’utilisateur doit être redirigé sur la route feed.

  2. Modifiez le fichier config/routes.yaml afin d’autoriser seulement la méthode POST lorsque la route de déconnexion est utilisée.

  3. Dans votre template base.html.twig, ajoutez le formulaire suivant dans le menu de navigation en complétant action de manière adéquate pour pointer sur votre route de déconnexion (en utilisant la fonction logout_path) :

     <form method="post" action="A compléter">
         <button id="btn-deconnexion">Déconnexion</button>
     </form>
    
  4. Tentez de vous connecter/déconnecter. Vous pouvez vérifier votre état dans la barre de débogage de Symfony : si votre pseudonyme apparait, vous êtes connecté. Si à la place vous avez n/a, vous êtes déconnecté.

Sécurisation d’action

Bon, pour le moment, vous ne voyez aucune différence (du point de vue d’un utilisateur normal) qui indique que vous êtes bien connecté ! Nous allons effectuer quelques modifications pour changer certains éléments affichés selon l’état de l’utilisateur (connecté ou non).

Dans vos templates Twig, vous pouvez utiliser la fonction is_granted(role) pour vérifier le rôle d’un utilisateur (par exemple, dans une structure conditionnelle) et ainsi afficher ou non certaines sections.

Dans notre cas, nous pouvons par exemple vérifier si l’utilisateur a le rôle ROLE_USER qui est le rôle de base que tous les utilisateurs connectés possèdent (donnés dans la classe Utilisateur) :


{% if is_granted('ROLE_USER') %}
<!-- Utilisateur connecté -->
{% else %}
<!-- Utilisateur non connecté -->
{% endif %}

On peut bien sûr utiliser not is_granted('...') pour vérifier qu’un utilisateur ne possède pas un rôle.

Il est aussi possible d’utiliser app.user qui ne vérifie pas les permissions mais qui renvoie l’utilisateur courant (donc, s’il n’y en a pas, l’application renvoie null) ou bien is_granted("IS_AUTHENTICATED") qui vérifie que l’utilisateur est connecté, sans vérifier de rôles en particulier. Cette dernière approche est utile si on ne donne pas le ROLE_USER par défaut, comme ce qui est fait dans l’application quand on génère l’entité utilisateur avec make:user.

Du côté des contrôleurs, il est aussi possible de limiter l’accès à des routes à certains rôles en utilisant l’attribut #[IsGranted(role)] au-dessus d’une route :

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

#[IsGranted('ROLE_ADMIN')]
#[Route('/exemple', name: 'route_exemple', methods: ["GET"])]
public function methodeExemple(): Response
{
    //Seuls les utilisateurs possédant le rôle `ROLE_ADMIN` ont accès à cette route.
}

Si jamais il y a plusieurs méthodes autorisées pour une route (par exemple, GET et POST) et que l’on souhaite seulement interdire l’accès à cette route pour une méthode donnée, on peut vérifier quelle est la méthode utilisée en spécifiant l’arguments methods de l’attribut IsGranted:

#[IsGranted('ROLE_USER', methods: ['POST'])]
#[Route('/exemple', name: 'route_exemple', methods: ["GET", "POST"])]
public function methodeExemple(Request $request): Response
{
    //Tous les utilisateurs (connectés ou non) peuvent accéder à cette route avec la méthode 'GET' mais seuls les utilisateurs qui ont le rôle 'ROLE_USER' (donc, tous les utilisateurs connectés) peuvent déclencher cette route avec la méthode 'POST'.
}

Alternativement, si on ne souhaite pas utiliser IsGranted, il est possible de faire la même chose avec la méthode isMethod(method) de l’objet Request et refuser l’accès à la route en utilisant denyAccessUnlessGranted(role) si l’utilisateur ne possède pas le rôle spécifié :

#[Route('/exemple', name: 'route_exemple', methods: ["GET", "POST"])]
public function methodeExemple(Request $request): Response
{
    if($request->isMethod('POST')) {
        $this->denyAccessUnlessGranted('ROLE_USER');
        //Si l'utilisateur n'a pas le rôle 'ROLE_USER' l'exécution s'arrête et une page d'erreur est affichée.
    }

    //...
}

Notez qu’il est aussi possible de simplement faire de routes séparées avec le même chemin, une en GET et une en POST et utiliser l’attribut IsGranted :

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

#[Route('/exemple', name: 'route_exemple_get', methods: ["GET"])]
public function methodeExempleGet(): Response
{
    //Tout le monde a accès à cette route.
}

#[IsGranted('ROLE_USER')]
#[Route('/exemple', name: 'route_exemple_post', methods: ["POST"])]
public function methodeExemplePost(): Response
{
    //Seuls les utilisateurs connectés ont accès à cette route.
}

Cependant, comme nous l’avons vu, dans le cadre d’un formulaire, nous pouvons regrouper GET et POST dans la même méthode. Dans le cas particulier où l’on souhaite afficher une page formulaire, mais réserver son traitement à un rôle particulier, on utilisera donc une des techniques présentées ci-dessus. Néanmoins, ce cas de figure ne se produit pas souvent, mais c’est le cas pour notre route feed, par exemple : on souhaite pouvoir afficher la page à tout le monde, mais réserver la création d’une publication aux utilisateurs connectés.

  1. Adaptez le template base.html.twig afin que les liens menant vers la page Inscription et Connexion ne soient affichés que si l’utilisateur n’est pas connecté. À l’inverse, faites en sorte que le formulaire de déconnexion ne soit visible que par les utilisateurs connectés.

  2. Adaptez votre route feed pour autoriser la création d’une publication seulement aux utilisateurs connectés. Attention cependant, tout le monde doit pouvoir voir la liste des publications.

  3. Enfin, modifiez votre template feed.html.twig pour afficher le formulaire de création d’une publication uniquement aux utilisateurs connectés.

  4. Vérifiez que tout s’affiche comme attendu selon votre état (connecté/déconnecté).

Notification de connexion et déconnexion

Comme notre système de connexion/déconnexion est géré par Symfony, nous ne pouvons pas ajouter de messages flash comme pour une route normale. Mais heureusement, pour cela, il y a les événements ! Durant le cycle de vie de l’application, certains événements comme la connexion ou la déconnexion de l’utilisateur peuvent être captés par le développeur afin de réaliser des actions complémentaires. Les classes qui traitent ces événements sont appelées EventListener.

Ces classes sont regroupées dans le dossier src/EventListener de l’application et se présentent ainsi :

namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

class MonEventListener {

    public function __construct(/* Injection de dépendances possible ici*/){}

    #[AsEventListener]
    public function onMonEvent(MonEvent $event) {
        //Méthode déclenchée quand un événement de type `MonEvent` est déclenché
    }

}

Dans notre cas, trois événements vont nous intéresser :

À l’aide de ces événements, nous allons pouvoir ajouter des messages flash pour améliorer l’ergonomie de notre site après l’exécution de ces actions. Pour cela, nous pouvons utiliser le service RequestStack qui permet (entre autres) d’ajouter des messages flash :

$flashBag = $this->requestStack->getSession()->getFlashBag();
$flashBag->add(categorie, message);
  1. Créez un dossier EventListener dans src.

  2. Dans votre nouveau dossier, créez la classe AuthenticationEventListener dans lequel vous injecterez le service RequestStack et qui devra posséder trois méthodes :

    • Une méthode permettant de gérer l’événement LoginSuccessEvent et qui ajoute le message flash de type success : “Connexion réussie !”.

    • Une méthode permettant de gérer l’événement LoginFailureEvent et qui ajoute le message flash de type error : “Login et/ou mot de passe incorrect !”.

    • Une méthode permettant de gérer l’événement LogoutEvent et qui ajoute le message flash de type success : “Déconnexion réussie !”.

    Les imports à faire dans votre classe :

     use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
     use Symfony\Component\HttpFoundation\RequestStack;
     use Symfony\Component\Security\Http\Event\LoginFailureEvent;
     use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
     use Symfony\Component\Security\Http\Event\LogoutEvent;
    
  3. Vérifiez que vos messages s’affichent bien dans les trois situations.

Concernant le message d’erreur en cas d’identifiants invalides, nous aurions pu récupérer le message d’erreur puis l’afficher directement sur le formulaire via l’objet AuthenticationUtils injecté à l’action de la route de connexion, au lieu d’utiliser des messages flashs.

Sécurisation (suite)

Enfin, il reste un dernier problème à régler : malgré le fait que l’accès à la page “inscription” et la page “connexion” soit masqué sur notre page quand un utilisateur est connecté il peut toujours y accéder en tapant l’URL de la route (vous pouvez essayer), ce qui n’est pas normal.

Il est possible d’utiliser l’attribut #[IsGranted] pour utiliser une condition plus complexe et vérifier, par exemple, qu’un utilisateur donné n’a pas un rôle spécifique :

use Symfony\Component\ExpressionLanguage\Expression;

#[IsGranted(new Expression("!is_granted('ROLE_USER')"))]
#[Route('/exemple', name: 'route_exemple', methods: ["GET"])]
public function methodeExemple(): Response
{
    //Seuls les utilisateurs ne possédant pas le rôle `ROLE_USER` ont accès à cette route.
}

Contrairement à l’utilisation habituelle que nous faisions de #[IsGranted], on peut utiliser certaines fonctions et des opérateurs conditionnels, comme dans un template Twig et ainsi faire une condition du style !is_granted('ROLE_USER'), c’est-à-dire “n’est pas authentifié”. Une autre valeur ayant le même effet (dans notre cas) est IS_AUTHENTICATED (à la place du rôle). Cela peut être utile de l’utiliser si on ne donne pas par défaut le rôle ROLE_USER à tous nos utilisateurs connectés.

Cependant, il faut réfléchir en termes d’ergonomie : est-ce qu’un utilisateur connecté tentant d’accéder à ces pages doit recevoir une page d’erreur ou bien être redirigé vers une autre page ? Dans notre cas, nous allons plutôt privilégier la seconde solution. Pour cela, il suffit de regarder les permissions de l’utilisateur à l’intérieur de la route, avec isGranted :

#[Route('/exemple', name: 'route_exemple', methods: ["GET"])]
public function methodeExemple(): Response
{
    //Déjà connecté...
    if($this->isGranted('ROLE_USER')) {
        return $this->redirectToRoute('maRoute');
    }
}

Cependant, gardez en mémoire l’utilisation de IsGranted avec une Expression, nous en aurons besoin pour le TD3 !

  1. Si un utilisateur déjà connecté tente d’accéder aux routes inscription ou connexion, redirigez-le vers la route feed (page principale).

  2. Vérifiez que vous êtes effectivement redirigé vers la page principale si vous tentez d’accéder à ces routes (en modifiant directement l’URL) tout en étant connecté.

Finitions

Maintenant, il ne reste plus qu’à finaliser notre site, notamment en reliant nos entités Utilisateur et Publication afin d’enregistrer (et d’afficher) les auteurs d’une publication ! Nous verrons également comment créer une page listant les publications d’un utilisateur, et aussi comment personnaliser certaines pages d’erreurs.

Auteur d’une publication

Avec doctrine, pour associer deux entités, il suffit de créer un attribut faisant référence à une autre classe et utiliser les attributs nécessaires pour préciser le sens et la cardinalité de cette association. Il est aussi possible de paramétrer la stratégie en cas de suppression (mettre à null les colonnes faisant référence ou bien supprimer les entités associées…).

Voici la liste des attributs disponibles, qui devraient notamment vous rappeler celles utilisées avec hibernate en base de données en 2ème année :

La configuration des attributs présentée implique un système bidirectionnel où l’entité A connait l’entité B et inversement. Il est bien entendu possible de faire un système unidirectionnel. Pour cela, il faut placer seulement l’attribut dans une des entités concernées, de ne pas spécifier les paramètres mappedBy et inversedBy et de rajouter l’attribut #[JoinColumn(name: 'parent_id', referencedColumnName: 'id')]parent_id référence le nom de l’attribut “clé étrangère” (qui va être créé) et referencedColumnName le nom de la clé primaire de la table référencée. Il est aussi possible de créer des auto-références (référence vers la même entité).

Attention, au niveau des attributs des relations OneToOne ou ManyToOne, une clé étrangère est générée dans la base :

Vous pouvez également consulter une documentation plus complète.

Fort heureusement, Symfony nous permet de mâcher ce travail en utilisant encore une fois la commande make:entity en mettant à jour notre entité cible. Il faut simplement :

  1. Déclarer une nouvelle propriété

  2. Quand on demande le type, sélectionner au choix : ManyToOne, OneToMany, ManyToMany ou OneToOne. Il est également possible de sélectionner relation : cela affichera des informations pour vous aider à choisir parmi les 4 valeurs précédentes.

  3. On précise l’entité cible (avec le nom de sa classe).

  4. Selon la relation, on précise ensuite si l’entité cible (clé étrangère) peut être nulle ou non, par exemple, dans le cas de OneToMany.

  5. On nous demande ensuite si la relation doit être bidirectionnelle, c’est-à-dire s’il faut aussi mettre à jour l’entité avec laquelle on forme une relation. Si on sélectionne oui, on nous demande aussi si les entités orphelines doivent être supprimées (par exemple dans notre cas, si on retire une publication à un auteur…).

  1. Supprimez toutes les publications stockées dans votre base (car, n’ayant pas d’auteurs, cela va générer des erreurs quand on va forcer les publications à avoir un auteur…).

  2. En utilisant la commande make:entity, mettez à jour votre entité Publication en ajoutant une propriété auteur qui sera un Utilisateur :

    • Trouvez le bon type à utiliser. Si vous hésitez, précisez le type relation, Symfony vous aidera alors à choisir.

    • La propriété ne peut pas être nulle.

    • La relation est bidirectionnelle (on doit avoir la liste des publications côté utilisateurs). Nommez cette propriété publications côté Utilisateur.

    • Activez la suppression des entités orphelines.

  3. Observez le code généré dans Publication et Utilisateur.

  4. Modifiez la classe Publication pour faire en sorte que quand un utilisateur est supprimé dans la base, ses publications soient toutes supprimées (correspondant à une contrainte ON DELETE CASCADE). Il vous suffit d’éditer un attribut (annotation) déjà existant…

    Vous vous faites peut-être la réflexion que cette contrainte semble redondante avec la suppression des entités orphelines. En fait, onDelete: "CASCADE" va créer une contrainte au niveau de la base de données. L’option orphanRemoval (du côté Utilisateur) quant à elle agit au niveau de l’application (de l’ORM) : si on retire la publication à l’utilisateur (depuis sa collection de publications) la publication sera supprimée, car considérée comme orpheline (dans ce contexte, l’entité ne peut pas être possédée par plusieurs entités et doit forcément avoir un auteur). Mais si on supprime l’utilisateur tout court, cela ne supprimera pas les publications, car on ne retire pas vraiment la publication d’un utilisateur dans ce contexte. C’est donc pour cela qu’on ajoute aussi la contrainte ON DELETE CASCADE.

    En résumé :

    • orphanRemoval permet de supprimer une publication si on la retire de l’utilisateur dans l’application (elle devient “orpheline” car elle n’est plus liée à son auteur).
    • ON DELETE CASCADE : permet de supprimer les publications si l’auteur (l’utilisateur) est supprimé. Cela est directement géré au niveau de la base de données.
  5. Mettez à jour la structure de votre base de données avec les commandes make:migration et doctrine:migrations:migrate.

  6. Observez la nouvelle structure de votre base de données depuis votre interface de gestion.

Maintenant que toutes nos publications doivent avoir un auteur, il va falloir un peu modifier notre route feed ainsi que notre template feed.html.twig, afin de prendre en compte les données de l’utilisateur.

L’auteur d’une publication est l’utilisateur actuellement connecté qui envoie le formulaire. Du côté contrôleur, lors de la création d’une nouvelle publication, on peut récupérer l’utilisateur connecté ainsi :

//Dans une méthode d'un contrôleur
$utilisateur = $this->getUser();

Du côté du template, il faut, pour chaque publication :

Pour le dernier point, il y a plusieurs possibilités : utiliser un “if/else”. Utiliser une ternaire (Condition) ? (Statement1) : (Statement2). Définir une variable dans le template avec set

Pour rappel, si une propriété d’un objet est null, alors un test conditionnel “objet.propriete” renvoie simplement false.

Aussi, avec Twig, pour concaténer des valeurs, on utilise le symbole ~ :


{{ "Coucou " ~ prenom ~ " !" }}

Attention de bien respecter un espace avant et après ~.

  1. Mettez à jour votre route feed dans le contrôleur PublicationController afin de récupérer l’utilisateur connecté et de l’affecter comme auteur de la publication avant de l’enregistrer dans la base de données (idéalement, on aurait pu créer un service comme nous l’avions fait pour les utilisateurs, mais là, il s’agit simplement d’une petite ligne de code à ajouter… Mais s’il y avait plus de code à gérer, il faudrait y penser !)

  2. Mettez à jour votre template feed.html.twig pour afficher le login de l’utilisateur et son image de profil sur chaque publication. S’il n’a pas d’image de profil, il faut continuer d’afficher l’image par défaut img/utilisateurs/anonyme.jpg. Pour rappel, avec twig, vous pouvez concaténer deux chaînes de caractères en utilisant ~.

  3. Connectez-vous (si ce n’est pas déjà fait) puis créez de nouvelles publications. Testez avec un compte ayant une image de profil et un autre compte n’en ayant pas. Vérifiez que tout s’affiche correctement.

Tout fonctionne bien, mais il y a néanmoins un petit problème : jetez un œil aux requêtes SQL exécutées lors du chargement du feed, en fouillant dans la barre de débogage (cliquez sur le bouton qui a une forme de base de données). Si vous avez un ensemble de publications avec X auteurs différents, il y a X+1 requêtes exécutées ! Pourquoi ça ?

Quand on utilise un ORM avec des entités en relation, il existe deux modes de chargement de données d’autres entités : le lazy loading et le eager loading. Le lazy loading consiste à ne charger des données que quand on en a besoin alors que le eager loading permet de charger tout d’un coup (avec une seule requête, si possible).

Doctrine utilise notamment une de ses stratégies au niveau des entités en relation. Par exemple, quand on charge l’auteur d’une publication. Par défaut, doctrine utilise le lazy loading pour cet attribut. Cela signifie que :

Ceci est très mauvais niveau performance ! Notamment si on a beaucoup de publications avec des auteurs différents. Et comme a priori, on souhaite pouvoir lire quelques données sur l’auteur à chaque fois qu’on charge une publication. Pour pallier ce problème, il existe deux solutions : activer le eager loading sur les auteurs d’une publication ou bien modifier notre méthode findAllOrderedByDate pour lui dire de charger les données de l’utilisateur.

En utilisant le eager loading :

Attention, cette stratégie (eager loading) peut sembler pertinente dans ce contexte, car nous savons que nous devons afficher les données de l’auteur sur chaque publication. Mais, dans d’autres contextes où ces données ne seraient pas toujours affichées, on pourrait alors préférer le lazy loading pour ne pas charger trop de données d’un seul coup (ce qui peut aussi réduire les performances inutilement, si on n’a pas besoin de lire toutes les données).

Pour changer la stratégie utilisée pour récupérer les données d’une propriété, il suffit de configurer le paramètre fetch (avec EAGER ou LAZY) dans l’attribut gérant la relation. Par exemple :

#[ORM\ManyToOne(fetch: 'EAGER', ...)]
private ?Entite $monEntite = null;

Une autre solution consiste à utiliser le DQL (doctrine query langage) pour exécuter une requête custom qui chargera également les données de l’utilisateur auteur d’une publication dans findAllOrderedByDate :

class PublicationRepository extends ServiceEntityRepository
{

    //...

    public function findAllOrderedByDate() : array {
        $queryBuilder = $this->createQueryBuilder("p")
            ->select("p", "a") //On sélectionne les données de la publication + celles de l'auteur
            ->join("p.auteur", "a")
            ->orderBy("p.datePublication", "DESC");
        $query = $queryBuilder->getQuery();
        return $query->getResult();
    }
}

Pour en savoir plus sur le DQL, vous pouvez consulter cette note complémentaire à la fin du TD.

Cette deuxième approche a l’avantage de sélectionner sur quelles opérations et pour quelles entités spécifiques, on chargera les données, sans l’activer globalement sur toute la ressource. C’est une approche souvent conseillée dans la plupart des frameworks web (qui proposent généralement les deux approches).

  1. Choisissez et implémentez l’une des deux solutions pour éviter de charger les auteurs de publication en mode lazy-loading (soit en configurant la stratégie de l’attribut auteur sur EAGER ou bien en modifiant findAllOrderedByDate dans PublicationRepository).

  2. Rechargez la page principale et vérifiez qu’il n’y a plus qu’une requête exécutée ! Vous pouvez observer le code SQL de cette requête dans l’interface dédiée.

Une stratégie plus poussée nommée EXTRA_LAZY peut aussi être utilisée dans le cadre de collections d’entités, pour ne pas tout charger d’un coup si on la manipule.

Aussi, dans un site concret, on mettrait en place un système de pagination pour charger les publications petit à petit, pour ne pas charger tout à chaque fois (imaginez qu’il y ait 1 million de publications !). Par manque de temps, nous ne le ferons pas dans nos TDs, mais pensez-y si vous développez un site similaire dans le futur. N’oubliez pas que vous pouvez ajouter des méthodes à vos classes de repository et utiliser le DQL pour faire des requêtes plus complexes.

Page d’un utilisateur

Nous allons maintenant créer une page qui regroupera l’ensemble des publications d’un utilisateur précis. À terme, on ajoutera un lien permettant d’accéder à la page d’un utilisateur depuis une publication et un autre dans le menu de navigation afin que l’utilisateur connecté puisse accéder à sa propre page.

Pour récupérer les informations d’un utilisateur précis, on peut utiliser une route paramétrée comme nous l’avons déjà vu : /route/{propriete}/test. On pourrait ensuite alors utiliser le repository de l’entité ciblée puis utiliser findOne ou findOneBy (si la propriété n’est pas la clé primaire) pour retrouver l’entité :

#[Route('/route/{propriete}/test', name: 'route_exemple', methods: ["GET"])]
public function methodeExemple(string $propriete, ExempleRepository $repository): Response
{
    $exemple = $repository->findOneBy(["propriete" => $propriete]);
    if($exemple == null) {
        //Message d'erreur + redirection
    }

    //Traitement normal
}

Si cette méthode est bien valide, Symfony propose une méthode encore plus simple : nommer les attributs de la route paramétrée comme les critères de sélection utilisés lors de la requête SQL, puis laisser doctrine faire le reste.

Par exemple, ce bout de code fait exactement la même chose (en arrière-plan) que le précédent :

#[Route('/route/{propriete:exemple}/test', name: 'route_exemple', methods: ["GET"])]
public function methodeExemple(?Exemple $exemple): Response
{
    if($exemple == null) {
        //Message d'erreur + redirection
    }

    //Traitement normal
}

Dans l’exemple ci-dessus, Symfony va automatiquement utiliser le repository “ExempleRepository” (car il détecte qu’on souhaite trouver une entité Exemple, d’après les paramètres de la méthode) et faire un appel à findOneBy(["propriete" => {propriete}]) (où {propriete} désigne la valeur de l’attribut {propriete} dans l’URL) afin de placer une valeur dans Exemple. Le ? (devant le type) permet d’autoriser une valeur nulle pour l’attribut.

Il faut donc que le paramètre de la route porte exactement le même nom que la propriété visée dans l’entité (ici “propriete”) suivi du nom exact du paramètre correspondant à l’entité dans la fonction (ici “exemple”), et que la requête ne renvoie qu’une seule entité.

Il est tout à fait possible de combiner plusieurs critères de recherche ! Par exemple, si une entité à une clé primaire composée de deux attributs (ou plus) :

#[Route('/route/{critere1:exemple}/test/{critere2:exemple}', name: 'route_exemple', methods: ["GET"])]
public function methodeExemple(?Exemple $exemple): Response
{
    //Execute (en arrière-plan) : $exemple = findOneBy(["critere1" => {critere1}, "critere2" => {critere2}]);
    ...
}

Il est aussi tout à fait possible de chercher automatiquement plus d’une entité à la fois !

#[Route('/entreprise/{id:entreprise}/employes/{id:employe}', name: 'route_entreprise_employe', methods: ["GET"])]
public function employeEntreprise(?Entreprise $entreprise, ?Employe $employe): Response
{
    /* 
    Execute (en arrière-plan):
    - $entreprise = findOneBy(["id" => {id:entreprise}]);
    - $employe = findOneBy(["id" => {id:employe}]);
    */
    ...
}

Bref, dans la plupart des cas, on cherche une seule entité avec un seul paramètre :

#[Route('/livres/{isbn:livre}', name: 'get_livre', methods: ["GET"])]
public function getLivre(?Livre $livre): Response
{
    /* 
    Execute (en arrière-plan):
    - $livre = findOneBy(["isbn" => {isbn}]);
    */
    ...
}

Dans ce contexte, il est important de placer un point d’interrogation ? devant le type des entités, car la requête peut renvoyer null. (Pour rappel, ? placé devant un nom de classe signifie que l’objet est autorisé à être null).

  1. Dans le contrôleur UtilisateurController créez une route (et sa méthode) nommée pagePerso qui doit être déclenchée par les chemins type /utilisateurs/{login:utilisateur}/publications où le login est le login d’un utilisateur. La route est accessible en GET seulement. Vous devez faire en sorte de récupérer (automatiquement, sans utiliser de repository) l’utilisateur correspondant au login passé en paramètre par la route puis :

    • Si l’utilisateur n’existe pas, afficher un message (flash) d’erreur “Utilisateur inexistant” puis rediriger vers la route feed.

    • Si l’utilisateur existe bien, retourner la page générée par le template utilisateur/page_perso.html.twig que nous allons créer juste après. Il faudra passer l’utilisateur que vous avez récupéré en paramètre de ce template.

  2. Créez le template page_perso.html.twig dans le dossier templates/utilisateur. Le contenu de cette page doit être la liste des publications de l’utilisateur. On veut le même style d’affichage que sur la page principale.

    • Importez le template suivant :

      {% extends 'base.html.twig' %} 
      {% block page_title %}Page de <!-- login de l'utilisateur -->{% endblock %}
      {% block page_content %}
      <main>
          <div class="center">
              <p id="titre-page-perso">Page de <!-- login de l'utilisateur --></p>
          </div>
          <div id="feed">
             <!-- Liste des publications de l'utilisateur -->
          </div>
      </main>
      {% endblock %}
      
    • Complétez le template en reprenant le code de la liste des publications depuis feed.html.twig (et l’adapter) pour cette partie. Ce n’est pas très optimisé, car on duplique le code. Nous allons améliorer cet aspect un peu plus tard. On rappelle que, comme on a défini la relation entre publication et utilisateur comme étant bidirectionnelle, on peut accéder à la liste des publications depuis l’utilisateur, qui possède une propriété dédiée.

  3. Accédez aux différentes pages personnelles de vos utilisateurs pour vérifier que tout fonctionne.

Vous avez sans doute remarqué que, dans les pages des utilisateurs, les publications ne sont pas triées par ordre décroissant des dates de publications contrairement à la page principale. Ici, vous avez directement utilisé la propriété utilisateur.publications qui n’applique pas de tri (par défaut).

Pour remédier à ce problème, il y a deux solutions possibles :

Vous connaissez déjà la méthode path pour créer une URL depuis le nom d’une route dans un template Twig. Mais comment faire quand le chemin de la route contient des paramètres, comme pour les pages des utilisateurs ? Il suffit d’ajouter les paramètres correspondants à path :


<a href="{{ path('route_exemple', {'propriete': 'coucou'}) }}">Exemple</a>

Donc, si la route route_exemple possède pour chemin /route/{propriete}/test l’exemple ci-dessus générera :

<a href="/route/coucou/test">Exemple</a>

Rappelons que Symfony met à disposition un objet app.user représentant l’utilisateur connecté. On peut donc accéder à ses propriétés, par exemple app.user.id, etc…

  1. Faites en sorte que la liste des publications de chaque utilisateur soit triée (en utilisant la méthode de votre choix). Vérifiez en chargeant la page personnelle d’un utilisateur.

  2. Modifiez feed.html.twig et page_perso.html.twig afin d’inclure sur chaque publication un lien vers la page personnelle de l’auteur de la publication au niveau de son image de profil. L’élément <a></a> est déjà présent et entoure l’élément <img>, il faut simplement compléter la partie href.

  3. Modifiez base.html.twig afin d’ajouter un lien “Ma page” dans le menu de navigation. Ce lien doit pointer vers la page personnelle de l’utilisateur connecté. Ce lien ne doit être visible qu’aux utilisateurs connectés.

  4. Rechargez la page principale et testez vos nouveaux liens.

Comme évoqué plus tôt, dans un site concret, on aurait plutôt un système de pagination plutôt que de charger toutes les publications de l’utilisateur d’un seul coup, avec une requête dédiée (on pourrait alors éventuellement se passer de l’attribut publications dans la classe Utilisateur, ou bien l’utiliser différemment).

Inclure des templates

Nous avons dupliqué le code permettant d’afficher la liste des publications dans feed.html.twig et page_perso.html.twig : ce n’est pas bon !

Une autre fonctionnalité de Twig que nous n’avons pas abordé jusqu’ici est l’inclusion de template : il est possible d’inclure le code d’un template dans un autre template. Ce mécanisme est différent de l’extension de template que nous utilisions jusqu’ici et qui consistait à “hériter” du code d’un template et redéfinir certaines parties. L’inclusion de template se rapproche plus d’une fonction qu’on peut réutiliser dans plusieurs autres templates. De plus, un peu comme une fonction, on peut passer des paramètres aux templates inclus.

L’instruction pour inclure un template est la suivante :


{{ include(cheminTemplate, {'param1': ..., 'param2': ... }) }}

Imaginons par exemple que je définisse le template livres/livres.html.twig suivant, permettant de générer le code HTML pour présenter les détails d’un livre :


<h2>Livre: {{ livre.tire }}</h2>
<p>Année: {{ livre.anneePublication }}</p>
<p>Auteur: {{ livre.auteur }}</p>

Je peux inclure ce template dans un autre template à tout moment, en passant le livre en paramètre. Par exemple, imaginons que je définisse un template best_seller.html.twig qui liste les trois livres les plus vendus cette année. Je possède un objet “top” contenant quatre propriétés : annee, livre1, livre2 et livre3.


<h1>Best-sellers de {{ top.annee }}:</h1>
<p>Top 1:</p>
{{ include('livres/livres.html.twig', {'livre': top.livre1}) }}
<p>Top 2:</p>
{{ include('livres/livres.html.twig', {'livre': top.livre2}) }}
<p>Top 3:</p>
{{ include('livres/livres.html.twig', {'livre': top.livre3}) }}

Bien sûr, la modélisation pour ce problème n’est pas la meilleure, et même dans le template, nous aurions pu utiliser une boucle, mais cela permet d’illustrer efficacement la fonctionnalité d’inclusion.

Tout cela va trouver son intérêt si le template est réutilisé dans plusieurs pages différentes. Par exemple, je pourrais réutiliser dans la page illustrant les détails d’un livre. Ou alors, si j’utilise un formulaire à plusieurs endroits de mon site, je peux le placer dans un template et l’inclure là où il y a besoin.

Il est intéressant de noter que le template inclus ait accès à toutes les variables déjà accessibles (ou définies) par le template qui l’appelle.

Il est d’ailleurs tout à fait possible que ce template “étende” un autre template, comme nous l’avons fait pour la plupart des templates que nous avons créé jusqu’à présent. Finalement, on peut voir l’extension de templates comme de l’héritage (et la redéfinition de blocks comme de la réécriture de méthodes) et l’utilisation d’un template à l’intérieur d’un autre template comme un appel de fonction, par exemple.

  1. Créez un template publication.html.twig dans templates/publication contenant le code affichant une publication (vous pouvez rependre le code concerné depuis feed.html.twig, par exemple).

  2. Dans feed.html.twig et page_perso.html.twig remplacez le code contenu dans votre boucle affichant chaque publication en incluant votre nouveau template à la place. Il faudra passer chaque publication traitée en paramètre.

  3. Vérifiez que tout s’affiche toujours normalement, sur la page principale et sur une page personnelle.

On pourrait même aller plus loin et avoir un template liste_publications.html.twig si l’affichage d’une liste de publications était plus complexe qu’une simple boucle et se répétait sur plusieurs pages. Ce template pourrait lui-même utiliser notre nouveau template publication.html.twig

Environnement et pages d’erreurs

Actuellement, quand une page d’erreur s’affiche, vous avez une trace assez complète et beaucoup d’autres informations qui vous permettent de trouver l’erreur. Toutes ces données sont disponibles, car nous sommes dans un environnement d’application appelé dev sur Symfony. Cet environnement permet aussi d’accéder à la barre de débogage dont nous avons parlé plus tôt.

Bien sûr, toutes ces informations ne doivent pas apparaître quand le site est publié. On change alors d’environnement pour prod. Il est aussi possible d’avoir des fichiers de configurations différents par environnement (pas la même base de données entre dev et prod, par exemple).

Vous avez sans doute remarqué que parfois, le chargement des pages est un peu long après certaines de vos modifications. C’est aussi une des différences majeures entre l’environnement prod et dev. Quand on est sur l’environnement de production, Symfony utilise un cache qui permet d’accélérer les prochaines requêtes. Néanmoins, après une modification du code, il est nécessaire de vider le cache pour constater les mises à jour.

En mode développement (dev), le cache n’est pas vraiment activé. Après une modification du code, il se régénérera automatiquement, ce qui fait que les requêtes sont plus longues de manière générale. À l’inverse, vous remarquerez que votre site est beaucoup plus rapide en mode prod (et heureusement !).

Il faut donc penser à vider le cache quand on fait un changement dans le mode prod avec la commande :

php bin/console cache:clear

Ou bien :

php bin/console c:c

Ce n’est pas très contraignant, car le développeur ne travaille pas (ou peu) dans le mode prod. Ce mode est utilisé là où le site est hébergé publiquement. Celui-ci va plutôt subir de “grosses” mises à jour ponctuelles plutôt que plein de petites mises à jour de code toutes les minutes, comme une application en développement. Après une grosse mise à jour du site, il suffit alors de vider le cache une fois.

Pour changer d’environnement, il suffit d’éditer la variable APP_ENV dans le fichier .env (ou .env.local) à la racine du projet.

Quand on passe en mode prod il est impératif de compiler les assets afin que les ressources (css, scripts, images…) soient transférés depuis le dossier assets vers le dossier public/assets (l’asset mapper est désactivé en production). Cela se fait avec cette commande :

php bin/console asset-map:compile

Pour en revenir aux pages d’erreurs celles-ci ont un look bien différent en mode prod qu’en mode dev. Ce qui est bien, c’est que Symfony nous permet de les customiser !

Il faut d’abord créer le chemin de répertoires templates/bundles/TwigBundle/Exception. Ensuite, on peut créer :

Ces templates seront chargés automatiquement (en mode prod) si une erreur survient.

  1. Changez l’environnement de l’application à prod.

  2. Compilez les assets avec la commande dédiée (puis allez jeter un coup d’œil au dossier public).

  3. Créez l’ensemble de répertoire nécessaire pour accueillir les templates de pages d’erreurs customisés.

  4. On va gérer trois cas : l’erreur 403 (accès refusé), l’erreur 404 (page non trouvée) et les autres erreurs (avec le template général). Créez les trois templates nécessaires. On veut que chaque template hérite de la structure de base de notre site (avec le menu de navigation, etc…) et ait pour titre “Erreur”. Donc, elles doivent étendre base.html.twig.

    Le contenu principal (page_content) de chaque page d’erreur sera assez similaire, on changera juste le titre de section et le message affiché :

     <main>
         <div class="center">
             <h1><!-- Titre de section --></h1>
             <p><!-- Message --></p>
         </div>
     </main>
    
    • Pour l’erreur 403, le titre de section est “Accès refusé” et le message “Vous ne pouvez pas accéder à cette page !”.

    • Pour l’erreur 404, le titre de section est “Page introuvable !” et le message “La page demandée est introuvable !”.

    • Enfin, pour toutes les autres erreurs, le titre de section est “Erreur !” et le message “Une erreur est survenue ! Veuillez réessayer plus tard.”.

    Pour ne pas perdre trop de temps, ne vous embêtez pas à faire une hiérarchie particulière entre les templates d’erreurs pour généraliser certaines parties du code (même si cela serait judicieux). Tous les templates d’erreurs héritent simplement de base.html.twig et redéfinissent le block de titre puis de contenu.

  5. Videz le cache.

  6. Naviguez sur le site : vous constaterez que la barre de débogage n’est plus là.

  7. Essayez d’afficher vos pages d’erreurs :

    • Accédez à une route qui n’existe pas.

    • Ajoutez une erreur de syntaxe (temporaire) dans le code lié à la route feed ce qui devrait générer une erreur 500. Videz le cache après avoir nettoyé l’erreur de syntaxe.

    • Pour le refus d’accès, c’est un peu plus complexe, car nous n’avons pas vraiment de route protégée pour le moment. Vous pouvez essayer d’ajouter temporairement un attribut IsGranted sur une route, en spécifiant un rôle qui n’existe pas (par exemple, ROLE_TEST). Videz le cache puis tentez d’accéder à cette route. La page d’erreur devrait s’afficher. N’oubliez pas de supprimer le IsGranted temporaire.

    N’oubliez pas que si vous faites des modifications pour améliorer ou corriger votre résultat, il faudra vider le cache (car nous sommes dans le mode prod).

  8. Repassez l’environnement de l’application à dev et supprimez le dossier public/assets généré lors de la compilation des assets. Pensez aussi à vider le cache.

Vous remarquerez que, si vous êtes connectés et que vous accédez à une route inexistante (404), la page vous apparait comme si vous étiez déconnecté. Malheureusement, c’est une petite limitation de Symfony à ce niveau, car l’ordre d’exécution des processus pour résoudre une route fait que les informations de l’utilisateur ne sont pas encore chargées à ce stade-là (car la route n’existe simplement pas dans l’application). C’est donc “normal” que le menu de navigation ne s’affiche pas comme pour les autres erreurs.

Conclusion

Vous possédez maintenant de solides bases pour construire des sites web à l’aide de Symfony ! Dans le prochain TD, nous irons un peu plus loin en intégrant du JavaScript, de nouvelles fonctionnalités, puis nous nous intéresserons également à la mise en place d’un système de paiement pour permettre à certains utilisateurs de bénéficier d’un accès “Premium” à notre site !

Lien avec le cours de complément web (RACDV)

Des notifications plus élégantes

Si le temps le permet, vous pouvez consulter cette note complémentaire qui permet de remplacer nos messages flashs par des notifications plus élégantes.