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 :
- Temps d’exécution de la requête.
- Trace : on peut mesurer le temps passé dans chaque fichier, dans chaque fonction, dans la base de données.
- Requêtes SQL exécutées : code, temps d’exécution, nombre de requêtes
- Les données de l’utilisateur connecté.
- Les erreurs, les warnings…
- Code de réponse HTTP.
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 passes.
Cette commande se nomme make:user
:
php bin/console make:user
Une fois exécutée, elle va vous demander :
-
Le nom de l’entité qui jouera le rôle d’utilisateur.
-
Si les informations de l’utilisateur doivent être stockées dans la base de données (login, mot de passe haché, etc…).
-
Le nom de la propriété (unique) identifiant de l’utilisateur, c’est-à-dire, avec laquelle il se connectera (quelque chose supposé unique par utilisateur, par exemple, son login ou bien son adresse email…)
-
Si les mots de passes doivent être hachés et vérifiés (a priori, on répond oui sauf si on a un autre système que Symfony qui gère cela).
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
-
À 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 passes sont hachés et vérifiés.
- Nom de l’entité :
-
Utilisez la commande
make:entity
pour mettre à jour et compléter la classeUtilisateur
avec les champs suivants :adresseEmail
: string, 255 caractères maximum, non null.nomPhotoProfil
: text, null autorisé.
-
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 annotationUniqueConstraint
nomméeUNIQ_IDENTIFIER_EMAIL
et visant l’attributadresseEmail
. -
Prenez le temps d’observer le code des classes générées ainsi que le fichier
security.yaml
. -
Mettez à jour la structure de la base de données avec les commandes
make:migration
puisdoctrine: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
:
-
La propriété
password
représente le mot de passe haché (on ne stocke jamais le mot de passe en clair ici) -
La propriété
roles
représente une liste de rôles de l’utilisateur. Les rôles sont un système permettant d’accorder des privilèges à certains utilisateurs. On peut limiter l’accès à des routes à certains rôles, ou bien vérifier le rôle d’un utilisateur dans un template Twig. Si vous jetez un œil à la méthodegetRoles
, vous remarquerez que par défaut, un utilisateur a le rôleROLE_USER
. C’est le rôle basique d’un utilisateur connecté. Dans la base de données, cette valeur est stockée comme un string et décodée puis transformée en tableau par Symfony. -
La méthode
eraseCredentials
permet d’effacer de la mémoire de Symfony des données sensibles, éventuellement stockées dans la classe de l’utilisateur, temporairement (comme le mot de passe en clair, pour la connexion). Dans notre cas, nous ne nous en servirons pas. -
La classe implémente
UserInterface
qui est demandée comme paramètre de nombreux services (comme pour le chiffrement du mot de passe). Notre classeUtilisateur
sera donc compatible.
Au niveau du fichier security.yaml
:
-
La zone
app_user_provider
permet d’informer Symfony quelle est l’entité qui représente nos utilisateurs ainsi que la propriété utilisée comme identifiant de connexion. -
Un peu plus bas,
password_hashers
permet de sélectionner l’algorithme de chiffrement des mots de passes. Depuis les dernières versions de Symfony, on peut utiliser la valeurauto
(comme c’est le cas ici) qui permet de sélectionner le meilleur algorithme de chiffrement disponible. Cela permet aux mots de passes d’être le plus sécurisé possible. De plus, si cet algorithme vient à changer (par exemple, un meilleur algorithme est publié dans le futur), Symfony procède à la migration des mots de passes. La prochaine fois qu’ils se connecteront, les utilisateurs dont le mot de passe utilise encore l’ancien algorithme de chiffrement déclencheront automatiquement la migration de leur mot de passe qui sera re-chiffré avec le nouvel algorithme puis stocké, et tout cela de manière invisible. Ainsi, avec ce paramètre, le développeur n’a pas (trop) à se soucier d’être à jour niveau sécurité des mots de passes. Une partie du code de ce système se trouve dans la classeRepository/UtilisateurRepository
au niveau de la méthodeupgradePassword
(vous pouvez y jeter un œil). Les algorithmes de chiffrement contiennent un système desalt
, comme vous l’avez vu l’année dernière.
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 :
-
plainPassword
: il s’agit du mot de passe en clair transmis via le formulaire, qui diffère de l’attributpassword
qui lui représente le mot de passe chiffré et ne doit justement pas faire partie du formulaire ! Cela signifie que pour les assertions concernantplainPassword
, il faudra le faire au niveau de la classe du formulaire, et non pas au niveau de l’entitéUtilisateur
. -
fichierPhotoProfil
: il s’agit du fichier contenant la photo de profil de l’utilisateur. Cela est différent denomPhotoProfil
qui ne doit pas faire partie du formulaire et qui stocke seulement le nom de la photo de profil (pour l’afficher plus tard).
Vous aurez aussi besoin de nouvelles assertions :
-
#[Assert\Email()]
: vérifie que la chaîne de caractères est une adresse email valide (bien formée). -
#[Assert\File(maxSize : ..., extensions : [...])]
: vérifie que le fichier envoyé ne dépasse pas une certaine taille et possède une des extensions autorisées (par exemple, “pdf”). Pour exprimer une taille en mégaoctets, on utiliseM
.Exemple d’une assertion
File
qui n’accepte que les fichiers de type mp3, wav ou ogg de 2Mo maximum :#[Assert\File(maxSize : '2M', extensions : ['mp3', 'wav', 'ogg'])]
-
#[Assert\Regex(pattern: ...)]
: que nous avions brièvement présenté plus tôt. Le paramètrepattern
défini l’expression régulière que la chaîne de caractères doit respecter.
Pour rappel, pour ajouter un champ qui ne fait par 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é : on rajoute l'option "mapped => false"
->add('monChamp', TextType::class, [
"mapped" => false,
//Les assertions
"constraints" => [
new NotBlank(),
new NotNull(),
new Length(...)
]
])
;
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ée 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, 'Mon label') }}
. 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é.
-
À l’aide de la commande
make:form
créez une classe de formulaireUtilisateurType
pour l’entitéUtilisateur
. Dans cette nouvelle classe, supprimez les champspassword
etnomPhotoProfil
etroles
(qui ne sont pas envoyés et gérés par l’utilisateur) puis ajoutez trois nouveaux champs :plainPassword
,fichierPhotoProfil
etinscription
. -
Configurez le type des champs ainsi :
login
: TextTypeadresseEmail
: EmailTypeplainPassword
: PasswordTypefichierPhotoProfil
: FileTypeinscription
: SubmitType (bouton d’envoi)
Quelques imports utiles à faire dans
UtilisateurType
: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;
-
Ajoutez différentes assertions (et attributs) pour implémenter les contraintes suivantes :
-
Au niveau de la classe
Utilisateur
:-
login
: non blanc, non null, entre 4 et 20 caractères. Configurez aussi des messages d’erreurs (si login trop court ou trop long) -
adresseEmail
: non blanc, non null, adresse email valide. Configurez un message d’erreur en cas d’adresse non valide (paramètremessage
). -
Deux entités différentes ne doivent pas avoir le même
login
et/ou la mêmeadresseEmail
. Faites le nécessaire pour implémenter ces contraintes au niveau de la classeUtilisateur
. 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;
-
-
Au niveau de
UtilisateurType
:-
plainPassword
: non blanc, non null, 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 (justemessage
). Il faut aussi configurer l’optionmapped
pour préciser que ce champ ne fait pas partie de la classeUtilisateur
. -
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
). Ici aussi, il faut configurer l’optionmapped
.
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;
-
-
-
Dans le dossier
templates
, créez un dossierutilisateur
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
.
-
-
À l’aide de la commande
make:controller
, créez un nouveau contrôleurUtilisateurController
, effacez la méthodeindex
générée par défaut ainsi que le templateindex.html.twig
généré dans le dossierutilisateur
. -
Créez une route nommée
inscription
, ayant pour chemin/inscription
et accessible avec les méthodesGET
etPOST
. Dans le code de cette route, initialisez un formulaire avecUtilisateurType
. Le formulaire utilisera la méthodePOST
et son action pointe vers la routeinscription
. 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 routefeed
). Comme pour les publications, nous utiliserons la même route pour afficher (GET) et traiter (POST) le formulaire. -
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 "Login" --> {{ form_label(...) }} <p class="help-input-form">Entre 4 et 20 caractères</p> <!-- Affichage de l'input du login --> {{ form_widget(...) }} </div> <div class="access-container"> <!-- Affichage du label "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 de l'input du mot de passe --> {{ form_widget(...) }} </div> <div class="access-container"> <!-- Affichage du label "Adresse mail" --> {{ form_label(...) }} <!-- Affichage de l'input de l'adresse email --> {{ form_widget(...) }} </div> <div class="access-container"> <!-- Affichage du label "Photo de profil" --> {{ form_label(...) }} <!-- Affichage de l'input de la photo de profil (required : '', car non obligatoire...) --> {{ form_widget(..., {'required' : '', 'attr' : {'accept' : 'image/png, image/jpeg'}}) }} </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>
-
Accédez à votre nouvelle page et vérifiez que le formulaire s’affiche correctement.
-
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
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 :
-
Il faut chiffrer/hacher le mot de passe.
-
Il faut sauvegarder la photo de profil (s’il y en a une) et enregistrer le nom de la photo dans les données de l’utilisateur. Il faut faire en sorte que le nom de cette image soit unique.
Pour gérer ces deux aspects, nous nous proposons de créer un service, pour ne pas surcharger le contrôleur (et de toute façon, ce n’est pas vraiment son rôle de faire ces étapes, normalement…)
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é
$fileName = uniqid($login) . '.' . $fichier->guessExtension();
$fichier->move($destination, $fileName);
L’utilisation de uniqueid
permet de générer un identifiant (chaîne de caractère) basé sur l’heure actuelle. Le paramètre donné à cette fonction permet de lui ajouter un préfixe (dans notre cas, on utilise le login) ce qui permet de garantir son unicité (on peut aussi éventuellement lui ajouter un deuxième paramètre booléen pour lui demander d’ajouter une chaîne aléatoire en suffixe). 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/chiffrer un mot de passe, en utilisant l’algorithme configuré dans security.yaml
(dans notre cas auto
, donc, le meilleur algorithme de chiffrement 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["monChamp"]->getData();
-
Dans
public/img/utilisateurs
, créez un dossieruploads
. -
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 root:www-data ./public/img/utilisateurs/uploads chmod g+w ./public/img/utilisateurs/uploads
-
Dans le fichier
config/services.yaml
, ajoutez un paramètredossier_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.). -
Dans le dossier
src/Service
, créez et complétez la classe suivante :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 ){} /** * Chiffre le mot de passe puis l'affecte au champ correspondant dans la classe de l'utilisateur */ private function chiffrerMotDePasse(Utilisateur $utilisateur, ?string $plainPassword) : void { //On chiffre 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 soumissions du formulaire (hachage du mot de passe, sauvegarde de la photo de profil...) */ public function processNewUtilisateur(Utilisateur $utilisateur, ?string $plainPassword, ?UploadedFile $fichierPhotoProfil) : void { //On chiffre le mot de passe //On sauvegarde (et on déplace) l'image de profil } }
-
Comme nous l’avions fait pour
FlashMessageHelper
, définissez une interfaceUtilisateurManagerInterface
contenant la signature deprocessNewUtilisateur
(on rappelle qu’il est très facile d’extraire une interface depuis une classe concrète avec PHPStorm !). Ensuite, faites le nécessaire pour queUtilisateurManager
implémente cette interface (c’est elle qu’on injectera dans les contrôleurs/services). -
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 extraireplainPassword
puisfichierPhotoProfil
et enfin utiliser votre nouveau service avec sa méthodeprocessNewUtilisateur
.N’oubliez pas aussi de prendre en charge les erreurs du formulaire, à sauvegarder comme messages flash ! En utilisant le service dédié que nous avons créé lors du précédent TD.
Enfin, lorsque l’utilisateur est enregistré, il faut ajouter un message flash (type :
success
) “Inscription réussie !” (on rappelle qu’il est possible d’utiliseraddFlash
dans un contrôleur) puis le rediriger vers la routefeed
(méthoderedirectToRoute
qui retourne un objetResponse
).Encore une fois, vous pouvez vous inspirer (en partie) du code de votre route
feed
, pour la création d’une publication. -
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
.
-
-
Tentez d’inscrire un nouvel utilisateur, sans image de profil (cela doit fonctionner).
-
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 flash d’erreur s’affichent bien.
-
En modifiant
UtilisateurType
, ajoutez des contraintes “clientes” (avecattr
, comme vous l’avez fait dansPublicationType
) 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
etmaxlength
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}$
-
Vous pouvez également déplacer les contraintes
required
etaccept
déjà présentes sur leform_widget
nommé fichierPhotoProfil dans le templateinscription.html.twig
dans le champ dédié de la classeUtilisateurType
. Attention, pourrequired
, il se place en dehors deattr
:
$builder ->add('champ1', MonType::class, [ //Champ non requis, génère un required='' dans le champ 'required' => false, 'attr' => [ //Il faut gérer la contrainte "accept" ici ... ] ]) ;
-
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" value="{{ csrf_token('authenticate') }}">
</form>
-
La valeur de l’attribut
name
du champ de login doit être_username
. -
La valeur de l’attribut
name
du champ de mot de passe doit être_password
. -
Ce formulaire doit aussi contenir un jeton CSRF (protection contre le cross-site request forgery), dans un input de type
hidden
(donc caché), nommé_csrf_token
dont la valeur est générée avec{{ csrf_token('authenticate') }}
.
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
-
Le système de sécurité de Symfony redirige les visiteurs non authentifiés vers la route indiquée dans
login_path
lorsqu’ils tentent d’accéder à un page sécurisé sans être connecté. -
Le paramètre
check_path
doit correspondre à la route vers laquelle renvoie le formulaire de connexion. Symfony va intercepter les requêtesPOST
àcheck_path
pour traiter les identifiants de connexion. En cas d’échec de connexion, Symfony redirige l’utilisateur surlogin_path
, ce qui a pour effet de réafficher le formulaire de connexion. -
Les paramètres
default_target_path
etalways_use_default_target_path
permettent de rediriger l’utilisateur vers une route précise après une connexion réussie.
-
Dans
UtilisateurController
, créez une routeconnexion
de chemin/connexion
, similaire à l’exemple donné plus tôt. Le template à préciser estutilisateur/connexion.html.twig
. Nous allons le créer à la prochaine étape. -
Créez le template
connexion.html.twig
dans le dossiertemplates/utilisateur
. Comme toutes nos pages, ce template étendbase.html.twig
et récrit certains blocks. La page doit avoir pour titre “Connexion”. -
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="...A compléter..." required/> </div> <div class="access-container"> <label for="password">Mot de passe</label> <input id="password" type="password" name="...A compléter..." required/> </div> <button type="submit" class="basic-form-submit">Se connecter</button> </fieldset> <!-- A compléter : champ caché contenant le jeton CSRF --> </form> </main>
-
Mettez à jour le fichier
config/packages/security.yaml
de manière adéquate. L’utilisateur doit être redirigé vers la routefeed
après s’être connecté. -
Mettez à jour le template
base.html.twig
afin d’inclure un lien vers votre page de connexion dans le menu de navigation. -
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).
-
Actuellement, si l’utilisateur se trompe dans son mot de passe, quand le formulaire est rechargé, le champ du login n’est pas pré-remplit. 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 donc tout le temps pré-remplit, ce qui est pratique en cas d’erreur de mot de passe, mais aussi si l’utilisateur se déconnecte puis se reconnecte plus tard. Cette donnée est mémorisée dans un cookie.Effectuez les modifications nécessaires dans votre route
connexion
et dans le templateconnexion.html.twig
pour que le champ du login soit automatiquement pré-remplit avec le dernier login avec lequel l’utilisateur a essayé de se connecter. -
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 :
-
Tout d’abord, le fichier
config/packages/security.yaml
en paramétrant notre route de déconnexion avec une section nomméelogout
, un peu comme nous l’avons fait pour la connexion :security: ... firewalls: ... main: ... logout: path: /cheminRouteDeconnexion target: routeRetour
Dans
path
, on précise le chemin de la route (par exemple/deconnexion
) et danstarget
la route (cette fois, pas avec son chemin, mais bien avec son nom) vers laquelle est redirigé l’utilisateur après s’être déconnecté. -
Ensuite, on configure une section
_security_logout
dans le fichierconfig/routes.yaml
:_security_logout: resource: security.route_loader.logout type: service methods: ['POST']
Par défaut, toutes les méthodes sont autorisées pour accéder à la route de déconnexion. Dans notre cas, nous limitons cela à
POST
grâce à l’attributmethods
.
Par la suite, quand on voudra faire appel à la route (par exemple, en utilisant path
dans un template twig), on utilisera le nom de route _logout_main
. Le main
correspond au nom du firewall
où nous avons paramétré la section logout
.
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.
-
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 routefeed
. -
Modifiez le fichier
config/routes.yaml
afin d’autoriser seulement la méthodePOST
lorsque la route de déconnexion est utilisée. -
Dans votre template
base.html.twig
, ajoutez le formulaire suivant dans le menu de navigation en complétantaction
de manière adéquate pour pointer sur votre route de déconnexion (toujours en utilisant la fonctionpath
, jamais directement le chemin) :<form method="post" action="A compléter"> <button id="btn-deconnexion">Déconnexion</button> </form>
-
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 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 :
{% 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.
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 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écifier :
#[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'éxécution s'arrête et une page d'erreur est affichée.
}
//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'.
}
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 plutôt la méthode utilisant denyAccessUnlessGranted
. 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.
-
Adaptez le template
base.html.twig
afin que les liens menant vers la pageInscription
etConnexion
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. -
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. -
Enfin, modifiez votre template
feed.html.twig
pour afficher le formulaire de création d’une publication seulement aux utilisateurs connectés. -
Vérifiez que tout s’affiche comme attendu selon votre état (connecté/déconnecté).
Notification de 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 EventSubscriber
.
Ces classes sont regroupées dans le dossier src/EventSubscriber
de l’application et se présentent ainsi :
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
class MonEventSubscriber {
public function __construct(/* Injection de dépendances possible ici*/){}
#[AsEventListener]
public function onMonEvent(MonEvent $event) {
//Méthode déclenchée quand un evenement de type `MonEvent` est déclenché
}
}
-
Chaque méthode de la classe peut avoir le nom qu’elle veut, mais il faut qu’elle possède l’attribut
#[AsEventListener]
et prenne en paramètre l’événement qu’elle souhaite traiter. -
Comme pour les services, il est possible de faire de l’injection de dépendances (de services ou d’autres paramètres) via le constructeur.
Dans notre cas, trois événements vont nous intéresser :
-
LoginSuccessEvent
: déclenché quand l’utilisateur s’est authentifié avec succès. -
LoginFailureEvent
: déclenché quand l’utilisateur n’a pas réussi à s’identifier (mauvais login/mot de passe). -
LogoutEvent
: déclenché quand l’utilisateur s’est déconnecté.
À 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
que nous avions déjà utilisé pour FlashMessageHelper
. Pour rappel, ce service nous permet (entre autres) d’ajouter des messages flash :
$flashBag = $this->requestStack->getSession()->getFlashBag();
$flashBag->add(categorie, message);
-
Créez un dossier
EventSubscriber
danssrc
. -
Dans votre nouveau dossier, créez la classe
AuthenticationSubscriber
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 typesuccess
: “Connexion réussie !”. -
Une méthode permettant de gérer l’événement
LoginFailureEvent
et qui ajoute le message flash de typeerror
: “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 typesuccess
: “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;
-
-
Vérifiez que vos messages s’affichent bien dans les trois situations.
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 fonction ayant le même effet (dans notre cas) est is_authenticated_fully
. 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 terme 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 !
-
Si un utilisateur déjà connecté tente d’accéder aux routes
inscription
ouconnexion
, redirigez-le vers la routefeed
(page principale). -
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 :
#[ORM\ManyToOne(inversedBy: ...)]
: À utiliser dans une relation 1 - plusieurs, du côté de l’entité qui doit posséder une instance de l’entité ciblée. Le paramètreinversedBy
permet de spécifier le nom de l’attribut de la classe cible qui fait référence à l’entité (où on place cette annotation).
On peut aussi ajouter un attribut supplémentaire #[ORM\JoinColumn(onDelete="SET NULL")]
si on veut appliquer la stratégie de mettre l’attribut référencé à null
lors de la suppression de l’entité référencée (au lieu de supprimer complétement la ressource qui lui est liée).
-
#[ORM\OneToMany(targetEntity: Target::class, mappedBy: ..., cascade: [...])]
: À utiliser dans une relation 1 - plusieurs, du côté de l’entité qui doit posséder une collection de l’entité ciblée (l’attribut est de typeCollection
). Le paramètretargetEntity
permet de spécifier la classe cible. Le paramètremappedBy
fonctionne de la même manière queinversedBy
. -
#[OneToOne(mappedBy: ...)]
: À utiliser dans une relation1 - 1
. Dans l’autre entité, on utilise le même attribut en remplaçantmappedBy
parinversedBy
. -
#[ManyToMany(targetEntity: Target::class, mappedBy: ...)]
: À utiliser dans une relation plusieurs - plusieurs. Dans l’autre entité, on utilise la même attribut en remplaçantmappedBy
parinversedBy
. Dans ce cas, une nouvelle table sera créée dans la base de données (table de jointure). Dans une des deux entités, au niveau de l’attribut concerné, il faut alors ajouter une autre attribut#[JoinTable(name: 'nom_table_jointure')]
afin de nommer cette table. Le paramètretargetEntity
fonctionne de la même manière que pourOneToMany
.
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')]
où 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 :
-
Si on veut que cet attribut ne puisse pas être null dans la base, on ajoute un attribut
#[ORM\JoinColumn(nullable: false)]
. -
Si on veut autoriser le comportement de suppression en cascade du côté de la base lors de la suppression de l’entité cible, il faut aussi spécifier le paramètre
onDelete: "CASCADE"
dans ce même attribut. Par exemple#[ORM\JoinColumn(nullable: false, onDelete: "CASCADE")]
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 :
-
Déclarer une nouvelle propriété
-
Quand on demande le type, sélectionner au choix :
ManyToOne
,OneToMany
,ManyToMany
ouOneToOne
. Il est également possible de sélectionnerrelation
: cela affichera des informations pour vous aider à choisir parmi les 4 valeurs précédentes. -
On précise l’entité cible (avec le nom de sa classe).
-
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
. -
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…).
-
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…).
-
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.
-
-
Observez le code généré dans
Publication
etUtilisateur
. -
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 contrainteON 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’optionorphanRemoval
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 contrainteON 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.
-
Mettez à jour la structure de votre base de données avec les commandes
make:migration
etdoctrine:migrations:migrate
. -
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 :
-
Remplacer le “Anonyme” par le login de l’auteur.
-
Remplacer l’image de profil “anonyme.jpg” par l’image de profil de l’utilisateur. Cependant, s’il n’a pas de photo de profil (propriété
nomPhotoProfil
null) alors il faut continuer d’afficher l’image “anonyme.jpg” (image de profil “par défaut”). Pour rappel, les images de profil sont stockées dansimg/utilisateurs/uploads
et la propriéténomPhotoProfil
donne simplement le nom de l’image enregistrée dans ce dossier.
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 nulle, alors un test conditionnel “objet.propriete” renvoi 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 ~
.
-
Mettez à jour votre route
feed
dans le contrôleurPublicationController
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 !) -
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éfautimg/utilisateurs/anonyme.jpg
. Pour rappel, avec twig, vous pouvez concaténer deux chaînes de caractères en utilisant~
. -
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 ?
Si vous vous souvenez de vos cours de base de données du semestre 3, nous avions parlé de deux modes de chargement de données : 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 :
-
Quand on exécute
findAllOrderedByDate
, une première requête est exécutée pour récupérer toutes les publications, mais sans les données sur les auteurs. -
Quand, dans notre template
Twig
, on lit les données de l’auteur d’une publication pour la première fois, une nouvelle requête est exécutée pour récupérer ses données (et conservées pour ne pas avoir à refaire la requête si on a plusieurs publications avec le même auteur…). Donc, une requête supplémentaire par utilisateur.
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, il serait plus judicieux d’utiliser le eager loading dans ce contexte.
En utilisant le eager loading :
-
Quand on exécute
findAllOrderedByDate
, une seule requête est exécutée pour récupérer toutes les publications et les données des auteurs (avec une jointure). -
Quand, dans notre template
Twig
, on lit les données de l’auteur d’une publication, il n’y a pas de nouvelles requêtes exécutées pour récupérer ses données, elles ont déjà été chargées.
Attention, cette stratégie (eager loading) est 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 besoins 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;
-
Faites en sorte que l’auteur d’une publication soit chargé en mode
EAGER
. -
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, depuis sa dernière version, Symfony a introduit 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}/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é 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}/test/{critere2}', 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! Dans ce cas, il faut alors préciser dans la route paramétrée à quel type d’entité appartient le paramètre :
#[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}', name: 'get_livre', methods: ["GET"])]
public function getLivre(?Livre $livre): Response
{
/*
Execute (en arrière-plan) :
- $livre = findOneBy(["isbn" => {isbn}]);
*/
...
}
-
Dans le contrôleur
UtilisateurController
créez une route (et sa méthode) nomméepagePerso
qui doit être déclenchée par les chemins type/utilisateurs/{login}/publications
où le login est le login d’un utilisateur. La route est accessible enGET
seulement. Vous devez faire en sorte de récupérer 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.
-
-
Créez le template
page_perso.html.twig
dans le dossiertemplates/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. Pour le moment, vous pouvez donc reprendre 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 un attribut dédié. -
Importez et complétez 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 %}
-
-
Accédez aux différentes pages personnelles de vos utilisateurs pour vérifier que tout fonctionne.
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’ajout 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>
Autre élément important à connaître : Symfony met à disposition un objet app.user
qui est un objet représentant l’utilisateur connecté. On peut donc accéder à ses propriétés, par exemple app.user.id
, etc…
Vous aurez également remarqué que, dans les pages des utilisateurs, les publications ne sont pas triées par ordre décroissant des dates de publications contrairement sur 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 suffit d’utiliser un attribut au-dessus de la propriété correspondante, pour indiquer comment elle doit être triée quand lue depuis la base de données :
#[ORM\OrderBy(["propriete" => "DESC ou ASC", ...])]
: ici, on a le même fonctionnement que pour findBy
. On indique dans un tableau la ou les propriétés avec lesquelles on souhaite trier les résultats et le sens (ASC ou DESC) :
class Entreprise {
#[ORM\OneToMany(mappedBy: 'entreprise', targetEntity: Employe::class)]
#[ORM\OrderBy(["salaire" => "DESC"])]
private Collection $employes;
}
Ici, quand on lira la propriété employes
d’une entité de type Entreprise
, la collection d’employés sera triée selon le salaire des employés (du plus haut au plus bas). Il est possible d’ajouter d’autres critères, en cas d’égalité… Vous l’aurez compris, les propriétés à indiquer pour le tri appartiennent à l’entité cible de la collection.
-
Faites en sorte que la liste des publications de chaque utilisateur soit triée. Vérifiez en chargeant la page personnelle d’un utilisateur.
-
Modifiez
feed.html.twig
etpage_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 partiehref
. -
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. -
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' : ... }) }}
-
cheminTemplate
: correspond au chemin du template à partir de la racine : le dossiertemplates
(comme on étend un template, ou qu’on l’utilise dans un contrôleur…) -
Le second paramètre est optionnel et permet de passer des paramètres utilisables par le template inclus.
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.
-
Créez un template
publication.html.twig
danstemplates/publications
contenant le code affichant une publication (vous pouvez rependre la code concerné depuisfeed.html.twig
, par exemple). -
Dans
feed.html.twig
etpage_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. -
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.
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 :
-
Un template pour un code d’erreur HTTP spécifique (403, 404, 500, etc…). Ce template doit s’appeler précisément
errorXXX.html.twig
oùXXX
est le code de l’erreur. -
Un template général
error.html.twig
qui sera chargé par défaut, s’il n’existe pas de template pour le code HTTP ayant déclenché l’erreur.
Ces templates seront chargés automatiquement (en mode prod
) si une erreur survient.
-
Changez l’environnement de l’application à
prod
. -
Créez l’ensemble de répertoire nécessaire pour accueillir les templates de pages d’erreurs customisés.
-
On va gérer trois cas : l’erreur
403
(accès refusé), l’erreur404
(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 templates héritent de la structure de base de notre site (avec le menu de navigation, etc…) et aient toutes pour titre “Erreur”. Donc, elles doivent étendrebase.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 contenu. -
-
Videz le cache.
-
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 placé (cela fera une erreur, c’est normal) puis 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 leIsGranted
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
). -
-
Repassez l’environnement de l’application à
dev
.
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.
Note sur les erreurs du formulaire
Avant de conclure, une petite information complémentaire sur les erreurs générées quand un formulaire n’est pas valide. Pendant le premier TD, nous avons mis en place tout un système pour que les erreurs soient ajoutées comme des messages flash, mais on aurait également pu utiliser form_error(form.champ)
pour obtenir les erreurs liées à un champ en particulier.
Par ailleurs, une bonne pratique de design consiste à plutôt faire apparaître l’erreur liée à un champ au niveau de son label.
Quand on utilise un framework css et/ou un thème de formulaire, Symfony se charge de placer adéquatement les informations liées aux erreurs. Vous pouvez retrouver diverses informations utiles à ce propos ici.
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 !