Introduction au Framework PHP Symfony :
Dans ce tutoriel, nous allons vous guider à travers les étapes pour commencer à utiliser le framework PHP Symfony. Symfony est un framework web puissant et flexible qui facilite le développement d'applications web évolutives et de haute qualité.
Symfony est un framework PHP qui vous permettra d'écrire une application web de manière plus organisée avec une séparation de la logique en plusieurs composants.
Il va vous fournir un ensemble de classes préconçues qui vont vous permettre de simplifier les tâches récurrentes dans la création d'une application web (vous allez avoir des classes pour communiquer avec la base de données, valider les données, gérer les URL...).
Symfony suit la structure MVC (pour Model View Controller), qui est une manière d'organiser le code qui est très répandue et que l'on retrouve sur la plupart des frameworks modernes aujourd'hui. Aussi, ce que vous allez apprendre avec Symfony sera aussi valable pour d'autres frameworks.
Vous trouverez la documentation complète de Symfony à l'adresse suivante : https://symfony.com/doc/current/index.html.
Étape 1 : Installation de Symfony :
Avant de commencer, assurez-vous que vous disposez d'une installation de PHP et du gestionnaire de dépendances Composer sur votre système.
Symfony a besoin de la version 8.2 ou supérieure de PHP. Pour vérifier la version de votre PHP et de votre Composer, vous pouvez utiliser les commandes suivantes :
php --version
composer --version
Symfony CLI est optionnel, mais recommandé pour une gestion simplifiée.
-
Ouvrez une ligne de commande ou un terminal.
-
Installez Symfony en utilisant Composer avec la commande suivante :
composer create-project symfony/skeleton:"7.0.*" nom_du_projet
Ou encore, si vous avez installé le Symfony CLI et que vous l'avez mis dans le PATH, vous pouvez faire la même chose avec la commande :
symfony new nom_du_projet --full
Remplacez "nom_du_projet" par le nom que vous souhaitez donner à votre projet.
Remarque : Au moment où j'écris ces lignes, l'option --full est déprécié, mais il existe l'option --webapp à la place.
-
Changez de répertoire vers votre nouveau projet :
cd nom_du_projet
Si vous avez utilisez la commande skeleton qui installe une version "vide" de Symfony dans laquelle on peut charger les modules que l'on souhaite utiliser, mais que vous voulez faire une application web complète, vous pouvez installer la dépendance webapp avec la commande suivante pour ajouter tous les composants nécessaires à la création d'une application web :
composer require webapp
Profiler Symfony permet au développeur d'utiliser /app_dev.php/_profiler pour déboguer et analyser vos requêtes, performances, etc.
Dans un projet Symfony typique, vous trouverez généralement les dossiers suivants à la racine du projet :
-
assets/ : Contient les assets (JavaScript, CSS) front-end.
-
bin/ : Contient des scripts exécutables, tels que console, qui est utilisé pour exécuter des commandes Symfony en ligne de commande.
-
config/ : Contient les fichiers de configuration de l'application au format yaml, tels que config.yaml (ou config.yml), services.yaml, routes.yaml, etc.
-
migrations/ : Dans les projets Symfony qui utilisent Doctrine, un outil de mapping objet-relationnel (ORM) pour PHP, contient les fichiers de migration de base de données pour détailler les évolutions de la base de données.
-
public/ : Contient les fichiers accessibles publiquement par le navigateur, tels que les fichiers CSS, JavaScript, les images, ainsi que le fichier index.php qui est le point d'entrée de l'application.
-
src/ : Contient le code source de l'application. C'est ici que vous trouverez généralement les contrôleurs, les entités, les formulaires, les services, etc. Il correspond au namespace \App (branché grâce à la clef autoload dans le fichier composer.json).
-
templates/ : Contient les fichiers de templates Twig utilisés pour générer les vues de l'application.
-
tests/ : Contient les tests unitaires et fonctionnels de l'application.
-
translations/ : Contient les fichiers de traduction de l'application.
-
var/ : Contient les fichiers générés par l'application, tels que les caches, les logs, les sessions, etc.
-
vendor/ : Contient les dépendances installées via Composer, le gestionnaire de dépendances de PHP.
-
.env : Fichier de configuration de l'environnement, utilisé pour définir des variables d'environnement telles que les paramètres de connexion à la base de données, les clés secrètes, etc. Ce fichier est versionné et il est possible de créer un nouveau fichier .env.local pour des variables qui sont spécifiques à votre environnement.
-
composer.json et composer.lock : Fichiers utilisés par Composer pour gérer les dépendances du projet.
-
symfony.lock : Fichier utilisé par Symfony pour verouiller les versions des composants Symfony installés.
Ce sont les principaux dossiers et fichiers que vous trouverez dans un projet Symfony standard. Le contenu exact peut varier en fonction de la version de Symfony et des besoins spécifiques du projet.
Étape 2 : Lancer le serveur web local :
Symfony fournit un serveur de développement pour faciliter le test de votre application en local. Pour le lancer, utilisez la commande suivante :
symfony server:start
# Ou
symfony serve
Ou encore en utilisant la commande php :
php -S localhost:8000 -t public
Pour l'arrêter complètement, on peut faire la commande :
symfony server:stop
Étape 3 : Création d'une route et d'un contrôleur :
Un contrôleur est une classe qui classe qui va contenir des méthodes permettant de répondre à une requête utilisateur.
-
Créez le contrôleur nommé DefaultController en utilisant la commande suivante :
php bin/console make:controller DefaultController
Ou encore, si et seulement si on a bien configuré le PATH de Windows, on utilise la syntaxe suivante qui fait la même chose :
symfony console make:controller DefaultController
En résumé, php bin/console peut être remplacé par symfony console.
-
Ouvrez le fichier `src/Controller/DefaultController.php` avec votre éditeur de code préféré et voici le code généré par la commande :
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class DefaultController extends AbstractController
{
#[Route('/default', name: 'app_default')]
public function index(): Response
{
return $this->render('default/index.html.twig', [
'controller_name' => 'DefaultController',
]);
}
}
?>
-
Créez une nouvelle action (méthode) dans le contrôleur pour gérer une page d'accueil :
// Ajoutez cette méthode à la classe DefaultController
#[Route('/', 'home.index', methods: ['GET'])]
public function homepage() : Response {
return $this->render('pages/homepage.html.twig');
}
Étape 4 : Création de la vue (template) :
Le moteur de template Twig va nous permettre de générer des pages HTML complexes.
On peut se poser la question suivante : Pourquoi ne pas utiliser du PHP ?
PHP est déjà un moteur de template, car il nous permet de mélanger logique et rendu, on peut alors se poser également la question de la pertinence de l'utilisateur d'un moteur de template.
-
Twig inègre un système d'extensions qui permet de définir un template de base que l'on pourra étendre en fonction de la page courante.
-
Les variables sont automatiquement échappées.
-
La syntaxe est plus simple que celle de PHP et permet à des personnes non familières avec le lanagage de pouvoir éditer la structure des pages.
-
Le langage est plus limité et empêche de faire n'importe quoi dans les fichiers de template.
Les templates seront placés dans le dossier templates à la racine de notre projet.
-
Créez un nouveau fichier `templates/pages/homepage.html.twig` dans le répertoire du projet (si le dossier "pages" n'existe pas encore, créez-le).
-
Ajoutez du contenu HTML à votre template :
<!DOCTYPE html>
<html lang="fr">
<head>
<title>Mon Projet Symfony</title>
</head>
<body>
<h1>Bienvenue sur Mon Projet Symfony !</h1>
</body>
</html>
-
Modifiez le template pour qu'il étend de celui de base dans le fichier `templates/base.html.twig` :
{% extends "base.html.twig" %}
{% block title %}Mon Projet Symfony{% endblock %}
{% block body %}
<h1>Bienvenue sur Mon Projet Symfony !</h1>
{% endblock %}
On uilise ici deux tags (délimités par {% %}) :
-
extends permet d'indiquer qu'on souhaite étendre d'un autre template, ici base.html.twig qui est en général créé par défaut lors d'une installation classique de Symfony.
-
block permet de placer du contenu dans un block qui est utilisé dans le template dont on hérite.
Si on a juste du texte dans un bloc comme ceci :
{% block title %}Toutes mes recettes{% endblock %}
On peut le remplacer avec cela :
{% block title 'Toutes mes recettes' %}
On peut concaténer plusieurs variables via le ~ dans une vue Twig :
{{ person.lastname ~ person.firstname }}
On peut également faire un dd dans la vue Twig :
{{ dump(person) }}
-
Vous pouvez également modifier votre template de base pour intégrer Bootstrap : au-dessus du block stylesheets, on met le lien CDN CSS de Bootstrap et, au-dessus du block javascripts, le lien CDN JS de Bootstrap.
-
On peut également inclure un template Twig dans un autre comme par exemple :
{% include "partials/_header.html.twig" %}
Ça permet donc d'inclure le fichier partiel "header" pour avoir le même header sur toutes les pages.
Pour découvrir les fonctionnalités de Twig, il y a deux liens essentiels :
Étape 5 : Définir la route :
Une fois ce contrôleur créé, il va falloir indiquer au framework quelle URL correspondra à quelle action. Cela peut se faire de deux manières :
-
Dans le dossier config, on peut éditer le fichier routes.yaml pour enregistrer notre route.
# Ajoutez cette ligne au fichier routes.yaml
home:
path: /
controller: App\Controller\DefaultController::index
-
Les routes peuvent aussi être définies en utilisant des attributs PHP directement au niveau des méthodes de notre contrôleur comme dans le code du DefaultController ci-dessus.
-
Pour lister la liste des routes utilisées dans notre application, on utilise la commande :
php bin/console debug:router
Étape 6 : Accéder à votre application :
Ouvrez votre navigateur web et accédez à l'URL : `http://localhost:8000`. Vous devriez voir le message "Bienvenur sur Mon Projet Symfony !" sur la page d'accueil.
C'est tout ! Vous avez maintenant créé une application web simple en utilisant Symfony. Vous pouvez continuer à explorer la documentation officielle de Symfony pour découvrir plus de fonctionnalités et d'outils puissants offerts par ce framework.
En 2024, je rajoute de nouvelles étapes pour améliorer ce tutoriel sur Symfony. Voici les nouveautés :
Étape 7 : Objet Request :
Maintenant, comment ça se passe si on veut gérer des paramètres dans l'url ? Par exemple : ?name=john. Par défaut, ce qu'on ferait dans une application PHP standard, c'est qu'on mettrait un appel à $_GET et on irait récupérer la clef name. Ce n'est pas la bonne manière de faire ça dans le cadre d'une appplication Symfony. Avec le framework, lorsque j'utilise une fonction, je peux lui injecter un paramètre supplémentaire qui va permettre de récupérer les informations sur la requête. Ce paramètre devra être de type Symfony\Component\HttpFoundation\Request.
<?php
namespace App\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class HomeController
{
#[Route('/', name: 'home')]
function index (Request $request): Response
{
return new Response('Bonjour ' . $request->query->get('name', 'Anonyme'));
}
}
?>
Dès lors que l'on veut récupérer des informations sur la requête de l'utilisateur, on utilisera cet objet.
On aura sûrement l'occassion de revenir sur cet objet Request et ses méthodes plus tard.
Étape 8 : URLs dynamiques :
Maintenant, ce qu'on aimerait bien faire c'est avoir des URLs qui soient plus proches de la réalité. Par exemple, si je me rends sur un blog, souvent les URLs des articles contiennt un slug suivi d'un ID.
https://drissv.github.io/Syllabus/mon-premier-article-32
Et on aimerait bien faire pareil avec notre Routing, pour cela on va déclarer une nouvelle route qui aura des paramètre mis entre accolades.
#[Route('/recettes/{slug}-{id}', name: 'recipe.show', requirements: ['id' => Requirement::DIGITS, 'slug' => '[a-z0-9]+'])]
public function index (Request $request): Response
{
}
Les paramètres sont ensuite accessibles dans l'objet requête.
$request->attributes->get('id)
Mais peuvent aussi être automatiquement injectés en modifiant les paramètres de la fonction.
#[Route('/recettes/{slug}-{id}', name: 'recipe.show', requirements: ['id' => '\d+', 'slug' => '[a-z0-9]+'])]
public function index (Request $request, string $slug, int $id): Response
{
}
Étape 9 : Notre première entité :
Cette étape nous permet de découvrir comment stocker et afficher des données provenant d'une base de données en utilisant l'ORM Doctrine qui va nous permettre de récupérer et de modifier des informations plus facilement.
Pour commencer, on va modifier notre fichier .env ou .env.local pour définir la configuration permettant de se connecter à la base de données via la variable DATABASE_URL. Sa syntaxe sera :
DATABASE_URL="mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=8.0.31&charset=utf8mb4"
On doit remplacer "db_user", "db_password" et "db_name" par les valeurs que l'on a définies. Vous pouvez vous inspirer des lignes en commentaire pour adapter à votre SGBD. Le numéro de version est important car cela permet à doctrine de générer des requêtes SQL compatibles avec votre version. Si la base de données avec le nom stocké dans "db_name" n'est pas encore dans PHP My Admin, on peut la créer avec la commande suivante :
php bin/console doctrine:database:create
Ou on peut utiliser la commande abrégée :
symfony console d:d:c
Remarque : Ça lance une erreur si la base de données existe déjà. On verra sûrement un peu plus tard comment faire pour la supprimer.
L'ORM permet de représenter nos données sous forme d'objets qui pourront ensuite être persistés en base de données grâce à des méthodes spécifiques. Il se chargera de générer les requêtes SQL et de s'adapter au système de gestion de base de données utilisé.
Dans un projet de recettes de cuisine, on a besoin d'une entité pour l'ingrédient qui sera définie par :
-
Un nom qui ne pourra pas excéder plus de 50 caractères et devra faire au minimum 2 caractères.
-
Un prix au kg (par exemple 2 euros le kilo) qui ne pourra être inférieur à 1 et supérieur à 200. Le prix pourra contenir des décimales.
-
Un champ contenant la date de création qui doit être générée automatiquement une fois l'ingrédient créé.
Pour créer cette entité, on utilise la commande :
php bin/console make:entity Ingredient
Cette commande crée deux fichiers : `src/Entity/Ingredient.php` et `src/Repository/IngredientRepository.php`.
Ensuite, cette commande va vous poser des questions pour définir les champs dont vous avez besoin : le nom des propriétés, son type, sa longueur et s'il est nullable en base de données. Par exemple, la propriété "name" est une "string" de "50" caractères "no" nullable.
Une fois terminée cette commande va générer deux fichiers :
-
Une entité qui est un objet PHP classique qui va représenter les données. Cet objet possède des attributs PHP qui permettent à Doctrine de comprendre comment sont sauvegardées les données dans la base de données.
-
Un repository, qui permet de récupérer les enregistrements en utilisant des requêtes SQL.
Voici la classe entité "Ingredient.php" (je ne mettrais pas tous les getters et les setters) :
<?php
namespace App\Entity;
use App\Repository\IngredientRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
#[ORM\Entity(repositoryClass: IngredientRepository::class)]
#[UniqueEntity('name')]
class Ingredient
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id;
#[ORM\Column(type: 'string', length: 50)]
private string $name;
#[ORM\Column(type: 'float')]
private float $price;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function __toString()
{
return $this->name;
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string name): self
{
$this->name = $name;
return $this;
}
}
?>
Ensuite, une fois que l'on a créé nos entités, il va falloir créer les tables associées dans notre base de données, et, comme avec Laravel, on va faire une migration pour migrer les entités dans la base de données avec la commande :
php bin/console make:migration
Cette commande va comparer la structure actuelle de la base de données avec la structure attendue. Dans cette migration, on a du SQL pur et dur. Il gén!rera un fichier de migration qui contiendra les requêyes SQL à effectuer pour changer la structure de la base de données pour correspondre au code de notre application. Symfony a interprété l'entité en SQL et donc a fait un CREATE TABLE dans la fonction up et un DROP TABLE dans la fonction down.
Maintenant on va migrer toutes les migrations vers la base de données avec la commande :
php bin/console doctrine:migrations:migrate
Ou encore avec la commande abrégée :
php bin/console d:m:m
Si la base de données était vide, ça rajoute une table supplémentaire de la table "Ingredient" de notre migration qui est nommée "doctrine_migration_versions" et qui contient la version de la migration, la date d'exécution et le temps d'exécution en milisecondes.
Maintenant que notre entité et notre table sont créés, on va pouvoir utiliser l'ORM pour récupérer et modifier nos données.
Pour gérer les différentes contraintes des données, comme par exemple que le prix ne doit pas être inférieur à 1, on utilise des Assert.
La validation va se faire au travers d'attributs que l'on va pouvoir placer sur nos entités et nos différents modèles et qui permettront de définir ce qui constitue une structure valide. Ces attributs seront automatiquement lus par le composant formulaire et permettront de vérifier si les données qui ont été postées par l'utilisateur sont justes.
Reprenons l'exemple de notre entité "Ingredient" :
<?php
namespace App\Entity;
use App\Repository\IngredientRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
#[ORM\Entity(repositoryClass: IngredientRepository::class)]
#[UniqueEntity('name')]
class Ingredient
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id;
#[ORM\Column(type: 'string', length: 50)]
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 50)]
private string $name;
#[ORM\Column(type: 'float')]
#[Assert\NotNull()]
#[Assert\Positive()]
#[Assert\LessThan(200)]
private float $price;
#[ORM\Column(type: 'datetime_immutable')]
#[Assert\NotNull()]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function __toString()
{
return $this->name;
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string name): self
{
$this->name = $name;
return $this;
}
}
?>
Il sera aussi possible d'utiliser le composant de validation de manière isolée pour valider des données dans un contexte autre que celui du formulaire.
Pour créer une contrainte personnalisée, vous pouvez utiliser la commande :
symfony console make:validator
Liens utiles :
Étape 10 : Création des Fixtures :
Quand on travaille sur une application, on a souvent besoin de données pour tester nos fonctionnalités. Dans la suite de ce cours, on va créer des fixtures qui permettent de nous générer un jeu de fausses données de test comme un Faker.
php bin/console make:fixture
Pour faire cela, on va installer les packages en développement :
composer require --dev orm-fixtures
Ainsi que la commande :
composer require --dev fakerphp/faker
Ces commandes ont créer le dossier `src/DataFixtures` et dedans le fichier AppFixtures avec le contenu que l'on va modifier comme cela :
<?php
namespace App\DataFixtures;
use Faker\Factory;
use Faker\Generator;
use App\Entity\Ingredient;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class AppFixtures extends Fixture
{
/**
* @var Generator
*/
private Generator $faker;
public function __construct()
{
$this->faker = Factory::create('fr_FR');
}
public function load(ObjectManager $manager): void
{
// Ingredients
for ($i = 0; $i < 50; $i++) {
$ingredient = new Ingredient();
$ingredient->setName($this->faker->word())->setPrice(mt_rand(0, 100));
$manager->persist($ingredient);
}
$manager->flush();
}
}
?>
Ensuite, on va les charger avec la commande :
php bin/console doctrine:fixtures:load
Ou encore avec la commande abrégée :
symfony console d:f:l
Cette commande va commencer par effacer les enregistrements de votre base de données, et la remplira ensuite avec les données définies dans les fixtures.
Étape 11 : CRUD sur la table "Ingredient" :
Maintenant, on va enfin faire des CRUD sur la table "Ingredient". D'abord, on va afficher la liste des ingrédients.
Premièrement, on va créer un nouveau contrôleur "IngredientController" avec la commande :
php bin/console make:controller IngredientController
Remarque : Ça va créer le fichier `templates/ingredient/index.html.twig`, mais, pour faire un peu le ménage, on va créer un dossier "pages" et donc on va y insérer dedans le dossier "ingredient".
Lister tous les ingrédients :
Modifions la fonction "index" de notre contrôleur fraîchement créé :
#[Route('/ingredient', name: 'ingredient.index', methods: ['GET'])]
public function index(IngredientRepository $repository) : Response
{
$ingredients = $repository->findAll();
// dd($ingredients);
return $this->render('pages/ingredient/index.html.twig', [
'ingredients' => $ingredients
]);
}
On va modifier le fichier TWIG comme ceci :
{% extends 'base.html.twig' %}
{% block title %}Mes ingréidents{% endblock %}
{% block body %}
<div class="container mt-4">
<h1>Mes ingrédients</h1>
<table class="table table-hover">
<thead>
<tr>
<th scope="col">Numéro</th>
<th scope="col">Nom</th>
<th scope="col">Prix</th>
<th scope="col">Date de création</th>
</tr>
</thead>
<tbody>
{% for ingredient in ingredients %}
<tr class="table-primary">
<th scope="row">{{ ingredient.id }}</th>
<td>{{ ingredient.name }}</td>
<td>{{ ingredient.price }}</td>
<td>{{ ingredient.createdAt|date('d/m/Y') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
Pour récupérer toutes les recettes avec une durée plus petite ou égale à un certain nombre de minutes, on peut créer une nouvelle méthode dans le repository de l'entité Recipe, c'est-à-dire le RecipeRepository :
/**
* return Recipe[]
*/
public function findWithDurationLowerThan(int $duration): array
{
return $this->createQueryBuilder('r')
->where('r.duration <= :duration')
->orderBy('r.duration', 'ASC')
->setMaxResults(10)
->setParameter('duration', $duration)
->getQuery()
->getResult();
}
Jusqu'à maintenant, nous avons utilisé un findAll() pour lister l'ensemble des résultats. Mais, dans un cas réel, on va vouloir imposer une limite sur le nombre de résultats à afficher et on va souhaiter mettre en place un système de pagination.
Doctrine dispose d'une classe permettant de gérer la pagination.
<?php
namespace App\Repository;
use Doctrine\ORM\Tools\Pagination\Paginator;
// ...
class RecipeRepository extends ServiceEntityRepository
{
public function paginateRecipes(int $page, int $limit): Paginator
{
$query = $this
->createQueryBuilder('r')
->setFirstResult(($page -1) * $limit)
->setMaxResults($limit)
->getQuery()
->setHint(Paginator::HINT_ENABLE_DISTINCT, false);
$paginator = new Paginator($query, fetchJoinCollection: false);
}
}
?>
Cet objet paginator implémente les interfaces Countable et IteratorAggregate. Lors du count(), il génèrera une requête permettant de compter l'ensemble des résultats.
Voici le code dans notre contrôleur :
$page = $request->query->getInt('page', 1);
$limit = 2;
$recipes = $repository->paginateRecipes($page, $limit);
$maxPage = ceil($recipes->count() / $limit);
Voilà le code dans ma vue :
{% if page > 1 %}
<a href="{{ path('recipe.index', {page: page - 1}) }}" class="btn btn-secondary">Page précédente</a>
{% endif %}
{% if page < maxPage %}
<a href="{{ path('recipe.index', {page: page + 1}) }}" class="btn btn-secondary">Page suivante</a>
{% endif %}
Pour la pagination dans la page, on va utiliser le bundle KnpPaginatorBundle qui offre plus de fonctionnalités avec la possibilité de gérer l'organisation mais aussi la partie template avec la structure HTML d'une pagination. Aussi, si votre objectif est de paginer des résultats dans une page Web, je vous conseille d'utiliser ce bundle. On peut l'installer avec la commande :
composer require knplabs/knp-paginator-bundle
On va créer le fichier `config/packages/knp_paginator.yaml` avec le contenu suivant :
knp_paginator:
page_range: 5 # number of links shown in the pagination menu (e.g: you have 10 pages, a page_range of 3, on the 5th page you'll see links to page 4, 5, 6)
default_options:
page_name: page # page query parameter name
sort_field_name: sort # sort field query parameter name
sort_direction_name: direction # sort direction query parameter name
distinct: true # ensure distinct results, useful when ORM queries are using GROUP BY statements
filter_field_name: filterField # filter field query parameter name
filter_value_name: filterValue # filter value query parameter name
template:
pagination: '@KnpPaginator/Pagination/bootstrap_v5_pagination.html.twig' # sliding pagination controls template
# rel_links: '@KnpPaginator/Pagination/rel_links.html.twig' # <link rel=...> tags template
sortable: '@KnpPaginator/Pagination/bootstrap_v5_bi_sortable_link.html.twig' # sort link template
filtration: '@KnpPaginator/Pagination/bootstrap_v5_filtration.html.twig' # filters template
Modifions notre fonction "index" de notre cotrôleur pour qu'elle prenne en compte la pagination :
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* This controller display all ingredients
*
* @param IngredientRepository $repository
* @param PaginatorInterface $paginator
* @param Request $request
* @return Response
*/
#[Route('/ingredient', name: 'ingredient.index', methods: ['GET'])]
public function index(IngredientRepository $repository, PaginatorInterface $paginator, Request $request) : Response
{
$ingredients = $paginator->paginate(
$repository->findAll(),
$request->query->getInt('page', 1),
10,
[
'distinct' => false,
'sortFieldAllowList' => ['r.id', 'r.title']
]
);
// dd($ingredients);
return $this->render('pages/ingredient/index.html.twig', [
'ingredients' => $ingredients
]);
}
Ou on peut placer ce code dans le repository :
public function paginateRecipes(int $page): PaginatorInterface
{
return $this->paginator->paginate(
$this->createQueryBuilder('r')->leftJoin('r.category', 'c')->select('r', 'c'),
$page,
20,
[
'distinct' => false,
'sortFieldAllowList' => ['r.id', 'r.title']
]
);
}
On va modifier également le fichier TWIG en ajoutant ceci :
<div class="count mt-4">
<p>Il y a {{ ingredients.getTotalItemCount }} ingrédients au total</p>
</div>
<div class="navigation d-flex justify-content-center mt-4">
{{ knp_pagination_render(ingredients) }}
</div>
On peut faire un {% if not ingredients.items is same as([]) %} pour vérifier que la liste n'est pas vide.
On peut également trier le tableau :
<table class="table">
<thead>
<tr>
<th>{{ knp_pagination_sortable(recipes, 'ID', 'r.id') }}</th>
<th>{{ knp_pagination_sortable(recipes, 'Titre', 'r.title') }}</th>
// ...
</tr>
</thead>
// ...
</table>
Créer un nouvel ingrédient :
La gestion des formulaires se faire au travers de classes qui étendent de Symfony\Component\Form\AbstractType et qui vont décrire la structure du formulaire.
Pour la création d'un nouvel ingrédient, on va créer un nouveau formulaire "IngredientType" avec l'entité "Ingredient" via la commande :
php bin/console make:form
Ça permet de créer le fichier `src/Form/IngredientType.php` avec le contenu suivant :
<?php
namespace App\Form;
use App\Entity\Ingredient;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
class IngredientType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'attr' => [
'class' => 'form-control',
'minlength' => '2',
'maxlength' => '50'
],
'label' => 'Nom',
'label_attr' => [
'class' => 'form-label mt-4'
],
'constraints' => [
new Assert\Length(['min' => 2, 'max' => 50]),
new Assert\NotBlank()
]
])
->add('price', MoneyType::class, [
'attr' => [
'class' => 'form-control',
],
'label' => 'Prix',
'label_attr' => [
'class' => 'form-label mt-4'
],
'constraints' => [
new Assert\Positive(),
new Assert\LessThan(200),
new Assert\NotNull()
]
])
->add('submit', SubmitType::class, [
'attr' => [
'class' => 'btn btn-primary mt-4'
],
'label' => 'Créer mon ingrédient'
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Ingredient::class,
]);
}
}
?>
Il est possible d'utiliser ce formulaire dans notre controller.
Dans notre IngredientController, on va créer une nouvelle fonction "new" :
/**
*
* This controller show a form which create an ingredient
*
* @param Request $request
* @param EntityManagerInterface $manager
* @return Response
*/
#[Route('/ingredient/nouveau', 'ingredient.new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $manager): Response
{
$ingredient = new Ingredient();
$form = $this->createForm(IngredientType::class, $ingredient);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$ingredient = $form->getData();
$manager->persist($ingredient);
$manager->flush();
$this->addFlash('success', 'Votre ingrédient a été créé avec succès !');
return $this->redirectToRoute('ingredient.index');
}
return $this->render('pages/ingredient/new.html.twig', [
'form' => $form->createView()
]);
}
Il y a plusieurs méthodes qui nous intéressent :
-
createForm, permet de créer une instance de notre formulaire remplit avec les données passées en second paramètre.
-
handleRequest(), permet de passer les informations soumises dans le formulaire (si le formulaire a été rempli) et de mettre à jour les données qui sont à l'intérieur.
-
isSubmitted(), vérifie si le formulaire a été envoyé.
-
isValid(), utilisera les contraintes de validation définies sur l'entité et sur les champs pour vérifier que les données sont valides.
L'onjet formulaire est envoyé dans la vue Twig qui pourra utiliser des fonctions spéciales pour générer les différents champs.
On va également créer le nouveau template `templates/ingredient/new.html.twig` avec le contenu suivant :
{% extends "base.html.twig" %}
{% block body %}
<div class="container">
<h1 class="mt-4">Création d'un ingrédient</h1>
{{ form(form) }}
</div>
{% endblock %}
Pour ajouter des messages d'erreur, on va modifier le code précédent pour donner ceci :
{% extends "base.html.twig" %}
{% block body %}
<div class="container">
<h1 class="mt-4">Création d'un ingrédient</h1>
{{ form_start(form) }}
<div class="form-group">
{{ form_label(form.name) }}
{{ form_widget(form.name) }}
<div class="form-error">
{{ form_errors(form.name) }}
</div>
</div>
<div class="form-group">
{{ form_label(form.price) }}
{{ form_widget(form.price) }}
<div class="form-error">
{{ form_errors(form.price) }}
</div>
</div>
<div class="form-group">
{{ form_row(form._token) }}
</div>
<div class="form-group">
{{ form_row(form.submit) }}
</div>
{{ form_end(form) }}
</div>
{% endblock %}
Si on met en style quelques champs du formulaire, on peut utiliser le code ci-dessous pour afficher le reste du formulaire :
{{ form_rest(form) }}
On va afficher un message FLASH dans l'index pour dire que la création d'un ingrédient a eu un statut de succès ainsi qu'un lien qui permet d'accéder à la page de création d'un nouvel ingrédient. Voici le code à rajouter :
<a href="{{ path('ingredient.new') }} class="btn btn-primary">Créer un ingrédient</a>
{# read and display just one flash message type #}
{% for message in app.flashes('success') %}
<div class="alert alert-success mt-4">
{{ message }}
</div>
{% endfor %}
Comme ça peut être des messages flash génériques qui peuvent être utilisés dans plusieurs autres pages, on peut créer un nouveau fichier qu'on va nommer `_partials/flash.html.twig` :
{% for type, messages in app.flashes %}
<div class="alert alert-{type}">
{{ messages | join('. ') }}
</div>
{% endfor %}
Dans la navbar, on oublie pas de rajouter un lien vers "ingredient.index" pour accéder plus facilement à la liste des ingrédients.
Éditer un ingrédient :
Dans notre contrôleur, on va rajouter une nouvelle fonction pour pouvoir éditer un ingrédient :
/**
*
* This controller show a form which edit an ingredient
*
* @param IngredientRepository $repository
* @param int $id
* @param Request $request
* @param EntityManagerInterface $manager
* @return Response
*/
#[Route('/ingredient/edition/{id}', 'ingredient.edit', methods: ['GET', 'POST'])]
public function edit(IngredientRepository $repository, int $id, Request $request, EntityManagerInterface $manager) : Response
{
$ingredient = $repository->findBy(['id' => $id]);
$form = $this->createForm(IngredientType::class, $ingredient);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$ingredient = $form->getData();
$manager->persist($ingredient);
$manager->flush();
$this->addFlash('success', 'Votre ingrédient a été modifié avec succès !');
return $this->redirectToRoute('ingredient.index');
}
return $this->render('pages/ingredient/edit.html.twig', [
'form' => $form->createView()
]);
}
On peut utiliser directement la méthode find du repository au lieu de la méthode findBy :
$ingredient = $repository->find($id);
Ça c'est ce que la plupart des gens font d'habitude, mais nous on va utiliser un @ParamConverter comme ceci :
/**
*
* This controller show a form which edit an ingredient
*
* @param Ingredient $ingredient
* @param Request $request
* @param EntityManagerInterface $manager
* @return Response
*/
#[Route('/ingredient/edition/{id}', 'ingredient.edit', methods: ['GET', 'POST'])]
public function edit(Ingredient $ingredient, Request $request, Entity $manager) : Response
{
$form = $this->createForm(IngredientType::class, $ingredient);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$ingredient = $form->getData();
$manager->persist($ingredient);
$manager->flush();
$this->addFlash('success', 'Votre ingrédient a été modifié avec succès !');
return $this->redirectToRoute('ingredient.index');
}
return $this->render('pages/ingredient/edit.html.twig', [
'form' => $form->createView(),
]);
}
Ensuite, dans le "edit.html.twig", on va copier le contenu du "new.html.twig" et modifier "Création" par "Modification".
Supprimer un ingrédient :
Pour le delete, on rajoute une nouvelle fonction "delete" dans le contrôleur :
#[Route('/ingredient/suppression/{id}', 'ingredient.delete', methods: ['GET'])]
public function delete(EntityManagerInterface $manager, Ingredient $ingredient) : Response
{
$manager->remove($ingredient);
$manager->flush();
$this->addFlash('success', 'Votre ingrédient a été supprimé avec succès !');
return $this->redirectToRoute('ingredient.index');
}
Dans la liste des ingrédients, on va rajouter deux nouvelles colonnes : une pour l'édition et l'autre pour la suppression :
<th scope="col">Édition</th>
<th scope="col">Suppression</th>
<td>
<a href="{{ path('ingredient.edit', { id: ingredient.id }) }}" class="btn btn-info">Modifier</a>
</td>
<td>
<a href="{{ path('ingredient.delete', { id: ingredient.id }) }}" class="btn btn-danger">Supprimer</a>
</td>
Pour faire que ce soit une méthode DELETE, on doit remplacer le lien par un formulaire :
<form action="{{ path('ingredient.delete', { id: ingredient.id }) }}" method="post">
<input type="hidden" name="_method" value="DELETE">
<button type="submit" class="btn btn-danger btn-danger">Supprimer</button>
</form>
Et on doit également modifier le fichier de configuration `package/framework.yaml`, on va rajouter le code suivant dans la partie framework :
http_method_override: true
On peut utiliser un système d'événements au niveau des formulaires, les Form Events. Lorsqu'on soumet un formulaire, il y a différents événements qui sont envoyés : PRE_SUBMIT, SUBMIT et POST_SUBMIT.
Dans notre formulaire, on rajoute l'événement :
<?php
namespace App\Form;
use Symfony\Component\Form\Event\PreSubmitEvent;
use Symfony\Component\Form\Event\PostSubmitEvent;
use Symfony\Component\String\Slugger\AsciiSlugger;
// ...
// ... {
$builder
// ...
->addEventListener(FormEvents::PRE_SUBMIT, $this->autoSlug(...))
->addEventListener(FormEvents::POST_SUBMIT, $this->attachTimestamps(...))
}
public function autoSlug(PreSubmitEvent $event): void
{
$data = $event->getData();
if (empty($data['slug'])) {
$slugger = new AsciiSlugger();
$data['slug'] = strtolower($slugger->slug($data['title']));
$event->setData($data);
}
}
public function attachTimestamps(PostSubmitEvent $event): void
{
$data = $event->getData();
if (!($data instanceof Recipe)) {
return;
}
$data->setUpdatedAt(new \DateTimeImmutable());
if (!$data->getId()) {
$data->setCreatedAt(new \DateTimeImmutable());
}
}
// ...
?>
Pour rendre générique notre listener, on peut créer la classe `FormListenerFactory` :
<?php
namespace App\Form;
use Symfony\Component\Form\Event\PreSubmitEvent;
use Symfony\Component\Form\Event\PostSubmitEvent;
use Symfony\Component\String\Slugger\AsciiSlugger;
class FormListenerFactory
{
public function autoSlug(string $field): callable
{
return function (PreSubmitEvent $event) us ($field) {
$data = $event->getData();
if (empty($data['slug'])) {
$slugger = new AsciiSlugger();
$data['slug'] = strtolower($slugger->slug($data[$field]));
$event->setData($data);
}
};
}
public funtion timestamps(): callable
{
return function (PostSubmitEvent $event) {
$data = $event->getData();
$data->setUpdatedAt(new \DateTimeImmutable());
if (!$data->getId()) {
$data->setCreatedAt(new \DateTimeImmutable());
}
};
}
}
?>
On va pouvoir modifier notre formulaire RecipeType :
<?php
// ...
public function __construct(private FormListenerFactory $listenerFactory)
{
}
// ... {
// ...
->addEventListener(FormEvents::PRE_SUBMIT, $this->listenerFactory->autoSlug('title'))
->addEventListener(FormEvents::POST_SUBMIT, $this->listenerFactory->timestamps());
}
?>
Étape 12 : CRUD pour la table "Recipe" :
Maintenant, on va faire le CRUD pour la table "Recipe". Pour information, une recette sera définie par :
-
Un nom qui sera obligatoire et ne pourra pas être vide, il ne pourra également pas excéder plus de 5à caractères et devra faire au minimum 2 caractères.
-
Un temps (en minutes) qui n'est pas obligatoire. S'il est rempli, il ne pourra pas être inférieur à une minute et ne pourra pas dépasser les 24h.
-
Un nombre de personnes qui n'est pas obligatoire. S'il est rempli, il devra être inférieur à 50.
-
Une difficulté n'est pas obligatoire. Si elle esy rentrée, elle sera comprise entre 1 et 5.
-
Une liste d'étapes à suivre/description qui sera obligatoire et ne pourra pas être vide.
-
Un prix qui ne sera pas obligatoire. S'il est renseigné, le prix ne pourra pas être inférieur à 0 et supérieur à 1000. Le prix pourra contenir des décimales.
-
La possibilité de définir la recette comme étant favorite ou non.
-
Une date de création.
-
Une date de mise à jour.
-
Une liste d'ingrédients.
Remarque : La date de création et la date de mise à jour seront générées automatiquement une fois la recette créée et/ou modifiée.
Jusqu'à maintenant, nous n'avons travaillé qu'avec une seule entité à la fois mais, dans un cas réel, on a souvent besoin de lier des données ensemble pour par exemple mettre en place un système de catégorie pour les recettes.
Premièrement, on doit créer l'entité "Recipe" avec les propriétés "name" de type "string" non nullable, "time" de type "integer" nullable, "nbPeople" de type "integer" nullable, "difficulty" de type "integer" nullable, "description" de type "text" non nullable, "price" de type "float" nullable, "isFavorite" de type "boolean" non nullable, "createdAt" de type "datetime_immutable" non nullable, "updatedAt" de type "datetime_immutable" non nullable.
Pour la propriété "ingredients" qui définit la liste des ingrédients, on va dire qu'il est de type "relation" lié à l'entité "Ingredient" dont le type de relation est "ManyToMany" et on veut pas récupérer depuis les ingrédients les recettes qui ont cet ingrédient.
Au niveau de la gestion, l'ORM fait abstraction de la relation et, si vous souhaitez modifier des données croisées, il vous suffit de manipuler des objets de manière traditionnelle.
Voici donc le code généré par la commande php bin/console make:entity Recipe sans tous les getters et les setters et qu'on va modifier en ajoutant les Assert :
<?php
namespace App\Entity;
use App\Repository\RecipeRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[UniqueEntity('name')]
#[ORM\HasLifecycleCallbacks]
#[ORM\Entity(repositoryClass: RecipeRepository::class)]
class Recipe
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id;
#[ORM\Column(type: 'string', length: 50)]
#[Assert\NotBlank()]
#[Assert\Length(min: 2, max: 50)]
private string $name;
#[ORM\Column(type: 'integer', nullable: true)]
#[Assert\Positive()]
#[Assert\LessThan(1441)]
private ?int $time;
#[ORM\Column(type: 'integer', nullable: true)]
#[Assert\Positive()]
#[Assert\LessThan(51)]
private ?int $nbPeople;
#[ORM\Column(type: 'integer', nullable: true)]
#[Assert\Positive()]
#[Assert\LessThan(6)]
private ?int $difficulty;
#[ORM\Column(type: 'text')]
#[Assert\NotBlank()]
private string $description;
#[ORM\Column(type: 'float', nullable: true)]
#[Assert\Positive()]
#[Assert\LessThan(1001)]
private ?float $price;
#[ORM\Column(type: 'boolean')]
private bool $isFavorite;
#[ORM\Column(type: 'datetime_immutable')]
#[Assert\NotNull()]
private \DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable')]
#[Assert\NotNull()]
private \DateTimeImmutable $updatedAt;
#[ORM\ManyToMany(targetEntity: Ingredient::class)]
private $ingredients;
public function __construct() {
$this->ingredients = new ArrayCollection();
$this->createdAt = new \DateTimeImmutable();
$this->updatedAt = new \DateTimeImmutable();
}
#[ORM\PrePersist]
public function setUpdatedAt()
{
$this->updatedAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->string;
}
public function setName(string $name) : self
{
$this->name = $name;
return $this;
}
}
?>
Dans ma solution, je vais d'abord "DROP" toute la base de données avec la commande :
symfony console d:d:d --force
Avant de la recréer avec la commande :
symfony console d:d:c
Afin de créer une nouvelle migration avec la commande :
symfony console make:migration
Et de migrer dans la base de données toutes les migrations avec la commande :
php bin/console d:m:m
Deuxièmement, on va créer les fixtures en modifiant le fichier `src/DataFixtures/AppFixtures.php` comme ceci :
<?php
namespace App\DataFixtures;
use Faker\Factory;
use Faker\Generator;
use App\Entity\Ingredient;
use App\Entity\Recipe;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class AppFixtures extends Fixture
{
/**
* @var Generator
*/
private Generator $faker;
public function __construct()
{
$this->faker = Factory::create('fr_FR');
}
public function load(ObjectManager $manager): void
{
// Ingredients
$ingredients = [];
for ($i = 0; $i < 50; $i++) {
$ingredient = new Ingredient();
$ingredient->setName($this->faker->word())->setPrice(mt_rand(0, 100));
$ingredients[] = $ingredient;
$manager->persist($ingredient);
}
// Recipes
$recipes = [];
for ($j = 0; $j < 25; $j++) {
$recipe = new Recipe();
$recipe->setName($this->faker->word())
->setTime(mt_rand(0, 1) == 1 ? mt_rand(1, 1440) : null)
->setNbPeople(mt_rand(0, 1) == 1 ? mt_rand(1, 50) : null)
->setDifiiculty(mt_rand(0, 1) == 1 ? mt_rand(1, 5) : null)
->setDescription($this->faker->text(300))
->setPrice(mt_rand(0, 1) == 1 ? mt_rand(1, 1000) : null)
->setIsFavorite(mt_rand(0, 1) == 1);
for ($k = 0; $k < mt_rand(5, 15); $k++) {
$recipe->addIngredient($ingredients[array_rand($ingredients)]);
}
}
$recipes[] = $recipe;
$manager->flush();
}
}
?>
Et on va pouvoir charger les nouvelles données factives avec la commande :
symfony console d:f:l
Troisièmement, on va commencer le CRUD par lister toutes les recettes. Pour cela, on va créer le contrôleur "RecipeController" avec la commande :
php bin/console make:controller RecipeController
Remarque : On oublie pas de déplacer le dossier templates "recipe" dans le dossier "pages" et d'ajouter un lien vers cette page dans la navbar :
<li class="nav-item">
<a class="nav-link {{ app.current_route starts with 'recipe.' ? 'active' : '' }}" href="{{ path('recipe.index') }}">Recettes</a>
</li>
Maintenant, modifions la fonction "index" du contrôleur comme ceci :
/**
* This controller display all recipes
*
* @param RecipeRepository $repository
* @param PaginatorInterface $paginator
* @param Request $request
* @return Response
*/
#[Route('/recette', name: 'recipe.index', methods: ['GET'])]
public function index(RecipeRepository $repository, PaginatorInterface $paginator, Request $request): Response
{
$recipes = $paginator->paginate(
$repository->findAll(),
$request->query->getInt('page', 1),
10
);
return $this->render('pages/recipe/index.html.twig', [
'recipes' => $recipes
]);
}
Quatrièmement, on va créer un nouveau formulaire pour la création d'une recette en rajoutant déjà dans le contrôleur la fonction "new" :
#[Route('/recette/creation', 'recipe.new', methods: ['GET', 'POST'])]
public function new(): Response
{
return $this->render('pages/recipe/new.html.twig');
}
Cinquièmement, on va créer le formulaire "RecipeType" lié à l'entité "Recipe" avec la commande suivante :
php bin/console make:form RecipeType Recipe
On va améliorer sa fonction "buildForm" en ajoutant par exemple un submit :
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'attr' => [
'class' => 'form-control',
'minlength' => '2',
'maxlength' => '50'
],
'label' => 'Nom',
'label_attr' => [
'class' => 'form-label mt-4'
],
'constraints' => [
new Assert\Length(['min' => 2, 'max' => 50]),
new Assert\NotBlank()
]
])
->add('time', IntegerType::class, [
'attr' => [
'class' => 'form-control',
'min' => 1,
'max' => 1440
],
'required' => false,
'label' => 'Temps (en minutes)',
'label_attr' => [
'class' => 'form-label mt-4'
],
'constraints' => [
new Assert\Possitive(),
new Assert\LessThan(1441)
]
])
->add('nbPeople', IntegerType::class, [
'attr' => [
'class' => 'form-control',
'min' => 1,
'max' => 50
],
'required' => false,
'label' => 'Nombre de personnes',
'label_attr' => [
'class' => 'form-label mt-4'
],
'constraints' => [
new Assert\Possitive(),
new Assert\LessThan(51)
]
])
->add('difficulty', RangeType::class, [
'attr' => [
'class' => 'form-range',
'min' => 1,
'max' => 5
],
'required' => false,
'label' => 'Difficulté',
'label_attr' => [
'class' => 'form-label mt-4'
],
'constraints' => [
new Assert\Possitive(),
new Assert\LessThan(6)
]
])
->add('description', TextareaType::class, [
'attr' => [
'class' => 'form-control'
],
'label' => 'Description',
'label_attr' => [
'class' => 'form-label mt-4'
],
'constraints' => [
new Assert\NotBlank()
]
])
->add('price', MoneyType::class, [
'attr' => [
'class' => 'form-control',
],
'required' => false,
'label' => 'Prix',
'label_attr' => [
'class' => 'form-label mt-4'
],
'constraints' => [
new Assert\Positive(),
new Assert\LessThan(1001),
new Assert\NotNull()
]
])
->add('isFavorite', CheckboxType::class, [
'attr' => [
'class' => 'form-check-input',
],
'label' => 'Favoris ?',
'label_attr' => [
'class' => 'form-check-label'
],
'constraints' => [
new Assert\NotNull()
]
])
->add('ingredients', EntityType::class, [
'class' => Ingredient::class,
'query_builder' => function(IngredientRepository $r) {
return $r->createQueryBuilder('i')
->orderBy('i.name', 'ASC');
},
'label' => 'Les ingrédients',
'label_attr' => [
'class' => 'form-label mt-4'
],
'choice_label' => 'name',
'multiple' => true,
'expanded' => true,
// 'by_reference' => false
])
->add('submit', SubmitType::class, [
'attr' => [
'class' => 'btn btn-primary mt-4'
],
'label' => 'Créer ma recette'
]);
}
On va modifier la fonction "new" précédemment créée :
/**
* This controller allow us to create a new recipe
*
* @param Request $request
* @param EntityManagerInterface $manager
* @return Response
*/
#[Route('/recette/creation', 'recipe.new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $manager): Response
{
$recipe = new Recipe();
$form = $this->createForm(RecipeType::class, $recipe);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$recipe = $form->getData();
$manager->persist($recipe);
$manager->flush();
$this->addFlash('success', 'Votre recette a été créée avec succès !');
return $this->redirectToRoute('recipe.index');
}
return $this->render('pages/recipe/new.html.twig', [
'form' => $form->createView()
]);
}
Sixièmement, on va créer dans le contrôleur la fonction "edit" :
/**
* This controller allow us to edit a recipe
*
* @param Recipe $recipe
* @param Request $request
* @param EntityManagerInterface $manager
* @return Response
*/
#[Route('/recette/edition/{id}', 'recipe.edit', methods: ['GET', 'POST'])]
public function edit(Recipe $recipe, Request $request, EntityManagerInterface $manager): Response
{
$form = $this->createForm(RecipeType::class, $recipe);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$recipe = $form->getData();
$manager->persist($recipe);
$manager->flush();
$this->addFlash('success', 'Votre recette a été modifiée avec succès !');
return $this->redirectToRoute('recipe.index');
}
return $this->render('pages/recipe/edit.html.twig', [
'form' => $form->createView()
]);
}
Septièmement, on rajoute également la méthode "delete" dans le contrôleur :
/**
* This controller allows us to delete a recipe
*
* @param EntityManagerInterface $manager
* @param Recipe $recipe
* @return Response
*/
#[Route('/recette/suppression/{id}', 'ingredient.delete', methods: ['GET'])]
public function delete(EntityManagerInterface $manager, Recipe $recipe): Response
{
$manager->remove($recipe);
$manager->flush();
$this->addFlash('success', 'Votre recette a été supprimée avec succès !');
return $this->redirectToRoute('recipe.index');
}
Dans notre entité, on peut persister ou supprimer une relation en cascade comme par exemple :
#[ORM\ManyToOne(inversedBy: 'recipes', cascade: ['persist'])]
private ?Category $category = null;
#[ORM\OneToMany(targetEntity: Recipe::class, mappedBy: 'category', cascade: ['remove'])]
private Collection $recipes;
Liens utiles :
Étape 13 : Sécurité & compte utilisateur :
Un compte utilisateur sera défini par :
-
Un nom et un prénom qui seront obligatoires. Ils devront faire entre 2 et 50 caractères.
-
Un pseudo qui sera facultatif et, s'il est renseigné, il devra également entre 2 et 50 caractères.
-
Une adresse email qui sera unique et servira d'identifiant lors de la connexion.
-
Un mot de passe qui sera encodé en base de données pour des questions de sécurité.
-
Une date de création qui sera générée seule.
-
Une date de modificaton sera également générée seule lors de la modification du profil utilisateur.
Pour cela, on utilise le composant Security de Symfony déjà installé, qui est un outil puissant et flexible qui permet de mettre en place un système d'authentification et d'autorisation et qui a généré le fichier `config/packages/security.yaml`. Dans Symfony, il y a une façon très simple de générer une entité qui permettra de représenter un utilisateur pour gérer la sécurité avec la commande :
php bin/console make:user
On nomme notre classe utilisateur "User", on la stocke dans la DB via Doctrine, on dit que la propriété "email" doit être unique et on a besoin de hasher les mots de passe. Voici le code généré (sans les getters et les setters) et que je modifie un peu :
<?php
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[UniqueEntity('email')]
#[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id;
#[ORM\Column(type: 'string', length: 180, unique: true)]
#[Assert\Email()]
#[Assert\Length(min: 2, max: 180)]
private string $email;
#[ORM\Column(type; 'json')]
#[Assert\NotNull()]
private array $roles = [];
private ?string $plainPassword = null;
#[ORM\Column(type: 'string')]
#[Assert\NotBlank()]
private string $password = 'password';
public function __construct()
{
}
public function getId(): ?int
{
return $this->id;
}
/**
* A visual identifier that represents this user
*
* @see UserInterface
*/
public function getUserIdentifier(): string
{
return (string) $this->email;
}
/**
* @see UserInterface
*/
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
public function setRoles(array $roles): self
{
$this->roles = $roles;
return $this;
}
}
?>
Dans le UserRepository, il a crée une méthode utilisée pour rehashé le mot de passe au fil du temps :
/**
* Used to upgrade (rehash) to user's password Automatically over time.
*/
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Instance of "%s" are not supported.', $user::class));
}
$user->setPassword($newHashedPassword);
$this->getEntityManager()->persist($user);
$this->getEntityManager->flush();
}
Avant de créer une migration pour cette entité et de la migrer, il faut modifier l'entité "User" avec la commande :
php bin/console make:entity User
On va rajouter les propriétés "fullName" de taille "50" non nullable, "pseudo" de taille "50" nullable, "createdAt" de type "datetime_immutable" non nullable et "updatedAt" de type "datetime_immutable" non nullable.
Donc, avec la commande `php bin/console make:migration` on crée la migration et avec `php bin/console d:m:m` pour la migrer.
Ensuite, dans le fichier "security.yaml", on va rajouter le provider comme suit :
providers:
# used to reload user from session & other features (e.g. switch user)
app_user_provider:
entity:
class: App\Entity\User
property: email
Pour hasher le mot de passe, on va définir un password_hashers s'il n'est pas défini dans le fichier security.yaml :
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: "auto"
Dans nos contrôleurs, si on veut limiter l'accès à certaines routes, on peut utiliser :
$this->denyAccessUnlessGranted('ROLE_USER');
Dans notre fixture, on va créer des utilisateurs fictifs :
// Users
$users = [];
for ($i = 0; $i < 10; $i++) {
$user = new User();
$user->setFullName($this->faker->name())
->setPseudo(mt_rand(0, 1) === 1 ? $this->faker->firstName() : null)
->setEmail($this->faker->email())
->setRoles(['ROLE_USER'])
->setPassword('password');
$users[] = $user;
$manager->persist($user);
}
On charge les nouveaux utilisateurs avec la commande :
php bin/console d:f:l
Ici, on peut voir que le mot de passe n'est pas encore crypté dans la base de données. Pour faire cela, on rajoute une propriété dans la classe "AppFixtures" et on modifie la fonction "load" :
private UserPasswordHasherInterface $hasher;
// Users
for ($i = 0; $i < 10; $i++) {
$user = new User();
$user->setFullName($this->faker->name())
->setPseudo(mt_rand(0, 1) === 1 ? $this->faker->firstName() : null)
->setEmail($this->faker->email())
->setRoles(['ROLE_USER']);
$hashPassword = $this->hasher->hashPassword($user, 'password');
$user->setPassword($hashPassword);
$manager->persist($user);
}
Le problème avec cette solution, c'est que n'importe où on doit toujours demander que le mot de passe soit hashé.
On voudrait exporter la logique de hashage du mot de passe à l'extérieur. Pour ce faire, on va utiliser les Entity Listeners de Symfony qui, comme leur nom l'indique, vont écouter ce qui se passe au niveau des entités et faire plusieurs actions. On va de nouveau modifier la fonction "load" :
// Users
for ($i = 0; $i < 10; $i++) {
$user = new User();
$user->setFullName($this->faker->name())
->setPseudo(mt_rand(0, 1) === 1 ? $this->faker->firstName() : null)
->setEmail($this->faker->email())
->setRoles(['ROLE_USER'])
->setPlainPassword('password');
$manager->persist($user);
}
Ensuite, on va rajouter un nouveau service dans le fichier `config/services.yaml` :
App\EntityListener\
resource: "../src/EntityListener/"
tags: ["doctrine.orm.entity_listener"]
Avant la déclaration de la classe de l'entité "User", on rajoute la ligne suivante :
#[ORM\EntityListeners(['App\EntityListener\UserListener'])]
Dans le dossier "src", on va créer un dossier "EntityListener" et y insérer le fichier "UserListener.php" avec le contenu suivant :
<?php
namespace App\EntityListener;
use App\Entity\User;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class UserListener
{
private UserPasswordHasherInterface $hasher;
public function __construct(UserPasswordHasherInterface $hasher)
{
$this->hasher = $hasher;
}
public function prePersist(User $user)
{
$this->encodePassword($user);
}
public function preUpdate(User $user)
{
$this->encodePassword($user);
}
/**
* Encode password based on plain password
*
* @param User $user
* @return void
*/
public function encodePassword(User $user)
{
if (is_null($user->getPlainPassword())) {
return;
}
$user->setPassword(
$this->hasher->hashPassword($user, $user->getPlainPassword())
);
$user->setPlainPassword(null);
}
}
?>
Remarques : Maintenant qu'on a le "$hasher" dans le "UserListener", on peut le supprimer dans les fixtures.
Étape 14 : Firewall et formulaire de connexion :
La section "Firewall" est la section la plus importante en terme de sécurité. Pour faire simple, le firewall sert à définir les parties de notre application qui sont sécurisées et comment les utilisateurs vont avoir la possibilité de s'authentifier (par exemple que ce soit via un formulaire de connexion ou par rapport à un token si on fait une API, etc).
On a deux firewalls : le "dev" et le "main". Le "dev" qui est un faux Firewall et le "main" qui de base n'est pas très sécurisé.
Maintenant, on va créer un nouveau contrôleur pour afficher la page de connexion que l'on nomme "SecurityController" avec la commande :
php bin/console make:controller SecurityController
Remarques : on déplace le dossier "templates/security" dans le dossier "templates/pages" et on renomme le fichier "index.html.twig" par "login.html.twig". Modifions la fonction "index" de ce nouveau contrôleur pour que ça devienne ceci :
use Symfony\Component\Security\Http\authentification\AuthenticationUtils;
/**
* This controller allow us to login
*
* @param AuthenticationUtils $authenticationUtils
* @return Response
*/
#[Route('/connexion', name: 'security.login', methods: ['GET', 'POST'])]
public function login(AuthenticationUtils $authenticationUtils): Response
{
$lastUsername = $authenticationUtils->getLastUsername();
$error = $authenticationUtils->getLastAuthenticationError();
return $this->render('pages/security/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error
]);
}
On va rajouter les lignes suivantes dans la configuration de notre "main" Firewall :
form_login:
login_path: security.login
check_path: security.login
On va ensuite créer la structure de notre formulaire de connexion dans "login.html.twig" :
{% extends 'base.html.twig' %}
{% block title %}Connexion{% endblock %}
{% block body %}
<div class="container">
<h1 class="mt-4">Formulaire de connexion</h1>
{% if error %}
<div class="alert alert-danger">
{{ error.messageKey|trans(error.messageData, 'security') }}
</div>
{% endif %}
<form action="{{ path('security.login') }}" method="post" name="login">
<div class="form-group">
<label for="username" class="form-label mt-4">Adresse email</label>
<input type="email" class="form-control" id="username" name="_username" value="{{ last_username }}">
</div>
<div class="form-group">
<label for="password" class="form-label mt-4">Mot de passe</label>
<input type="password" class="form-control" id="password" name="_password">
</div>
<button type="submit" class="btn btn-primary mt-4">
Se connecter
</button>
</form>
</div>
{% endblock %}
Voilà le formulaire fonctionne bien ! Passons vite fait à la déconnexion. Pour ce faire, on crée la fonction logout dans notre contrôleur et on rajoute les lignes suivantes dans le "main" Firewall :
/**
* This controller allow us to logout
*
* @return void
*/
#[Route('/deconnexion', 'security.logout', methods: ['GET'])]
public function logout()
{
// Nothing to do here...
}
logout:
path: security.logout
# target: app_login # route de redirection
# invalidate_session: false # empêche la suppression de la session après la déconnexion
Étape 15 : Formulaire d'inscription :
D'abord, on va créer le formulaire "RegistrationType" basé sur l'entité "User" avec la commande :
php bin/console make:form RegistrationType User
Ça génèrera le code suivant dans la fonction "buildForm" qu'on va modifier :
$builder->add('fullName', TextType::class, [
'attr' => [
'class' => 'form-control',
'minlength' => '2',
'maxlength' => '50',
],
'label' => 'Nom / Prénom',
'label_attr' => [
'class' => 'form_label mt-4'
],
'constraints' => [
new Assert\NotBlank(),
new Assert\Length(['min' => 2, 'max' => 50])
]
])
->add('pseudo', TextType::class, [
'attr' => [
'class' => 'form-control',
'minlength' => '2',
'maxlength' => '50',
],
'required' => false,
'label' => 'Pseudo (Facultatif)',
'label_attr' => [
'class' => 'form_label mt-4'
],
'constraints' => [
new Assert\Length(['min' => 2, 'max' => 50])
]
])
->add('email', EmailType::class, [
'attr' => [
'class' => 'form-control',
'minlength' => '2',
'maxlength' => '180',
],
'label' => 'Adresse email',
'label_attr' => [
'class' => 'form-label mt-4'
],
'constraints' => [
new Assert\Email(),
new Assert\Length(['min' => 2, 'max' => 180])
]
])
->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'first_options' => [
'attr' => [
'class' => 'form-control'
],
'label' => 'Mot de passe',
'label_attr' => [
'class' => 'form-label mt-4'
]
],
'second_options' => [
'attr' => [
'class' => 'form-control'
],
'label' => 'Confirmation du mot de passe',
'label_attr' => [
'class' => 'form-label mt-4'
]
],
'invalid_message' => 'Les mots de passe ne correspondent pas !',
'constraints' => [
new Assert\Length([
'min' => 8,
'minMessage' => 'Le mot de passe doit contenir au moins {{ limit }} caractères !'
])
]
])
->add('submit', SubmitType::class, [
'attr' => [
'class' => 'btn btn-primary mt-4'
],
'label' => 'S\'inscrire'
]);
Dans notre "SecurityController", on rajoute la fonction "registration" :
/**
* This controller allow us to RegistrationType
*
* @param Request $request
* @param EntityManagerInterface $manager
* @return Response
*/
#[Route('/inscription', 'security.registration', methods: ['GET', 'POST'])]
public function registration(Request $request, EntityManagerInterface $manager): Response
{
$user = new User();
$user->setRoles(['ROLE_USER']);
$form = $this->createForm(RegistrationType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$user = $form->getData();
$manager->persist($user);
$manager->flush();
$this->addFlash('success', 'Votre compte a bien été créé !');
return $this->redirectToRoute('security.login');
}
return $this->render('pages/security/registration.html.twig', [
'form' => $form->createView()
]);
}
On va créer notre template avec le contenu suivant :
{% extends "base.html.twig" %}
{% block title %}Inscription{% endblock %}
{% block body %}
<div class="container">
<h1 class="mt-4">Formulaire d'inscription</h1>
{{ form(form) }}
</div>
{% endblock %}
Voilà le formulaire d'inscription est fonctionnel.
Étape 16 : Édition du profil et du mot de passe :
On va créer le formulaire "UserType" lié à l'entité "User" avec la commande :
php bin/console make:form UserType User
Comme précédemment, on va modifier la fonction "buildForm" comme ceci :
$builder->add('fullName', TextType::class, [
'attr' => [
'class' => 'form-control',
'minlength' => '2',
'maxlength' => '50',
],
'label' => 'Nom / Prénom',
'label_attr' => [
'class' => 'form_label mt-4'
],
'constraints' => [
new Assert\NotBlank(),
new Assert\Length(['min' => 2, 'max' => 50])
]
])
->add('pseudo', TextType::class, [
'attr' => [
'class' => 'form-control',
'minlength' => '2',
'maxlength' => '50',
],
'required' => false,
'label' => 'Pseudo (Facultatif)',
'label_attr' => [
'class' => 'form_label mt-4'
],
'constraints' => [
new Assert\Length(['min' => 2, 'max' => 50])
]
])
->add('plainPassword', PasswordType::class, [
'attr' => [
'class' => 'form-control'
],
'label' => 'Mot de passe',
'label_attr' => [
'class' => 'form-label mt-4'
]
])
->add('submit', SubmitType::class, [
'attr' => [
'class' => 'btn btn-primary mt-4'
],
'label' => 'Modifier le profil'
]);
Ensuite, on va créer un contrôleur "UserController", on met le dossier de template "user" dans le dossier "pages", on modifie le fichier "index.html.twig" en "edit.html.twig" et on modifie la fonction "index" du contrôleur en "edit" :
/**
* This controller allow us to edit user's profile
*
* @param User $user
* @param Request $request
* @param EntityManagerInterface $manager
* @param UserPasswordHasherInterface $hasher
* @return Response
*/
#[Route('/utilisateur/edition/{id}', name: 'user.edit', methods: ['GET', 'POST'])]
public function edit(User $user, Request $request, EntityManagerInterface $manager, UserPasswordHasherInterface $hasher): Response
{
if (!$this->getUser()) {
return $this->redirectToRoute('security.login');
}
if ($this->getUser() !== $user) {
return $this->redirectToRoute('recipe.index');
}
$form = $this->createForm(UserType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if ($hasher->isPasswordValid($user, $form->getData()->getPlainPassword()))
{
$user = $form->getData();
$manager->persist($user);
$manager->flush();
$this->addFlash('success', 'Les informations de votre compte ont bien été modifiées !');
return $this->redirectToRoute('recipe.index');
} else {
$this->addFlash('warning', 'Le mot de passe renseigné est incorrect !');
}
}
return $this->render('pages/user/edit.html.twig', [
'form' => $form->createView(),
]);
}
Modifions le template ainsi :
{% extends 'base.html.twig' %}
{% block title %}Modification de l'utilisateur{% endblock %}
{% block body %}
<div class="container">
<h1 class="mt-4">Formulaire de modification des informations de l'utilisateur</h1>
{% for message in app.flashes('warning') %}
<div class="alert alert-warning mt-4">
{{ message }}
</div>
{% endfor %}
{{ form(form) }}
</div>
{% endblock %}
On crée un nouveau formulaire "UserPasswordType" associer à l'entité "User" pour modifier le mot de passe :
$builder->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'first_options' => [
'attr' => [
'class' => 'form-control'
],
'label' => 'Mot de passe',
'label_attr' => [
'class' => 'form-label mt-4'
]
],
'second_options' => [
'attr' => [
'class' => 'form-control'
],
'label' => 'Confirmation du mot de passe',
'label_attr' => [
'class' => 'form-label mt-4'
]
],
'invalid_message' => 'Les mots de passe ne correspondent pas !',
'constraints' => [
new Assert\Length([
'min' => 8,
'minMessage' => 'Le mot de passe doit contenir au moins {{ limit }} caractères !'
])
]
])
->add('newPassword', PasswordType::class, [
'attr' => ['class' => 'form-control'],
'label' => 'Nouveau mot de passe',
'label_attr' => ['class' >= 'form-label mt-4'],
'constraints' => [new Assert\NotBlank()]
])
->add('submit', SubmitType::class, [
'attr' => [
'class' => 'btn btn-primary mt-4'
],
'label' => 'Changer mon mot de passe'
]);
On rajoute la fonction "editPassword" dans le contrôleur :
/**
* This controller allow us to edit user's password
*
* @param User $user
* @param Request $request
* @param EntityManagerInterface $manager
* @param UserPasswordHasherInterface $hasher
* @return Response
*/
#[Route('/utilisateur/edit-mot-de-passe/{id}', 'user.edit.password', methods: ['GET, 'POST'])]
public function editPassword(User $user, Request $request, EntityManagerInterface $manager, UserPasswordHasherInterface $hasher): Response
{
$form = $this->createForm(UserPasswordType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if ($hasher->isPasswordValid($user, $form->getData()['plainPassword'])) {
$user->setUpdatedAt(new \DateTimeImmutable());
$user->setPlainPassword($form->getData()['newPassword']);
$manager->persist($user);
$manager->flush();
$this->addFlash('success', 'Le mot de passe a été modifié !');
return $this->redirectToRoute('recipe.index');
} else {
$this->addFlash('warning', 'Le mot de passe renseigné est incorrect !');
}
}
return $this->render('pages/user/edit_password.html.twig', [
'form' => $form->createView(),
]);
}
Modifions également le template :
{% extends 'base.html.twig' %}
{% block title %}Modification du mot de passe de l'utilisateur{% endblock %}
{% block body %}
<div class="container">
<h1 class="mt-4">Formulaire de modification du mot de passe de l'utilisateur</h1>
{% for message in app.flashes('warning') %}
<div class="alert alert-warning mt-4">
{{ message }}
</div>
{% endfor %}
{{ form(form) }}
</div>
{% endblock %}
Étape 17 : Assigner les entités à un utilisateur (ingrédient, recette) :
Dans cette partie, on va mettre en place la relation entre l'entité "User" et l'entité "Ingredient". On doit également modifier les fixtures pour lier les ingrédients à un utilisateur, ainsi que lier un ingrédient à l'utilisateur connecté lors de la création et afficher uniquement les ingrédients reliés à l'utilisateur.
Pour commencer, on rajoute la propriété "ingredients" dans l'entité "User" de type "relation" lié à l'entité "Ingredient" et de type de relation "OneToMany".
On va dire que la propriété "user" non nullable sera rajouté dans l'entité "Ingredient" et on va ajouter le fait de supprimer l'ingrédient orphelin.
Dans les fixtures, on déplace le code des users au-dessus de celui des ingrédients que l'on va modifier ainsi :
// Ingredients
$ingredients = [];
for ($i = 0; $i < 50; $i++) {
$ingredient = new Ingredient();
$ingredient->setName($this->faker->word())
->setPrice(mt_rand(0, 100))
->setUser($users[array_range($users)]);
}
On crée une nouvelle migration, on la migre et on load à nouveau les fixtures. Enfin, on modifie la fonction "index" de notre "IngredientController" pour qu'il affiche seulement les ingrédients lié à l'utilisateur connecté :
/**
* This controller display all ingredients
*
* @param IngredientRepository $repository
* @param PaginatorInterface $paginator
* @param Request $request
* @return Response
*/
#[Route('/ingredient', name: 'ingredient.index', methods: ['GET'])]
public function index(IngredientRepository $repository, PaginatorInterface $paginator, Request $request) : Response
{
$ingredients = $paginator->paginate(
$repository->findBy(['user' => $this->getUser()]),
$request->query->getInt('page', 1),
10
);
// dd($ingredients);
return $this->render('pages/ingredient/index.html.twig', [
'ingredients' => $ingredients
]);
}
Pour lier l'utilisateur à un ingrédient lors de sa création, on va modifier la fonction "new" du "IngredientController" pour ajouter la ligne suivante :
$ingredient->setUser($this->getUser());
Ce sera pareil pour l'entité "Recipe" afin de la lier à celle de "User". On doit ajouter un constructeur et on doit modifier le "QueryBuilder" du formulaire pour lors de la création d'une recette, on affiche seulement tous les ingrédients de l'utilisateur connecté :
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
private $token;
public function __construct(TokenStorageInterface $token)
{
$this->token = $token;
}
'query_builder' => function(IngredientRepository $r) {
return $r->createQueryBuilder('i')
->where('i.user = :user')
->orderBy('i.name', 'ASC')
->setParameter('user', $this->token->getToken()->getUser());
}
On va modifier la navbar qui sera différente si l'utilisateur est connecté ou pas avec {% if app.user %}.
Les liens vers la connexion et l'inscription si l'utilisateur n'est pas connecté sinon les liens vers mes ingrédients et mes recettes comme par exemple :
<div class="d-flex">
<ul class="navbar-nav me-auto">
{% if app.user %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-haspopup="true" aria-expanded="false">{{ app.user.fullName }}</a>
<div class="dropdown-menu dropdown-menu-end">
<a class="dropdown-item" href="{{ path('user.edit', {id: app.user.id}) }}">Modifier mes informations</a>
<a class="dropdown-item" href="{{ path('user.edit.password', {id: app.user.id}) }}">Modifier mon mot de passe</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="{{ path('security.logout') }}">Déconnexion</a>
</div>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{{ path('security.login') }}">Connexion</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ path('security.registration') }}">Inscription</a>
</li>
{% endif %}
</ul>
</div>
Étape 18 : Sécuriser les routes avec IS_GRANTED et SECURITY :
Dans cette partie, on va gérer la gestion de l'accès des pages, c'est-à-dire qu'on va interdire l'accès à certaines pahes en fonction de différents critères.
Les annotations @Security et @IsGranted restreint l'accès aux contrôleurs et donc par extension aux pages.
L'annotation @IsGranted est la plus simple pour restreindre par rôle ou encore par les variables passées au contrôleur.
L'annotation @Security est plus flexible et plus complète qui permet de passer des expressions contenant de la logique.
Pour la page de la liste des ingrédients, on va donner l'accès aux utilisateurs ayant le rôle "ROLE_USER" en ajoutant sur la méthode "index" du "IngredientController" la ligne suivante :
#[IsGranted('ROLE_USER')]
Pour la modification des ingrédients, on doit aussi vérifier qu'en plus du rôle "ROLE_USER" que l'utilisateur connecté soit bien celui lié à cet ingrédient avec la ligne suivante :
#[Security("is_granted('ROLE_USER') and user === ingredient.getUser()")]
Ce sera pareil pour les recettes. Pour la modification du profil de l'utilisateur ou encore pour la modification du mot de passe, on va supprimer les if de vérification pour les remplacer par la ligne suivante au-dessus de la fonction "edit" de l'"UserController" :
#[Security("is_granted('ROLE_USER') and user === choosenUser")]
public function edit(User $choosenUser, Request $request, EntityManagerInterface $manager, UserPasswordHasherInterface $hasher): Response
{
...
}
Remarque : On a modifié le "$user" par "$choosenUser" car il existe déjà la variable "user" dans l'annotation "Security".
Étape 19 : Partager une recette :
Pour partager une recette, on la rendre publique ou non avec les spécificités suivantes :
- Un utilisateur connecté aura à disposition un champ, pour chaque recette, lui permettant de choisir si la recette en question est disponible pour l'ensemble de la communauté.
- Si la recette est rendue publique, alors les utilisateurs pourront la consulter.
- Sinon, elle ne pourra pas être consultée par quelqu'un d'autre que le créateur.
On va rajouter la propriété "isPublic" de type "boolean" non nullable dans l'entité "Recipe" avec la valeur par défaut à "false". On fait une nouvelle migration et on la migre. On va modifier la fixture pour ajouter la ligne suivante :
->setIsPublic(mt_rand(0, 1) == 1);
Dans le contrôleur "RecipeController", on va rajouter la fonction "show" :
/**
* This controller allow us to see a recipe if this one is public
*
* @param Recipe $recipe
* @return Response
*/
#[Security("is_granted('ROLE_USER') and (recipe.getIsPublic() === true" || user === recipe.getUser()))]
#[Route('/recette/{id}', 'recipe.show', methods: ['GET'])]
public function show(Recipe $recipe): Response
{
return $this->render('pages/recipe/show.html.twig', [
'recipe' => $recipe
]);
}
Créons donc ce template avec le contenu suivant :
{% extends 'base.html.twig' %}
{% block title %}
{{ recipe.name }}
{% endblock %}
{% block body %}
<div class="container">
<h1 class="mt-4">{{ recipe.name }}</h1>
<div>
<span class="badge bg-primary">Créée le {{ recipe.createdAt|date('d/m/Y') }}</span>
</div>
{% if recipe.time %}
<p>Temps (en minutes) : {{ recipe.time }}</p>
{% else %}
<p>Temps non renseigné</p>
{% endif %}
{% if recipe.nbPeople %}
<p>Pour {{ recipe.nbPeople }} personnes</p>
{% else %}
<p>Nombre de personnes non renseigné</p>
{% endif %}
{% if recipe.difficulty %}
<p>Difficulté : {{ recipe.difficulty }}/5</p>
{% else %}
<p>Difficulté non renseignée</p>
{% endif %}
{% if recipe.price %}
<p>Prix (en €) : {{ recipe.price }}</p>
{% else %}
<p>Prix non renseigné</p>
{% endif %}
<div class="mt-4">
{{ recipe.description|raw }}
</div>
<p class="mt-4">Ingrédients :</p>
{% for ingredient in recipe.ingredients %}
<span class="badge bg-primary">{{ ingredient.name }}</span>
{% endfor %}
</div>
{% endblock %}
Pour afficher la page qui liste toutes les recettes qui ont été partagées par l'ensemble des personnes, on va créer dans le "RecipeController" une nouvelle fonction :
#[Route('/recette/pblique', 'recipe.public', methods: ['GET'])]
public function indexPublic(RecipeRepository $repository, PaginatorInterface $paginator, Request $request): Response
{
$recipes = $paginator->paginate(
$repository->findPublicRecipe(null),
$request->query->getInt('page', 1),
10
);
return $this->render('pages/recipe.index_public.html.twig', [
'recipes' => $recipes
]);
}
On peut copier le même template que la liste des recettes mais sans les boutons de modification et de suppression. Ensuite, on va créer une fonction dans le "RecipeRepository" qui va permettre de récupérer un nombre x de recettes publiques :
/**
* This method allow us to find public recipes based on number of recipes
*
* @param integer $nbRecipes
* @return array
*/
public function findPublicRecipe(?int $nbRecipes): array
{
$queryBuilder = $this->createQueryBuilder('r')
->where('r.isPublic = 1')
->orderBy('r.createdAt', 'DESC');
if ($nbRecipes !== 0 || !is_null($nbRecipes)) {
$queryBuilder->setMaxResults($nbRecipes);
}
return $queryBuilder->getQuery()
->getResult();
}
Sur la page d'accueil, on pourrait afficher les 3 dernières recettes de la communauté en précisant la valeur de "$nbRecipes" à 3.
Étape 20 : Noter une recette :
Cette fonctionnalité va permettre aux différents utilisateurs de noter les recettes mises en mode "public" sur l'application. Une fois la recette mise en mode "public", elle est éligible aux votes des utilisateurs.
Un utilisateur connecté pourra donner une note entre 1 et 5. L'ensemble des notes deront une moyenne, et les utilisateurs pourront voir cette moyenne sur la page de la recette.
En logique et c'est très compréhensible, un utilisateur ne pourra pas noter sa propre recette et un utilisateur ne pourra pas non plus noter deux fois la même recette.
Pour commencer à coder cette fonctionnalité, on va d'abord créer une entité "Mark" ("note" en anglais) avec la commande suivante :
php bin/console make:entity Mark
Avec les propriétés "mark" de type "integer" non nullable, "user" de type "ManyToOne" non nullable liée à l'entité "User", "recipe" de type "ManyToOne" non nullable liée à l'entité "Recipe" et "createdAt" de type "datetime_immutable" non nullable.
On va modifier l'entité "Mark" comme ceci :
#[ORM\Entity(repositoryClass: MarkRepository::class)]
#[UniqueEntity(
fields: ['user', 'recipe'],
errorpath: 'user',
message: 'Cet utilisateur a déjà noté cette recette !'
)]
class Mark
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'integer')]
#[Assert\Positive()]
#[Assert\LessThan(6)]
private int $mark;
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'marks')]
#[ORM\JoinColumn(nullable: false)]
private User $user;
#[ORM\ManyToOne(targetEntity: Recipe::class, inversedBy: 'marks')]
#[ORM\JoinColumn(nullable: false)]
private Recipe $recipe;
#ORM\Column(type: 'datetime_immutable')]
private ?\DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
}
On oublie pas de créer la migration et la migrer. Dans nos fixtures, on va rajouter les marks :
// Marks
foreach ($recipes as $recipe) {
for ($i = 0; $i < mt_rand(0, 4); $i++) {
$mark = new Mark();
$mark->setMark(mt_rand(1, 5))
->setUser($users[array_range($users)])
->setRecipe($recipe);
$manager->persist($mark);
}
}
Pour calculer la moyenne des notes de la recette, on rajouter un private ?float $average = null; dans l'entité "Recipe" et y rajouter la fonction "getAverage" :
/**
* Get the value of average
*/
public function getAverage()
{
$marks = $this->marks;
if ($marks->toArray() === []) {
$this->average = null;
} else {
$total = 0;
foreach ($marks as $mark) {
$total += $mark->getMark();
}
$this->average = $total / count($marks);
}
return $this->average;
}
On va créer un petit formulaire "MarkType" basé sur l'entité "Mark" pour pouvoir noter les recettes avec la commande suivante :
php bin/console make:form MarkType Mark
On va ensuite modifier la fonction "buildForm" de ce nouveau fichier comme ceci :
$builder
->add('mark', ChoiceType::class, [
'choices' => [
'1' => 1,
'2' => 2,
'3' => 3,
'4' => 4,
'5' => 5,
],
'attr' => [
'class' => 'form-select'
],
'label' => 'Noter la recette',
'label_attr' => [
'class' => 'form-label mt-4'
]
])
->add('submit', SubmitType::class, [
'attr' => [
'class' => 'btn btn-primary mt-4'
],
'label' => 'Noter la recette'
]);
On va modfifier dans le "RecipeController" la fonction "show" :
/**
* This controller allow us to see a recipe if this one is public
*
* @param Recipe $recipe
* @param Request $request
* @param MarkRepository $markRepository
* @param EntityManagerInterface $manager
* @return Response
*/
#[Security("is_granted('ROLE_USER') and (recipe.getIsPublic() === true" || user === recipe.getUser()))]
#[Route('/recette/{id}', 'recipe.show', methods: ['GET', 'POST'])]
public function show(Recipe $recipe, Request $request, MarkRepository $markRepository, EntityManagerInterface $manager): Response
{
$mark = new Mark();
$form = $this->createForm(MarkType::class, $mark);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$mark->setUser($this->getUser())
->setRecipe($recipe);
$existingMark = $markRepository->findOneBy([
'user' => $this->getUser(),
'recipe' => $recipe
]);
if (!$existingMark) {
$manager->persist($mark);
} else {
$existingMark->setMark($form->getData()->getMark());
}
$manager->flush();
$this->addFlash('success', 'Votre note a bien été prise en compte !');
return $this->redirectToRoute('recipe.show', ['id' => $recipe->getId()]);
}
return $this->render('pages/recipe/show.html.twig', [
'recipe' => $recipe,
'form' => $form->createView()
]);
}
On va modifier le template lié à cette fonction pour ajouter les lignes suivantes au bon endroit :
{% for message in app.flashes('success') %}
<div class="alert alert-success mt-4">
{{ message }}
</div>
{% endfor %}
<p>La moyenne de cette recette est de {{ recipe.average|number_format(2, '.', ',') }}/5 !</p>
<div class="mark">
{{ form(form) }}
</div>
Étape 21 : Upload une image :
Symfony permet la gestion de fichier avec une classe qui permet de représenter les fichiers envoyés. Cette classe contiendra une méthode move() qui permettra de le déplacer dans le système de fichier du serveur.
Via le formulaire :
Du côté du formulaire, on peut définir un champs qui accueillera le fichier.
->add('thumbnailFile', FileType::class, [
'required' => false,
'constraints' => [new Image()],
'mapped' => false,
])
mapped permet d'indiquer que le champs n'est pas relié à une donnée de l'entité (il n'essaiera pas de récupérer la valeur ni de trouver le setter qui correspondra).
Ensuite, du côté du contrôleur, on peut récupérer le fichier envoyé en utilisant la donnée contenue dans le formulaire.
/** @var UploadedFile $file */
$file = $form->get('thumbnailFile')->getData(); // UploadedFile
if ($file) {
$file->move(sprintf(
"%s/public/image/monfichier.%s",
$this->getParameter('kernel.project_dir'),
$file->getClientOriginalExtension()
));
}
Si le dossier de destination n'existe pas, il sera automatiquement créé par Symfony.
Pour afficher tous les services contenus dans les containers avec leurs valeurs associées, on peut utiliser la commande suivante :
php bin/console debug:container --parameters
À vous ensuite d'ajouter la logique pour persister le nom du fichier en base de données et gérer la suppression lorsque l'entité est supprimée. Pour interagir avec le système de fichiers, il est possible d'utiliser le composant FileSystem.
Exemple :
Pour gérer plus facilement le système d'envoi de fichier, il est possible d'utiliser le bundle "VichUploaderBundle" qui permet d'attacher automatiquement un système d'upload de fichier aux événements de notre entité.
Pour uploader une image pour chaque recette, on va d'abord utiliser ce fameux bundle et on va l'installer avec la commande suivante :
composer require vich/uploader-bundle
Cette commande va générer le fichier `config/packages/vich_uploader.yaml` qui est le fichier de configuration de ce bundle qu'on va modifier :
vich_uploader:
db_driver: orm
metadata:
type: attribute
mappings:
recipe_images:
uri_prefix: /images/recette
upload_destination: "%kernel.project_dir%/public/images/recette"
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
On va rajouter des informations pour l'upload des images dans l'entité "Recipe" :
// ...
use Symfony\Component\HttpFoundation\File\File;
use Vich\UploaderBundle\Mapping\Annotation as Vich;
// ...
#[Vich\Uploadable]
class Recipe
{
// ...
/**
* NOTE : This is not a mapped field of entity metadata, just a simple property.
*/
#[Vich\UploadableField(mapping: 'recipe_images', fileNameProperty: 'imageName')]
// #[Assert\Image()]
private ?File $imageFile = null;
#[ORM\Column(type: 'string', nullable: true)]
private ?string $imageName = null;
// ...
/**
* If manually uploading a file (i.e. not using Symfony Form) ensure an instance
* of 'UploadedFile' is injected into this setter to trigger the update. If this
* bundle's configuration parameter 'inject_on_load' is set to 'true' this setter
* must be able to accept an instnace of 'File' as the bundle will inject one here
* during Doctrine hydration.
*
* @param File|\Symfony\Component\HttpFoundation\File\UpdloadedFile|null $imageFile
*/
public function setImageFile(?File $imageFile = null): static
{
$this->imageFile = $imageFile;
if (null !== $imageFile) {
// It is required that at least one field changes if you are using doctrine
// otherwise the event listeners won't be called and the file is lost
$this->updatedAt = new \DateTimeImmutable();
}
return $this;
}
public function getImageFile(): ?File
{
return $this->imageFile;
}
public function setImageName(?string $imageName): static
{
$this->imageName = $imageName;
return $this;
}
// ...
}
Dans le formulaire de création d'une recette, on va rajouter un champ "imageFile" comme ceci :
->add('imageFile', VichImageType::class, [
'label' => 'Image de la recette',
'label_attr' => [
'class' => 'form-label mt-4'
]
])
Et dans le template du formulaire de création/modification d'une recette :
{{ form_row(form.imageFile) }}
On oublie pas de créer la migration et de la migrer. Pour afficher l'image dans le détail de la recette (show), on fait :
<div class="recipe_image">
<img style="max-width: 500px;" src="{{ vich_uploader_asset(recipe, 'imageFile') }}" alt="">
</div>
Si on veut récupérer le chemin du fichier depuis un contrôleur, on a un helper que l'on peut injecter :
// ...
public function edit(// ..., UploaderHelper $helper)
{
dd($helper->asset($recipe, 'imageFile'));
// ...
}
Étape 22 : Formulaire de contact, email et reCaptcha v3 :
Maintenant que l'on a vu les concepts de base, je vous propose d'attaquer un premier cas pratique : un formulaire de contact.
Ce formulaire contiendra :
-
Un nom
-
Un prénom
-
Une adresse email
-
Un sujet
-
Un message
Le nom et le prénom seront optionnels. Ils devront faire entre 2 et 50 caractères. L'adresse email sera obligatoire, comme le message. Le sujet sera lui optionnel et contiendra macimum 50 caractères.
Si l'utilisateur est connecté, alors son nom, prénom et adresse email seront automatiquement remplis.
Le formulaire contiendra également un système reCaptcha.
D'abord, on va créer une entité "Contact" avec les propriétés "fullName" de type "string" de taille "50" nullable, "email" de type "string" de taille "180" non nullable, "subject" de type "string" de taille "100" nullable, "message" de type "text" non nullable et "createdAt" de type "datetime_immutable" non nullable avec la commande :
php bin/console make:entity Contact
On va modifier cette entité pour rajouter les "Assert" :
#[ORM\Entity(repositoryClass: ContactRepository::class)]
class Contact
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id;
#[ORM\Column(type: 'string', length: 50, nullable: true)]
#[Assert\Length(min: 2, max: 50)]
private ?string $fullName = null;
#[ORM\Column(type: 'string', length: 180)]
#[Assert\Email()]
#[Assert\Length(min: 2, max: 180)]
private string $email;
#[ORM\Column(type: 'string', length: 100, nullable: true)]
#[Assert\Length(min: 2, max: 100)]
private ?string $subject = null;
#[ORM\Column(type: 'text')]
#[Assert\NotBlank()]
private string $message;
#[ORM\Column(type: 'datetime_immutable')]
#[Assert\NotNull()]
private ?\DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
...
}
On crée donc la migration et on la migre. On va rajouter des fixtures pour les contacts :
// Contact
for ($i = 0; $i < 5; $i++) {
$contact = new Contact();
$contact->setFullName($this->faker->name())
->setEmail($this->faker->->email())
->setSubject('Demande n°' . ($i + 1))
->setMessage($this->faker->text());
$manager->persist($contact);
}
Si on ne veut pas sauvegarder en base de données, on ne crée pas une entité mais bien un objet "ContactFormDTO" pour représenter les données de ce formulaire ("DTO" est l'abréviation de "Data Transfer Object") :
<?php
namespace App\DTO;
use Symfony\Component\Validator\Constraints as Assert;
class ContactDTO
{
#[Assert\NotBlank]
#[Assert\Length(min: 3, max: 200)]
public string $name = '';
#[Assert\NotBlank]
#[Assert\Email]
public string $email = '';
#[Assert\NotBlank]
#[Assert\Length(min: 3, max: 200)]
public string $message = '';
}
?>
<?php
namespace App\Form;
namespace App\Form;
use App\DTO\ContactDTO;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
class ContactType AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'empty_data' => '',
])
->add('email', EmailType::class, [
'empty_data' => '',
])
->add('message', TextareaType::class, [
'empty_data' => '',
])
->add('save', SubmitType::class, [
'label' => 'Envoyer',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => ContactDTO::class,
]);
}
}
?>
On va créer un contrôleur "ContactController" avec la commande :
php bin/console make:controller ContactController
On va créer un formulaire de contact nommé "ContactType" avec la commande :
php bin/console make:form ContactType Contact
Et on va modifier sa fonction "buildForm" :
$builder
->add('fullName', TextType::class, [
'attr' => [
'class' => 'form-control'
'minlength' => '2',
'maxlength' => '50',
],
'label' => 'Nom / Prénom',
'label_attr' => [
'class' => 'form-label mt-4'
]
])
->add('email', EmailType::class, [
'attr' => [
'class' => 'form-control',
'minlength' => '2',
'maxlength' => '180',
],
'label' => 'Adresse email',
'label_attr' => [
'class' => 'form-label mt-4'
],
'constraints' => [
new Assert\NotBlank(),
new Assert\Email(),
new Assert\Length(['min' => 2, 'max' => 180])
]
])
->add('subject', TextType::class, [
'attr' => [
'class' => 'form-control',
'minlength' => '2',
'maxlength' => '100',
],
'label' => 'Sujet',
'label_attr' => [
'class' => 'form-label mt-4'
],
'constraints' => [
new Assert\Length(['min' => 2, 'max' => 100])
]
])
->add('message', TextareaType::class, [
'attr' => [
'class' => 'form-control',
],
'label' => 'Message',
'label_attr' => [
'class' => 'form-label mt-4'
],
'constraints' => [
new Assert\NotBlank()
]
])
->add('submit', SubmitType::class, [
'attr' => [
'class' => 'btn btn-primary mt-4'
],
'label' => 'Soumettre ma demande'
]);
On va modifier la fonction "index" de notre contrôleur pour ajouter le formulaire :
#[Route('/contact', name: 'contact.index')]
public function index(Request $request, EntityManagerInterface $manager): Response
{
$contact = new Contact();
if ($this->getUser()) {
$contact->setFullName($this->getUser()->getFullName())
->setEmail($this->getUser()->getEmail());
}
$form = $this->createForm(ContactType::class, $contact);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$contact = $form->getData();
$manager->persist($contact);
$manager->flush();
$this->addFlash('success', 'Votre demande a été envoyée avec succès !');
return $this->redirectToRoute('contact.index');
}
return $this->render('pages/contact/index.html.twig', [
'form' => $form->createView()
]);
}
Pour envoyer un email à chaque soumission du formulaire de contact, on va utiliser le composant "Mailer" avec le fichier de configuration `config/packages/mailer.yaml`.
Pour tester l'envoi des emails en local, vous pouvez utiliser un de ces outils :
-
Mailpit, disponible sous forme de simple exécutable.
-
Maildev, disponible sous forme d'image Docker.
-
Mailtrap qui est un service tiers avec une formule gratuite limitée à 100 emails / mois.
Dans le dossier `bin/` de notre projet, on va mettre l'exécutable de Mailpit par exemple au même niveau que console et phpunit. On peut le lancer avec la commande :
./bin/mailpit
Cette commande va automatiquement démarrer un serveur sur le port 8025, c'est-à-dire accessible via l'url http://localhost:1025, avec la variable d'environnement MAILER_DSN à la valeur de l'url du serveur.
Dans notre fichier ".env", on doit définir le système de transport d'email via la variable "MAILER_DSN" avec la syntaxe suivante :
MAILER_DSN=smtp://user:pass@smtp.example.com:port
Et on va modifier la configuration messenger.yaml pour que l'envoi des emails se fasse de manière synchrone.
framework:
messenger:
failure_transport: failed
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
use_notify: true
check_delayed_interval: 60000
retry_strategy:
max_retries: 3
multiplier: 2
failed: 'doctrine://default?queue_name=failed'
sync: 'sync://'
routing:
Symfony\Component\Mailer\Messenger\SendEmailMessage: sync
Symfony\Component\Notifier\Message\ChatMessage: sync
Symfony\Component\Notifier\Message\SmsMessage: sync
Documentation sur le composant Mailer
Ensuite, on va modifier la fonction "index" de notre contrôleur :
#[Route('/contact', name: 'contact.index')]
public function index(Request $request, EntityManagerInterface $manager, MailerInterface $mailer): Response
{
$contact = new Contact();
if ($this->getUser()) {
$contact->setFullName($this->getUser()->getFullName())
->setEmail($this->getUser()->getEmail());
}
$form = $this->createForm(ContactType::class, $contact);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$contact = $form->getData();
$manager->persist($contact);
$manager->flush();
// Email
$email = (new TemplatedEmail())
->from($contact->getEmail())
->to('admin@symrecipe.com')
->subject($contact->getSubject())
->htmlTemplate('emails/contact.html.twig')
// pass variables (name => value)to the template
->context([
'contact' => $contact
]);
$mailer->send($email);
$this->addFlash('success', 'Votre demande a été envoyée avec succès !');
return $this->redirectToRoute('contact.index');
}
return $this->render('pages/contact/index.html.twig', [
'form' => $form->createView()
]);
}
On va créer le dossier "emails" et dedans le fichier "contact.html.twig" avec le contenu suivant :
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
<h1>Demande de {{ contact.fullName }}</h1>
<p>Email : {{ contact.email }}</p>
<p>Sujet : {{ contact.subject }}</p>
<p>Message :</p>
<div>
{{ contact.message|raw }}
</div>
</body>
</html>
On va mettre en place le reCaptcha qui, d'après Google, est la façon ultime de lutter contre les robots. On va utiliser le bundle "KarserRecaptcha3Bundle" qu'on va installer avec la commande :
composer require karser/karser-recaptcha3-bundle
On va dire "non" au "google/recaptcha" et "oui" au "karser/karser-recaptcha3-bundle". Dans son fichier de configuration `config/packages/karser_recaptcha3.yaml`, on peut voir qu'il faut rajouter les variables "RECAPTCHA3_KEY" et "RECAPTCHA3_SECRET" dans notre fichier ".env".
Dans notre formulaire "ContactType", on va rajouter le captcha comme ceci :
->add('captcha', Recaptcha3Type::class, [
'constraints' => new Recaptcha3(),
'action_name' => 'contact',
])
Si on veut contacter un service en particulier, on peut rajouter une propriété service dans notre DTO :
#[Assert\NotBlank]
public string $service = '';
On va ensuite ajouter dans le formulaire un select pour sélectionner le bon service, donc dans notre ContactType on va rajouter un ChoiceType dans le builder du formulaire :
->add('service', ChoiceType::class, [
'choices' => [
'Compta' => 'compta@demo.com',
'Support' => 'support@demo.com',
'marketing' => 'marketing@demo.com',
],
])
On peut également récupérer les erreurs lors de l'envoi d'un email :
try {
$email = (new TemplatedEmail())
->to($contact->service)
->from($contact->email)
->subject('Demande de contact')
->htmlTemplate('emails/contact.html.twig')
->context(['contact' => $contact]);
$mailer->sender($email);
$this->addFlash('success', 'Votre email a bien été envoyé !');
return $this->redirectToRoute('contact.index');
} catch (\Exception $e) {
$this->addFlash('danger', 'Impossible d\'envoyer votre email !');
}
Étape 23 : Les services :
Le système de services et d'injection de dépendances de Symfony est un outil puissant qui permet de découpler les composants de son application et de les rendre plus testables et maintenables.
Les services sont simplement des objets PHP qui vont remplir une fonction particulière. Précédemment, on a déjà utilisé des services sans le savoir comme le service "EntityManager" ou encore le service "Mailer".
Tous ces services sont à l'intérieur d'une classe PHP qui est assez spéciale et qui s'appelle le container de services et va centraliser tous les services au sein de notre application Symfony.
En gros, un service est un objet qui fournit une fonctionnalité spécifique à l'application. Ce n'est qu'une classe qui peut être plus ou moins complexe en fonction de la situation.
Au coeur de Symfony se cache un conteneur d'inversion de contrôle (IoC) qui permet de gérer les services de votre application. C'est un objet qui permet de construire des objets en fonction des besoins en indexant les objets avec une clef particulière.
Si on regarde le code source de la fonction render() que l'on utilise depuis le début de ce cours, on retrouve le code suivant :
$twig = $this->container->get('twig');
Symfony demande au conteneur une instance de l'objet Twig qui gère le rendu HTML.
L'injection de dépendances :
L'injection de dépendances est un principe de conception qui consiste à fournir aux objets les dépendances dont ils ont besoin pour fonctionner. Cela se fait en injectant les dépendances dans le constructeur de l'objet (ou plus rarement via un setter).
class MailNotification
{
public function __construct(private readonly MailerInterface $mailer)
{
}
// ...
}
L'avantage est que le conteneur va être capable de résoudre les dépendances d'un objet automatiquement et injecter les services nécessaires automatiquement.
Pour visualiser tous les services disponibles par défaut et câblés automatiquement, on utilise la commande :
php bin/console debug:autowiring --all
Si on cherche un service en particulier, on peut utiliser :
php bin/console debug:autowiring mailer.mailer
Les contrôleurs sont des classes spéciales qui bénéficient aussi de ce système d'injection dans leurs actions. C'est ce qui permet par exemple de récupérer le manageur d'entité ou les repositories.
<?php
namespace App\Controller\Admin;
use App\Form\RecipeType;
use App\Repository\RecipeRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/admin/recettes', name: 'admin.recipe.')]
class RecipeController extends AbstractController
{
#[Route('/', name: 'index')]
public function index(
RecipeRepository $repository,
Request $request,
): Response
{
$page = $request->query->getInt('page', 1);
$recipes = $repository->paginateRecipes($page);
return $this->render('admin/recipe/index.html.twig', [
'recipes' => $recipes,
]);
}
}
?>
Documentation sur le Service Container
Ce qui est également très intéressant, c'est que nous pouvons également nos propres services car ça permettra d'utiliser une fonctionnalité précise à plusieurs endroits de notre code.
Par exemple, chaque fichier a son rôle à jouer et il faut un service email pour être cohérent avec ce système car actuellement l'envoi est géré au sein du contrôleur et on doit mettre le moins de logique dans le contrôleur.
On va créer le dossier `src/Service` et dedans on y insère le fichier "MailService.php" avec le contenu suivant :
<?php
namespace App\Service;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
class MailService
{
/**
* @var MailerInterface
*/
private MailerInterface $mailer;
public function __construct(MailerInterface $mailer)
{
$this->mailer = $mailer;
}
public function sendEmail(string $from, string $subject, string $htmlTemplate, array $context, string $to = 'admin@symrecipe.com'): void
{
$email = (new TemplatedEmail())
->from($from)
->to($to)
->subject($subject)
->htmlTemplate($htmlTemplate)
->context($context);
$this->mailer->send($email);
}
}
?>
On va de nouveau modifier la fonction "index" de notre contrôleur pour remplacer notre "MailerInterface" par notre "MailService" :
#[Route('/contact', name: 'contact.index')]
public function index(Request $request, EntityManagerInterface $manager, MailService $mailService): Response
{
$contact = new Contact();
if ($this->getUser()) {
$contact->setFullName($this->getUser()->getFullName())
->setEmail($this->getUser()->getEmail());
}
$form = $this->createForm(ContactType::class, $contact);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$contact = $form->getData();
$manager->persist($contact);
$manager->flush();
// Email
$mailService->sendEmail(
$contact->getEmail(),
$contact->getSubject(),
'emails/contact.html.twig',
['contact' => $contact]
);
$this->addFlash('success', 'Votre demande a été envoyée avec succès !');
return $this->redirectToRoute('contact.index');
}
return $this->render('pages/contact/index.html.twig', [
'form' => $form->createView()
]);
}
Étape 24 : Administration avec EasyAdmin :
Pour utiliser des interfaces d'Administration simple, on va utiliser le bundle "EasyAdmin" et on va l'installer avec la commande :
composer require easycorp/easyadmin-bundle
Pour bien commencer, la première étape est les dashboards qui est le point d'entré de la partie administrateur. Pour créer notre dashboard, on utilise la commande suivante :
php bin/console make:admin:dashboard
On le nomme "DashboardController" et on le stocke dans le dossier `src/Controller/Admin`. On peut voir que cette classe étend de AbstractDashboardController qui lui-même étend de AbstractController. On y modifie sa fonction "index" :
#[Route('/admin', name: 'admin')]
public function index(): Response
{
return $this->render('admin/dashboard.html.twig');
}
On oublie pas de créer le dossier `templates/admin` et dedans le fichier "dashboard.html.twig" :
{% extends "@EasyAdmin/pahe/content.html.twig" %}
{% block content %}
<h1>Bienvenue au sein de l'administration de SymRecipe</h1>
{% endblock %}
On va configurer le dashboard en modifiant la fonction "configureDashboard" de notre contrôleur :
public function configureDashboard(): Dashboard
{
return Dashboard::new()
->setTitle('SymRecipe - Administration')
->renderContentMaximized();
}
On va configurer les éléments du menu du Dashboard dans la fonction "configureMenuItems" de notre contrôleur :
public function configureMenuItems(): iterable
{
yield MenuItem::linkToDashBoard('Dashboard', 'fa fa-home');
yield MenuItem::linkToCrud('Utilisateurs', 'fas fa-user', User::class);
}
Si on relance la page, on va voir qu'il y a une erreur car il n'y a pas de "UserCrudController".
Un "CRUD controller" est un contrôleur qui va nous fournir les opérations de base d'un CRUD classique pour les entités. Il étend de AbstractCrudController qui étend lui-même de AbstractController. Pour faire ce fameux "CRUD controller", on utilise la commande suivante en sélectionnant l'entité sur lequel on veut le faire :
php bin/console make:admin:crud
Il sera généré dans le dossier `src/Controller/Admin` avec le namespace "App\Controller\Admin".
On va rajouter la fonction "configureCrud" dans notre contrôleur :
public function configureCrud(Crud $crud): Crud
{
return $crud
->setEntityLabelInPlural('Utilisateurs')
->setEntityLabelInSingular('Utilisateur');
->setPageTitle('index', 'Symrecipe - Administration des utilisateurs')
->setPaginatorPageSize(10);
}
On va également configurer les champs à afficher dans le tableau dans la fonction "configureFields" de notre contrôleur :
public function configureFields(string $pageName): iterable
{
return [
IdField::new('id')->hideOnForm(),
TextField::new('fullName'),
TextField::new('pseudo'),
TextField::new('email')->hideOnForm(),
ArrayField::new('roles')->hideOnIndex(),
DteTimeField::new('createdAt')->hideOnForm()
];
}
Pour le champ textarea pour le message de contact, on doit mettre en place un WYSIWYG (abréviation de "What You See Is What You Get", c'est-à-dire litéralement "ce que vous voyez est ce que vous obtenez"). On va installer le bundle "CKEditor" avec la commande :
composer require friendsofsymfony/ckeditor-bundle
Ensuite, on va installer "drop" de CKEditor avec la commande :
php bin/console ckeditor:install
On va installer les assets de CKEditor avec la commande :
php bin/console assets:install public
Enfin, on va modifier notre fichier de configuration `config/packages/fos_ckeditor.yaml` :
fos_ck_editor:
configs:
main_config:
toolbar:
- {
name: "styles",
items:
[
"Bold",
"Italic",
"Underline",
"Strike",
"Blockquote",
"-",
"Link",
"-",
"RemoveFormat",
"-",
"NumberedList",
"BulletedList",
"-",
"Outdent",
"Indent",
"-",
"-",
"JustifyLeft",
"JustifyCenter",
"JustifyRight",
"JustifyBlock",
"-",
"Image",
"Table",
"-",
"Styles",
"Format",
"Font",
"FontSize",
"-",
"TextColor",
"BGColor",
"Source",
],
}
On va modifier notre ContactCrudController avec les fonctions suivantes :
public function configureCrud(Crud $crud): Crud
{
return $crud
->setEntityLabelInSingular('Demande de contact')
->setEntityLabelInPlural('Demandes de contact')
->setPageTitle('index', 'SymRecipe - Administration des demandes de contact')
->setPaginatorPageSize(20)
->addFormTheme('@FOSCKEditor/Form/ckeditor_widget.html.twig');
}
public function configureFields(string $pageName): iterable
{
return [
IdField::new('id')->hideOnForm(),
TextField::new('fullName'),
TextField::new('email'),
TextareaField::new('message')->setFormType(CKEditor::class)->hideOnIndex(),
DateTimeField::new('createdAt')->hideOnForm()
];
}
On va mettre en place la sécurité en ajoutant un admin dans les fixtures :
$admin = new User();
$admin->setFullName('Administrateur de SymRecipe')
->setPseudo(null)
->setEmail('admin@symrecipe.fr')
->setRoles(['ROLE_USER', 'ROLE_ADMIN'])
->setPlainPassword('password');
$user[] = $admin;
$manager->persist($admin);
On va rajouter le "IsGranted" sur les fonctions du DashboardController pour qu'il soit accessible aux utilisateurs ayant le rôle "ROLE_ADMIN" :
#[IsGranted('ROLE_ADMIN')]
Dans notre navbar, on peut ajouter le lien vers le dashboard :
{% if 'ROLE_ADMIN' in app.user.roles %}
<a class="dropdown-item" href="{{ path('admin') }}">Administration</a>
<div class="dropdown-divider"></div>
{% endif %}
Étape 25 : Tester notre application :
Dans cette partie, on va mettre en place des tests unitaires et des tests fonctionnels. En programmation, le test unitaire est un procédé permettant de s'assurer du fonctionnement correct d'une partie déterminée d'un logiciel ou d'une portion d'un programme. Les tests fonctionnels sont destinés à s'assurer que, dans le contexte d'utilisation réelle, le comportement fonctionnel obtenu est bien conforme avec celui attendu.
Pour ce faire, Symfony nous propose d'utiliser le framework PHPUnit. Pour exécuter les tests, on utilise la commande :
php bin/phpunit
Pour séparer les deux types de tests, je vous propose de créer deux dossiers différents dans le dossier "tests" : "Unit" et "Functional". On va ensuite créer notre premier test unitaire avec la commande :
php bin/console make:test TestCase \App\Tests\Unit\BasicTest
On va créer notre premier test fonctionnel avec la commande :
php bin/console make:test WebTestCase \App\Tests\Functional\BasicTest
On va copier les variables "DATABASE_URL" et "MAILER_DSN" dans le fichier ".env.test" et on va mettre la variable "RECAPTCHA3_ENABLED" à "0". On va créer la base de données de test avec la commande :
php bin/console d:d:c --env=test
On va migrer toutes les migrations dans cette nouvelle base de données avec la commande :
php bin/console d:m:m --env=test
On va loader les fixtures dans notre base de données avec la commande :
php bin/console d:f:l --env=test
Maintenant que cette base de données est créée, on va enfin faire les différents tests en commençant par les tests unitaires. Pour tester l'entité "Recipe", on utilise la commande suivante :
php bin/console make:test KernelTestCase \App\Tests\Unit\RecipeTest
On va modifier le contenu de cette classe comme ceci :
<?php
namespace App\Tests\Unit;
use App\Entity\Recipe;
use App\Entity\User;
use App\Entity\Mark;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class RecipeTest extends KernelTestCase
{
public function getEntity(): Recipe
{
return (new Recipe())->setName('Recipe #1')
->setDescription('Description #1')
->setIsFavorite(true)
->setCreatedAt(new \DateTimeImmutable())
->setupdatedAt(new \DateTimeImmutable());
}
public function testEntityIsValid(): void
{
self::bootKernel();
$container = static::getContainer();
$recipe = $this->getEntity();
$errors = $container->get('validator')->validate($recipe);
$this->assertCount(0, $errors);
}
}
public function testInvalidName()
{
self::bootKernel();
$container = static::getContainer();
$recipe = $this->getEntity();
$recipe->setName('');
$errors = $container->get('validator')->validate($recipe);
$this->assertCount(2, $errors);
}
public function testGetAverage()
{
$recipe = $this->getEntity();
$user = static::getContainer()->get('doctrine.orm.entity_manager')->find(User::class, 1);
for ($i = 0; $i < 5; $i++) {
$mark = new Mark();
$mark->setMark(2)
->setUser($user)
->setRecipe($recipe);
$recipe->addMark($mark);
}
$this->assertTrue(2.0 === $recipe->getAverage());
}
?>
Pour les tests fonctionnels, on va tester si on arrive sur telle ou telle page, si le formulaire est valide, etc. On va faire un nouveau test fonctionnel avec la commande suivante :
php bin/console make:test WebTestCase \App\Tests\Functional\HomePageTest
On va modifier le contenu de cette classe comme ceci :
<?php
namespace App\tests\Functional;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class HomePageTest extends WebTestCase
{
public function testSomething(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
$this->assertResponseIsSuccessful();
$button = $crawler->filter('.btn.btn-primary.btn-lgz');
$this->assertEquals(1, count($button));
$recipes = $crawler->filter('.recipes .card');
$this->assertEquals(3, count($recipes));
$this->assertSelectorTextContains('h1', 'Bienvenue sur SymRecipe');
}
}
?>
Pour le test fonctionnel pour le formulaire de contact dans notre "App\Tests\Functional\ContactTest.php", on va avoir le contenu suivant :
<?php
namespace App\Tests\Functional;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
class ContactTest extends WebTestCase
{
public function testIfSubmitContactFormIsSuccessful(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/contact');
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('h1', 'Formulaire de contact');
// Récupérer le formulaire
$submitButton = $crawler->selectButton('Soumettre ma demande');
$form = $submitButton->form();
$form["contact[fullName]"] = "Jean Dupont";
$form["contact[email]"] = "jd@symrecipe.com";
$form["contact[subject]"] = "Test";
$form["contact[message]"] = "Test";
// Soumettre le formulaire
$client->submit($form);
// Vérifier le statut HTTP
$this->assertResponseStatusCodeSame(Response::HTTP_FOUND);
// Vérifier l'envoi du mail
$this->assertEmailCount(1);
$client->followRedirect();
// Vérifier la présence du message de succès
$this->assertSelectorTextContains('div.alert.alert-success.mt-4', 'Votre demande a été envoyée avec succès !');
}
}
?>
Ensuite, on va créer un test fonctionnel pour tester la logique de connexion avec la commande :
php bin/console make:test WebTestCase \App\Tests\Functional\LoginTest
On va modifier le contenu de cette classe comme ceci :
<?php
namespace App\Tests\Functional;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
class LoginTest extends WebTestCase
{
public function testIfLoginIsSuccessful(): void
{
$client = static::createClient();
/** @var urlGeneratorInterface $urlGenerator */
$urlGenerator = $client->getContainer()->get('router');
$crawler = $client->request('GET', $urlGenerator->generate('security.login'));
$form = $crawler->filter("form[name=login]")->form([
"_username" => "admin@symrecipe.fr",
"_password" => 'password',
]);
$client->submit($form);
$this->assertResponseStatusCodeSame(Response::HTTP_FOUND);
$client->followRedirect();
$this->assertRouteSame('home.index');
}
}
public function testIfLoginFailedWhenPasswordIsWrong(): void
{
$client = static::createClient();
/** @var urlGeneratorInterface $urlGenerator */
$urlGenerator = $client->getContainer()->get('router');
$crawler = $client->request('GET', $urlGenerator->generate('security.login'));
$form = $crawler->filter("form[name=login]")->form([
"_username" => "admin@symrecipe.fr",
"_password" => 'password_',
]);
$client->submit($form);
$this->assertResponseStatusCodeSame(Response::HTTP_FOUND);
$client->followRedirect();
$this->assertRouteSame('security.login');
$this->assertSelectorTextContains("div.alert-danger", "Invalid credentials.");
}
?>
On va créer un "Makefile" avec toutes les commandes à faire, c'est-à-dire avec le contenu suivant :
.PHONY: tests
tests:
php bin/console d:d:d --force --if-exists --env=test
php bin/console d:d:c --env=test
php bin/console d:m:m --no-interaction --env=test
php bin/console d:f:l --no-interaction --env=test
php bin/phpunit --testdox tests/Unit/
php bin/phpunit --testdox tests/Functional/
On exécute le "Makefile" avec la commande :
make tests
On peut également faire des tests sur un CRUD simple, comme par exemple le CRUD d'un ingrédient avec la commande :
php bin/console make:test WebTestCase \App\Tests\Functional\IngredientTest
On peut modifier le contenu de notre classe comme ceci :
<?php
namespace App\Tests\Functional;
use App\Entity\User;
use App\Entity\Ingredient;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class IngredientTest extends WebTestCase
{
public function testIfCreateIngredientIsSuccessful(): void
{
$client = static::createClient();
// Recup urlGenerator
$urlGenerator = $client->getContainer()->get('router');
// Recup entity manager
$entityManager = $client->getContainer()->get('doctirne.orm.entity_manager');
$user = $entityManager->find(User::class, 1);
$client->loginUser($user);
// Se rendre sur la page de création d'un ingrédient
$crawler = $client->request(Request::METHOD_GET, $urlGenerator->generate('ingredient.new'));
// Gérer le formulaire
$form = $crawler->filter('form[name=ingredient]')->form([
'ingredient[name]' => "Un ingrédient",
'ingredient[price]' => floatval(33)
]);
$client->submit($form);
// Gérer la redirection
$this->assertResponseStatusCodeSame(Response::HTTP_FOUND);
$client->followRedirect();
// Gérer l'alert box et la route
$this->assertSelectorTextContains('div.alert-success', 'Votre ingrédient a été créé avec succès !');
$this->assertRouteSame('ingredient.index');
}
public function testIfListIngredientIsSuccessful(): void
{
$client = static::createClient();
$urlGenerator = $client->getContainer()->get('router');
$entityManager = $client->getContainer()->get('doctirne.orm.entity_manager');
$user = $entityManager->find(User::class, 1);
$client->loginUser($user);
$client->request(Request::METHOD_GET, $urlGenerator->generate('ingredient.index'));
$this->assertResponseIsSuccessful();
$this->assertRouteSame('ingredient.index');
}
public function testIfUpdateAnIngredientIsSuccessful(): void
{
$client = static::createClient();
$urlGenerator = $client->getContainer()->get('router');
$entityManager = $client->getContainer()->get('doctirne.orm.entity_manager');
$user = $entityManager->find(User::class, 1);
$ingredient = $entityManager->getRepository(Ingredient::class)->findOneBy([
'user' => $user
]);
$client->loginUser($user);
$crawler = $client->request(Request::METHOD_GET, $urlGenerator->generate('ingredient.edit', [
'id' => $ingredient->getId()
]));
$this->assertResponseIsSuccessful();
$form = $crawler->filter('form[name=ingredient]')->form([
'ingredient[name]' => "Un ingrédient 2",
'ingredient[price]' => floatval(34)
]);
$client->submit($form);
$this->assertResponseStatusCodeSame(Response::HTTP_FOUND);
$client->followRedirect();
$this->assertSelectorTextContains('div.alert-success', 'Votre ingrédient a été modifié avec succès !');
$this->assertRouteSame('ingredient.index');
}
public function testIfDeleteAnIngredient(): void
{
$client = static::createClient();
$urlGenerator = $client->getContainer()->get('router');
$entityManager = $client->getContainer()->get('doctirne.orm.entity_manager');
$user = $entityManager->find(User::class, 1);
$ingredient = $entityManager->getRepository(Ingredient::class)->findOneBy([
'user' => $user
]);
$client->loginUser($user);
$crawler = $client->request(Request::METHOD_GET, $urlGenerator->generate('ingredient.delete', [
'id' => $ingredient->getId()
]));
$this->assertResponseStatusCodeSame(Response::HTTP_FOUND);
$client->followRedirect();
$this->assertSelectorTextContains('div.alert-success', 'Votre ingrédient a été supprimé avec succès !');
$this->assertRouteSame('ingredient.index');
}
}
?>
On peut aussi tester le CRUD des ingrédients via EasyAdmin. On va créer un nouveau dossier "Admin" dans le dossier "tests/Functional" et on va créer le fichier "ContactTest.php" avec le contenu suivant :
<?php
namespace App\Tests\Funtional\Admin;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
class ContactTest extends WebTestCase
{
public function testCrudIsHere(): void
{
$client = static::createClient();
/** @var EntityManagerInterface $entityManager */
$entityManager = $client->getContainer()->get('doctrine.orm.entity_manager');
$user = $entityManager->getRepository(User::class)->findOneBy(['id' => 1]);
$client->loginUser($user);
$client->request(Request::METHOD_GET, '/admin');
$this->assertResponseIsSuccessful();
$crawler = $client->clickLink('Demandes de contact');
$this->assertResponseIsSuccessful();
$client->click($crawler->filter('.action-new')->link());
$this->assertResponseIsSuccessful();
$client->request(Request::METHOD_GET, '/admin');
$client->click($crawler->filter('.action-edit')->link());
$this->assertResponseIsSuccessful();
}
}
?>
Étape 26 : Créer une commande :
Dans cette partie, on va pouvoir créer un administrateur en ligne de commandes. Cela permettra d'ajouter des administrateurs par la suite si le site se développe. Donc, on va créer une commande "app:create-administrator" avec la commande suivante :
php bin/console make:command app:create-administrator
Cela génèrera le fichier `src/Command/CreateAdministratorCommand.php` avec le contenu modifié suivant :
<?php
namespace App\Command;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:create-administrator',
description: 'Create an Administrator',
)]
class CreateAdministratorCommand extends Command
{
private EntityManagerInterface $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
parent::__construct('app:create-administrator');
$this->entityManager = $entityManager;
}
protected function configure(): void
{
$this
->addArgument('full_name', InputArgument::OPTIONAL, 'Full Name')
->addArgument('email', InputArgument::OPTIONAL, 'Email')
->addArgument('password', InputArgument::OPTIONAL, 'Password');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$helper = $this->getHelper('question');
$io = new SymfonyStyle($input, $output);
$fullName = $input->getArgument('full_name');
if (!$fullName) {
$question = new Question('Quel est le nom de l\'adminstrateur : ');
$fullName = $helper->ask($input, $output, $question);
}
$email = $input->getArgument('email');
if (!$email) {
$question = new Question('Quel est l\'email de ' . $fullName . ' : ');
$email = $helper->ask($input, $output, $question);
}
$plainPassword = $input->getArgument('password');
if (!$plainPassword) {
$question = new Question('Quel est le mot de passe de ' . $fullName . ' : ');
$plainPassword = $helper->ask($input, $output, $question);
}
$user = (new User())->setFullName($fullName)
->setEmail($email)
->setPlainPassword($plainPassword)
->setRoles(['ROLE_USER', 'ROLE_ADMIN']);
$this->entityManager->persist($user);
$this->entityManager->flush();
$io->success('Le nouvel administrateur a été créé !');
return Command::SUCCESS;
}
}
?>
La commande devient donc ceci :
php bin/console app:create-administrator
Et avec les arguments :
php bin/console app:create-administrator "Emilien" "emilien@symrecipe.fr" "password"
Étape 27 : Créer un filtre Twig :
Par exemple si la recette a un temps en minutes avec une valeur de "1005", on pourrait afficher le temps en heures/minutes et donc créer/utiliser un filtre Twig (ou encore "extension Twig").
Pour cela, dans le dossier "src", on crée le dossier "Twig" et on y insère le fichier "AppExtension.php" ou on utilise la commande suivante :
php bin/console make:twig-extension AppExtension
On va modifier son contenu comme ceci :
<?php
namespace App\Twig;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
class AppExtension extends AbstractExtension
{
public function getFilters(): array
{
return [
new TwigFilter('min_to_hour', [$this, 'minutesToHours']);
];
}
public function minutesToHours($value)
{
if ($value < 60 || !$value) {
return $value;
}
$hours = floor($value / 60);
$minutes = $value % 60;
if ($minutes < 10) {
$minutes = '0' . $minutes;
}
$time = sprintf('%sh%s'; $hours, $minutes);
return $time;
}
}
?>
Une fois le filtre créé, dans notre template, on peut l'utiliser comme ceci :
<p>Temps : {{ recipe.time|min_to_hour }}</p>
Étape 28 : Mise en place d'un cache pour notre application :
Un cache va permettre de stocker les pages de nombreux sites Web, c'est-à-dire que les pages peuvent toujours être consultées même si le site Web d'origine est hors ligne.
Dans le "Cache Component" qui couvre les besoins simples et plus avancés au niveau du cache, on a deux grandes familles : le "Cache Contracts" qui est un sytème plus simple et plus puissant mettant en cache des valeurs appelées ou recalculées très fréquemment, et le "PSR-6 Caching" qui est un système générique de cache basé sur des systèmes de "pools" et de "items".
Dans notre application, comme on affiche très souvent les recettes publiques, ce sera intéressant de mettre un "Cache Contracts" dessus.
Dans notre fonction "findPublicRecipe" de notre "RecipeRepository", on a un "sleep(3);". Dans le "RecipeController", on va ajouter le cache dans la fonction "indexPublic" comme ceci :
#[Route('/recette/communaute', 'recipe.community', methods: ['GET'])]
public function indexPublic(RecipeRepository $repository, PaginatorInterface $paginator, Request $request): Response
{
$cache = new FilesystemAdapter();
$data = $cache->get('recipes', function(ItemInterface $item) use ($repository) {
$item->expiresAfter(15);
return $repository->findPublicRecipe(null);
});
$recipes = $paginator->paginate(
$data,
$request->query->getInt('page', 1),
10
);
return $this->render('pages/recipe/commuity.html.twig', [
'recipes' => $recipes
]);
}
Webpack Encore :
Symfony nous permet de manager correctement nos fichiers CSS et JavaScript, notamment avec "Webpack Encore". Cela va nous permettre de rendre notre travail plus agréable et plus qualicatif au niveau des fichiers de style et des fichiers de script. Ça va nous éviter d'avoir des fichiers dans le dossier "public" et de les inclure un à un.
D'après Wikipédia, Webpack est un modules bundle open source. Son objectif principal est de regrouper des fichiers JavaScript pour les utiliser dans un navigateur. Cet outil est également capable de transformer, regrouper ou empaqueter à peu près n'importe quelle ressource."
Webpack Encore est un moyen plus simple d'intégrer Webpack dans notre application car de base il n'est pas facile à configurer. La première étape est d'installer le bundle avec la commande suivante :
composer require symfony/webpack-encore-bundle
Il nous demande ensuite de faire :
yarn install
Cela permet donc de faire un "vendor" pour JavaScript : les "node_modules" avec un fichier "package.json". On peut également utiliser le "npm install"" à la place. Pour voir les modifications instantanées sur notre style sur Symfony avec la commande :
yarn watch
Ça permet de compiler tous nos fichiers dans le dossier "assets" dans le dossier "public/build". Dans ce nouveau dossier, on aura les fichiers "app.css" et "app.js". Dans le fichier "webpack.config.js", on a des "entry".
Grâce à "{{ encore_entry_link_tags('app') }}" et à "{{ encore_entry_script_tags('app') }}", Symfony sait qu'on utilise Webpack Encore.
Étape 29 : Les Event Listener & Event Subscriber :
Dans le cadre d'une application complexe, on a souvent besoin de rajouter de la logique supplémentaire sur des logiques déjà existantes. Pour répondre à cette problématique, Symfony propose un système d'événements qui permettra d'ajouter des écouteurs pour venir greffer la logique que l'on souhaite.
Durant l'exécution d'une application Symfony, plusieurs notifications d'évènements sont déclenchées. Par exemple, plusieurs évènements sont déclenchés lors d'une requête HTTP classique.
Un événement est une simple classe PHP que l'on va en général construire avec les éléments significatifs.
La façon la plus commune pour écouter un évènement est d'enregistrer un "event listener". Dans le dossier "src", on crée le dossier "EventListener" et créer le fichier "ExceptionListener.php" ou avec la commande suivante :
php bin/console make:listener ExceptionListener
On va modifier le contenu de cette classe comme ceci :
<?php
namespace App\EventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
class ExceptionListener
{
public function onKernelException(ExceptionEvent $event)
{
// You get the exception object from the reveived event
$exception = $event->getThrowable();
$message = sprintf(
'My error says: %s with code: %s',
$exception->getMessage(),
$exception->getCode()
);
// Customize your response object to display the exception details
$response = new Response();
$response->setContent($message);
// HttpExceptionInterface is a special type of exception that
// holds status code and header details
if ($exception instanceof HttpExceptionInterface) {
$response->setStatusCode($exception->getStatusCode());
$response->headers->replace($exception->getHeaders());
} else {
$response->setStatusCode(Response::HTTP_INTERNAL_SERVER_ERROR);
}
// sends the modified response object to the event
$event->setResponse($response);
}
}
?>
Dans le fichier de configuration "services.yaml", on doit ajouter les lignes suivantes :
App\EventListener\ExceptionListener:
tags:
- { name: kernel.event_listener, event: kernel.exception }
L'"event subscriber" est une classe qui définit une ou plusieurs méthodes qui écoutent un ou plusieurs évènements. La principale différence entre les deux est que les subscribers connaissent toujours les évènements qu'ils écoutent.
Dans le dossier "src", on crée le dossier "EventSubscriber" et dedans le fichier "kernelSubscriber.php" ou le créer via la commande :
php bin/console make:subscriber
On va modifier le contenu de cette classe comme ceci :
<?php
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
class kernelSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents()
{
return [RequestEvent => 'onKernelRequest'];
}
public function onKernelRequest(RequestEvent $event)
{
dd($event->getRequest()->getPathInfo());
}
}
?>
Exemples :
<?php
namespace App\Event;
use App\DTO\ContactDTO;
class ContactRequestEvent
{
public function __construct(public readonly ContactDTO $data)
{
}
}
?>
Ensuite, on pourra émettre cet événement via l'EventDispatcher.
#[Route('/contact', name: 'contact')]
public function contact(Request $request, MailerInterface $mailer, EventDispatcherInterface $dispatcher): Response
{
$data = new ContactDTO();
$form = $this->createForm(ContactType::class, $data);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$dispatcher->dispatch(new ContactRequestEvent($data));
$this->addFlash('success', 'Votre email a bien été envoyé !');
} catch(\Exception $e) {
$this->addFlash('danger', 'Impossible d\'envoyer votre email !');
}
}
return $this->render('contact/contact/contact.html.twig', [
'form' => $form->createView(),
]);
}
Maintenant, si on souhaite ajouter de la logique lorsque cet événement se produit, on peut créer un listener. Cela peut se faire facilement à l'aide l'attribut AsEventListener. C'est un mécanisme simple et moderne introduit dans Symfony 6.1.
#[AsEventListener]
fincal class ContactListener
{
publc function __invoke(ContactRequestEvent $event): void
{
// ...
}
}
Il est aussi possible de définir un Subscriber dans le cas où on veut pouvoir ajouter une logique similaire à plusieurs événements.
<?php
namespace App\EventSubscriber;
use App\Entity\User;
use App\Event\ContactRequestEvent;
use Symfonyy\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
class MailingSubscriber implements EventSubscriberInterface
{
public function __construct(private readonly MailerInterface $mailer)
{
}
public function onContactRequestEvent(ContactRequestEvent $event): void
{
$data = $event->data;
$mail = (new TemplatedEmail())
->to($data->service)
->from($data->email)
->subject('Demande de contact')
->htmlTemplate('emails/contact.html.twig')
->context(['data' => $data]);
$this->mailer->send($mail);
}
public function onLogin(InteractiveLoginEvent $event): void
{
$user = $event->getAuthenticationToken()->getUser();
if (!$user instanceof User) {
return;
}
$mail = (new Email())
->to($user->getEmail())
->from('support@demo.fr')
->subject('Connexion')
->text('Vous vous êtes connecté !');
$this->mailer->send($mail);
}
public static function getSubscribedEvents(): array
{
return [
ContactRequestEvent::class => 'onContactRequestEvent',
InteractiveLoginEvent::class => 'onLogin',
];
}
}
?>
Ou on peut également faire ceci :
<?php
namespace App\EventListener;
use App\Entity\User;
use App\Event\ContactRequestEvent;
use Symfonyy\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Security\Http\Event\InteractiveLoginEvent;
class MailingListener
{
public function __construct(private readonly MailerInterface $mailer)
{
}
#[AsEventListener(event: ContactRequestEvent::class)]
public function onContactRequestEvent(ContactRequestEvent $event): void
{
$data = $event->data;
$mail = (new TemplatedEmail())
->to($data->service)
->from($data->email)
->subject('Demande de contact')
->htmlTemplate('emails/contact.html.twig')
->context(['data' => $data]);
$this->mailer->send($mail);
}
#[AsEventListener(event: InteractiveLoginEvent::class)]
public function onLogin(InteractiveLoginEvent $event): void
{
$user = $event->getAuthenticationToken()->getUser();
if (!$user instanceof User) {
return;
}
$mail = (new Email())
->to($user->getEmail())
->from('support@demo.fr')
->subject('Connexion')
->text('Vous vous êtes connecté !');
$this->mailer->send($mail);
}
}
?>
Si tu utilises des services, assure-toi que l'autoconfiguration est activée dans services.yaml. Symfony détectera automatiquement les #[AsEventListener] :
services:
App\EventListener\:
resource: '../src/EventListener/*'
tags: ['kernel.event_listener']
Tu peux également avoir plusieurs méthodes pour le même évènement si elles gèrent des aspects différents. Par exemple :
#[AsEventListener(event: InteractiveLoginEvent::class, method: 'addCartToSession')]
#[AsEventListener(event: InteractiveLoginEvent::class, method: 'logUserLogin')]
Dans ce cas, chaque méthode est appelée à son tour. Symfont exécutera toutes les méthodes dans l'ordre de priorité par défaut (0).
Symfony te permet également de gérer l'ordre d'exécution des liners avec une priorioté :
#[AsEventListener(event: InteractiveLoginEvent::class, priority: 10)]
public function onInteractiveLogin(InteractiveLoginEvent $event): void
{
// Cette méthode sera appelée en premier
}
#[AsEventListener(event: InteractiveLoginEvent::class, priority: 5)]
public function logLoginDetails(InteractiveLoginEvent $event): void
{
// Cette méthode sera appelée ensuite
}
Étape 30 : Authentification à double facteur :
Pour cette partie, on va créer un nouveau projet Symfony avec la commande :
symfony new 2FA --webapp
On duplique le contenu du fichier ".env" dans un nouveau fichier ".env.local". On va décommenter la ligne de la DB en SQLite par exemple et on va la créer avec la commande :
symfony console d:d:c
On va créer un contrôleur "Home" avec la commande :
symfony console make:controller Home
On va modifier la fonction "index" et on ajoute une fonction "home" comme ceci :
#[Route('/home', name: 'home')]
public function home(): Response
{
return $this->render('home/index.html.twig');
}
#[Route('/', name: 'index')]
public function index(): Response
{
return $this->redirectToRoute('home');
}
Pour sécuriser la page "/home" pour dire que seul l'utilisateur ayant le rôle "ROLE_USER" peut y accéder, on peut ajouter dans le "access_control" du fichier "security.yaml" un path comme ceci :
access_control:
- { path: ^/home, roles: ROLE_USER }
On va créer un entité "User" avec la commande :
symfony console make:user
On oublie pas tout de suite dans la foulée de créer une migration avec la commande :
symfony console make:migration
Et bien sûr de migrer cette migration avec :
symfony console d:m:m
Dans notre UserRepository, on va créer une nouvelle méthode pour trouver l'utilisateur selon l'email ou le username :
public function findUserByEmailOrUsername(string $emailOrUsername): ?User
{
return $this->createQueryBuilder('u')
->where('u.email = :identifier')
->orWhere('u.username = :identifier')
->setParameter('identifier', $emailOrUsername)
->setMaxResults(1)
->getQuery()
->getSingleResults();
}
On va ensuite initialiser l'Authenticator qui créera une page d'authentification avec la commande :
symfony console make:auth
On va donc créer un login form authenticator dans une classe "AppAuthenticator". Vous pourrez ensuite personnaliser la classe générée par la commande pour personnaliser le processus de connexion.
<?php
namespace App\Security;
use // ...
class AppAuthenticator extends AbstractLoginFormAuthenticator
{
use TargetPathTrait;
public const LOGIN_ROUTE = 'app_login';
public function __construct(private UrlGeneratorInterface $urlGenerator, private UserRepository $UserRepository)
{
}
public function authenticate(Request $request): Passport
{
$username = $request->request->get('username', '');
$request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $username);
return new Passport(
new UserBadge($username, fn (string $identifier) => $this->UserRepository->findUserByEmailOrUsername($identifier)),
new PasswordCredentials($request->request->get('password', '')),
[
new CsrfTokenBadge('authenticate', $request->request->get('_csrf_token')),
new RememberBadge(),
]
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface, string $firewallName): ?Response
{
if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {
return new RedirectResponse($argetPath);
}
// For example :
// return new RedirectResponse($this->urlGenerator->generate('same_route'));
// throw new \Exception('TODO: provide a valid redirect inside ' . __FILE__);
return new RedirectResponse('/');
}
protected function getLoginUrl(Request $request): string
{
return $this->urlGenerator->generate(self::LOGIN_ROUTE);
}
}
?>
Dans le security.yaml, il y a maintenant le code ci-dessus :
main:
lazy: true
provider: app_user_provider
custom_authenticator: App\Security\AppAuthenticator
logout:
path: app_logout
# where to redirect after logout
# target: app_any_route
remember_me:
secret: '%kernel.secret%'
lifetime: 684800
path: /
# always_remeber_me: true
Si l'option always_remeber_me est décommentée, alors, dans notre vue login.html.twig, on rajoute le champ remember_me :
<label>
<input type="checkbox" name="_remember_me" checked">
Keep me logged in
</label>
Pour la partie enregistrement des utilisateurs, on peut également la commande qui créera un contrôleur et un formulaire pour gérer l'inscription des utilisateurs :
symfony console make:registration-form
Grâce à la commande précédente, on peut ajouter une validation "@UniqueEntity" à l'entité User. Ça peut également envoyer un email pour vérifier l'adresse email de l'utilisateur après l'inscription ainsi qu'authentifier l'utilisateur après l'inscription. Après l'inscription, on veut être redirigé vers la page "home".
La première étape est d'installer le package manquant :
composer require symfonycasts/verify-email-bundle
Voyons voir le code généré dans le RegistrationController :
<?php
namespace App\Controller;
use // ...
class RegistrationController extends AbstractController
{
private EmailVerifier $emailVerifier;
public function __construct(EmailVerifier $emailVerifier)
{
$this->emailVerifier = $emailVerifier;
}
#[Route('/register', name: 'app_register')]
public function register(Request $request, UserPasswordHasherInterface $userPasswordHasher, UserAuthenticatorInterface $userAuthenticator, AppAuthenticator $authenticator, EntityManagerInterface $entityManager): Response
{
$user = new User();
$form = $this->createForm(RegistrationFormType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// encode the plain password
$user->setPassword(
$userPasswordHasher->hashPassword(
$user,
$form->get('plainPassword')->getData()
)
);
$entityManager->persist($user);
$entityManager->flush();
// generate a signed url and email it to the user
$this->emailVerifier->sendEmailConfirmation('app_verify_email', $user,
(new TemplatedEmail())
->from(new Address('support@demo.fr', 'Support'))
->to($user->getEmail())
->subject('Please confirm your email')
->htmlTemplate('registration.confirmation_email')
);
// do anything else you need here, like send an email
return $userAuthenticator->authenticateUser(
$use,
$authenticator,
$request
);
}
return $this->render('registration/register', [
'registrationForm' => $form->createView(),
]);
}
#[Route('/verify/email', name: 'app_verify_email')]
public function verifyUserEmail(Request $request, TranslatorInterface $translate): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
// validate email confirmation link, sets User::isVerified=true and persists
try {
$this->emailVerifier->handleEmailConfirmation($request, $this->getUser());
} catch (VerifyEmailExceptionInterface $exception) {
$this->addFlash('verify_email_error', $translator($exception->getReason(), [], 'VerifyEmailBundle'));
return $this->redirectToRoute('/register');
}
// @TODO Change the redirect on success and handle or remove the flash message in your templates
$this->addFlash('success', 'your email address has been verified.');
return $this->redirectToRoute('/register');
}
}
?>
Pour mettre en place l'authentification à double facteur, on doit installer le bundle "2FA" avec la commande :
composer require 2fa
On va aussi installer une extension à ce bundle qui est "scheb/2fa-email" avec la commande suivante :
composer require scheb/2fa-email
Dans le "main firewall" de "security.yaml", on va rajouter les lignes suivantes :
two_factor:
auth_form_path: 2fa_login # The route name you have used in the routes.yaml
check_path: 2fa_login_check # The route name you have used in the routes.yaml
Dans le fichier de configuration "scheb_2fa.yaml", on va modifier son contenu comme ceci :
scheb_two_factor:
security_tokens:
- Symfony\Component\Security\Http\Authenticator\token\PostAuthenticationToken
email:
digits: 6
enabled: true
sender_email: no-reply@test.com
sender_name: John Doe
On va implémenter le "Scheb\TwoFactorBundle\Model\Email\TwoFactorInterface" dans notre entité "User" avec les lignes suivantes :
#[ORM\Column(type: 'string', nullable: true)]
private $authCode;
public function isEmailAuthEnabled(): bool
{
return true; // This can be a persist field to switch email code authentication on/off
}
public function getEmailAuthRecipient(): string
{
return $this->email;
}
public function getEmailAuthCode(): string
{
if (null === $this->authCode) {
throw new \LogicException('The email authentication code was not set');
}
return $this->authCode;
}
public function setEmailAuthCode(string $authCode): void
{
$this->authCode = $authCode;
}
Maintenant qu'on a modifié l'entité "User", on va créer une nouvelle migration et on va la migrer. On va configurer le "MAILER_DSN" via Docker par exemple dans le fiche "docker-compose.yml" avec le contenu suivant :
version: '3.7'
services:
mailer:
image: schickling/mailcatcher
ports: [1025, 1080]
On va démarrer notre petit serveur de mail Docker avec la commande :
docker-compose up -d
Ensuite, on va mettre en place les appareils de confiance pour ne pas chaque fois récupérer un code par email lors de la double authentification.
On va installer une nouvelle extension à ce bundle avec la commande :
composer require scheb/2fa-trusted-device
Dans notre fichier de configuration "scheb_2fa.yaml", on va rajouter les lignes suivantes :
trusted_device:
enabled: true # If the trusted device feature should be enabled
lifetime: 5184000 # Lifetime of the trusted device token
extend_lifetime: false # Automatically extend lifetime of the trusted cookie on re-login
cookie_name: trusted_device # Name of the trusted device cookie
cookie_secure: false # Set the 'Secure' (HTTPS Only) flag on the trusted device cookie
cookie_same_site: "lax" # The same-site option of the cookie, can be "lax" or "strict"
cookie_path: "/" # Path to use when setting the cookie
Dans le "two_factor" de notre "main firewall" de notre "security.yaml", on rajoute la ligne :
trusted_parameter_name: _trusted # Name of the parameter for the trusted device option
Étape 31 : Authentification à deux facteurs avec Google Authenticator :
Avec le prochain package qu'on va installer, le site web va générer un QR Code qui va partager une "OTP Secret Key", l'"issuer" (l'émetteur) et la "period" (durée de validation du QR Code définie par défaut à 30 secondes).
Dans un terminal, on va cloner le repository "symfony-google-authenticator" du youtubeur "Pentiminax" dont les explications sont tirées avec la commande :
git clone https://github.com/pentiminax/symfony-google-authenticator.git
On va installer les packages composer et Symfony avec la commande :
composer install
On va lancer le serveur local avec la commande :
php -S localhost:8000 -t public/
Dans notre entité "User", on a une nouvelle propriété qui va stocker la clé secrète :
#[ORM\Column(length: 255, nullable: true)]
private ?string $secret = null;
Dans notre fichier de configuration des services, "services.yaml", on a un paramètre "app.issuer" avec la valeur "Amazaune" par exemple.
Pour commencer on va créer le service "AuthenticatorService" dans le dossier "src/Service" avec le contenu suivant :
<?php
namespace App\Service;
use Doctrine\ORM\EntityManagerInterface;
use OTPHP\TOTP;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
class AuthenticatorService
{
public function __construct(
private readonly ParameterBagInterface $parameters,
private readonly EntityManagerInterface $em
)
{
}
public function getCodeUri(User $user): array
{
$totp = TOTP::generate();
$totp->setIssuer($this->parameters->get('app.issuer'));
$totp->setLabel($user->getUserIdentifier());
$qrCodeUri = $totp->getQRCodeUri(
'https://api.qrserver.com/v1/create-qr-code/?color=000&bgcolor=FFF&data=[DATA]&qzone=2&margin=0&size=300x300&ecc=M',
'[DATA]'
);
return [$qrCodeUri, $totp->getSecret()];
}
public function validatePairing(User $user, string $secret): void
{
if (!$secret) {
return;
}
$user->setSecret($secret);
$this->em->flush();
}
}
?>
Dans le "AuthenticatorController", on va y modifier sa fonction "pair" comme ceci :
<?php
namespace App\Controller;
use App\Service\AuthenticatorService;
...
class AuthenticatorController extends AbstractController
{
#[Route('authenticator/pair', name: 'app_authenticator_pair')]
public function pair(AuthenticatorService $authenticatorService, Request $request): Response
{
if ($request->isMethod(Request::METHOD_POST)) {
$authenticatorService->validatePairing($this->getUser(), $request->request->get('secret'));
return $this->redirectToRoute('app_home_index');
}
[$qrCodeUri, $secret] = $authenticatorService->getQRCodeUri($this->getUser());
return $this->render('authenticator/pair.html.twig', [
'qrCodeUri' => $qrCodeUri,
'secret' => $secret
]);
}
}
?>
On va modifier également le template pour qu'il puisse afficher les nouvelles valeurs comme ceci :
{% extends 'base.html.twig' %}
{% block title %}Pair{% endblock %}
{% block body %}
<img alt="QR Code" src="{{ qrCodeUri }}">
<form method="post">
<input type="hidden" name="secret" value="{{ secret }}">
<button type="submit">Validate Pairing</button>
</form>
{% endblock %}
Enfin, on va vérifier le code dans la fonction "verify" du "AuthenticatorController" :
#[Route('/authenticator/verify', name: 'app_authenticator_verify')]
public function verify(Request $request): Response
{
if (is_null($this->getUser()->getSecret())) {
return $this->redirectToRoute('app_authenticator_pair');
}
if ($request->isMethod(Request::METHOD_POST)) {
$totp = TOTP::createFromSecret($this->getUser()->getSecret());
$result = $totp->verify($request->request->getString('otp'));
}
return $this->render('authenticator/verify.html.twig', [
'result' => $result ?? null;
]);
}
On modifie également son template comme ceci :
{% extends 'base.html.twig' %}
{% block title %}Verify{% endblock %}
{% block body %}
<form method="post">
<label for="otp">OTP Code</label>
<input type="text" name="otp" id="otp">
<button type="submit">Verify</button>
</form>
<br/>
{% if result is not null %}
<p>{{ result == true ? 'Le code est bon' : "C'est pas bon" }}</p>
{% endif %}
{% endblock %}
À la découverte des variables d'environnement de Symfony :
Dans cette partie, nous allons partir à la découverte des variables d'environnement de Symfony, et vous allez voir qu'il y a une infinité de façons de les définir ou de les utiliser !
Initier notre projet Symfony :
Crérons notre projet et entrons dans le dossier :
symfony new variables_environnement --webapp
cd variables_environnement
Créons un contrôleur de test :
symfony console make:controller TestController
Démarrons notre application :
symfony serve -d
Et supprimons toutes références à Webpack dans le fichier `templates/bas.html.twig` :
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{% block title %}Welcome!{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text></svg>">
{# Run `composer require symfony/webpack-encore-bundle` to start using Symfony UX #}
{% block stylesheets %}
{% endblock %}
{% block javascripts %}
{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>
Définir des variables d'environnement :
Définir des variables d'environnement avec un fichier `.env` et `.env.local` :
Par défaut, Symfony utilise les variables d'environnement définies dans le fichier `.env` pour définir les variables d'environnement.
Si on consulte ce fichier on peut voir qu'il y a plusieurs variables d'environnement (j'ai supprimé les commentaires) :
APP_ENV=dev
APP_SECRET=d039c490ad66d067877186a6640183ab
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
DATABASE_URL="postgresql://symfony:ChangeMe@127.0.0.1:5432/app?serverVersion=13&charset=utf8"
La bonne façon de définir des variables d'environnement est de créer un fichier `.env.local` qui contient les variables d'environnement à utiliser pour le développement sur votre machine et de ne jamais définir des variables d'environnement dans le fichier `.env` (qui est commit sur Git).
Finalement, le fichier `.env` est une sorte de template qui permet de définir des variables d'environnement utilisées dans le projet.
Copions donc le fichier `.env` dans un fichier `.env.local`.
cp .env .env.local
Nous pouvons checker les variables d'environnement avec la debug barre :
Ajoutons une variable d'environnement :
Et checkons les variables d'environnement :
Hiérarchie des fichiers de variables d'environnement :
Nous l'avons vu juste avant, le fichier `.env.local` est prioritaire sur le fichier `.env`.
Mais il est possible d'ajouter encore de la finesse dans votre architecture de projet en créant un fichier `.env.dev` qui contient des variables d'environnement spécifiques à un environnement de développement.
Et copions le contenu du fichier `.env.local` dans le fichier `.env.dev` :
cp .env.local .env.dev
Et modifions légèrement le contenu du fichier `.env.dev` :
DEMO="Ceci est une démo depuis le fichier .env.local"
Nous constatons que le fichier `env.dev` est prioritaire sur le fichier `.env.local` :
Poussons encore plus loin dans notre architecture de projet en créant un fichier `.env.dev.local` en y copiant le contenu du fichier `.env.dev` :
cp .env.dev .env.dev.local
Nous constatons que le fichier `.env.dev.local` est prioritaire sur le fichier `.env.dev` :
La CLI :
Dumper les variables d'environnement :
Pour des raisons de performances, notamment en production, et afin d'évier de passer les fichiers `.env` à chaque requête, nous pouvons utiliser la commande `dump-env` pour afficher les variables d'environnement dans un fichier PHP :
composer dump-env dev # ou prod
Un fichier `.env.php` sera crée à la racine du projet :
<?php
// This file was generated by running "composer dump-env dev"
return array (
'APP_ENV' => 'dev',
'APP_SECRET' => 'd039c490ad66d067877186a6640183ab',
'MESSENGER_TRANSPORT_DSN' => 'doctrine://default?auto_setup=0',
'DATABASE_URL' => 'postgresql://symfony:ChangeMe@127.0.0.1:5432/app?serverVersion=13&charset=utf8',
'DEMO' => 'Ceci est une démo depuis le fichier .env.dev.local',
);
?>
Modifions le contenu du fichier `.env.php` (À ne jamais faire, c'est uniquement pour la démo) :
<?php
// This file was generated by running "composer dump-env dev"
return array (
'APP_ENV' => 'dev',
'APP_SECRET' => 'd039c490ad66d067877186a6640183ab',
'MESSENGER_TRANSPORT_DSN' => 'doctrine://default?auto_setup=0',
'DATABASE_URL' => 'postgresql://symfony:ChangeMe@127.0.0.1:5432/app?serverVersion=13&charset=utf8',
'DEMO' => 'Ceci est une démo depuis le fichier .env.local.php',
);
?>
Et vérifions que les variables d'environnement sont bien modifiées :
Lister les vaériables d'environnement en CLI :
La multiplicité des fichiers peut rendre la lecture des variables d'environnement difficile. C'est pourquoi il existe une commande qui permet de lister les variables d'environnement :
symfony console debug:dotenv
Le résultat de la commande est hyper clair :
Accéder aux variables d'environnement :
Maintenant que nous avons vu comment récupérer les variables d'environnement, nous pouvons les utiliser dans notre application.
Accéder aux variables d'environnement depuis les contrôleurs :
Afin d'accéder à vos variables d'environnement depuis les contrôleurs, nous devons les ajouter dans notre fichier `config/services.yaml` :
parameters:
demo: '%env(DEMO)%'
Vérifions avec une nouvelle commande que notre variable d'environnement est bien présente :
symfony console debug:container --env-vars
Dans notre contrôleur `src/Controller/TestController.php` ajoutons de quoi accéder à la variable d'environnement :
#[Route('/test', name: 'app_test')]
public function index(): Response
{
$demo = $this->getParameter('DEMO');
dd($demo);
return $this->render('test/index.html.twig', [
'controller_name' => 'TestController',
]);
}
Et vérifions !
Accéder aux variables d'environnement depuis les services :
Voyons maintenant comment accéder aux variables d'environnement depuis les services.
la méthode "manuelle" :
Commençons par créer un service dans le fichier `src/Service/DemoService.php` qui permet de récupérer les variables d'environnement :
<?php
namespace App\Service;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
class DemoService
{
public function __construct(ParameterBagInterface $parameterBag)
{
$this->parameterBag = $parameterBag;
}
public function getDemo(): string
{
return $this->parameterBag->get('DEMO');
}
}
?>
Et modifions notre contrôleur `src/Controller/TestController.php` pour utiliser notre service :
<?php
namespace App\Controller;
use App\Service\DemoService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class TestController extends AbstractController
{
#[Route('/test', name: 'app_test')]
public function index(DemoService $demoService): Response
{
dd($demoService->getDemo());
return $this->render('test/index.html.twig', [
'controller_name' => 'TestController',
]);
}
}
?>
On rafraîchit notre page, c'est bien le contenu de la variable d'environnement qui est affiché :
La méthode "automatique" :
Plutôt que d'utiliser le `ParameterBagInterface` dans le service pour récupérer les variables d'environnement, nous pouvons utiliser la méthode "automatique de Symfony pour injecter les variables d'environnement.
Dans notre fichier `config/services.yaml` ajoutons :
parameters:
DEMO: '%env(DEMO)%'
services:
# ...
App\Service\DemoService:
arguments:
$demo: '%env(DEMO)%'
Et utilions notre argument `$demo` dans notre service :
<?php
namespace App\Service;
class DemoService
{
public function __construct(public string $demo)
{
}
public function getDemo(): string
{
return $this->demo;
}
}
?>
Nous pouvons vérifier que tout fonctionne :
Accéder aux variables d'environnement depuis Twig :
Nous pouvons aussi accéder aux variables d'environnement depuis Twig.
Pour cela, nous devons ajouter une variable d'environnement dans notre fichier `config/packages/twig.yaml` :
twig:
default_path: '%kernel.project_dir%/templates'
globals:
DEMO: '%env(DEMO)%'
Et dansd notre fichier `templates/test/index.html.twig` nous pouvons accéder à la variable d'environnement directement :
{% extends 'base.html.twig' %}
{% block title %}Hello TestController{% endblock %}
{% block body %}
{{ DEMO }}
{% endblock %}
Et on rafraîchit notre page, c'est bien le contenu de la variable d'environnement qui est affiché :
Mettez vos variables d'environnement en sécurité :
Comme nous l'avons vu jusqu'ici, nous pouvons accéder aux variables d'environnement depuis n'importe quel endroit de notre application. Nos stockons les variables d'environnement dans un simple fichier.
C'est certes pratique mais pas nécessairement sécurisé.
Utilisation du système de gestion des secrets de Symfony :
Symfony propose un système de gestion des secrets basé sur un système de clés cryptographiques.
Génération des clés :
Première étape, nous allons générer les clés :
php bin/console secrets:generate-keys
Nous constatons que les clés sont générées avec succès dans les deux fichiers suivants :
Si vous souhaitez générer les clés pour la production :
APP_ENV=prod php bin/console secrets:generate-keys
Nous constatons que les clés sont générées avec succès dans le deux fichiers suivants :
Pensez à vérifier que votre fichier `prod.decrypt.private.php` est bien présent dans votre `.gitignore` :
/config/secrets/prod/prod.decrypt.private.php
Ajouter une variable d'environnement à la liste des secrets :
Pour ajouter un nouveau secret (`DEMO_SECRET: Ceci est un secret`) nous devons utiliser la commande suivante :
php bin/console secrets:set DEMO_SECRET
Pour la production :
APP_RUNTIME_ENV=prod php bin/console secrets:set DEMO_SECRET
Nous constatons la création d'un nouveau fichier `config/secrets/dev/dev/DEMO_SECRET.80921a.php` qui contient notre variable d'environnement chiffrée :
<?php // dev.DEMO_SECRET.80921a on Mon, 07 Mar 2022 10:07:38 +0100
return "\xCD\x9B\x7F6\xDC\xC9\x82\xE6\xEB\xE2\x808\xBF\xA9P\xA8\x9BL\x3A\xA5\xC0\xBD\xA6\xA6\xA7\x7D\x10\xCD\xE9\xD0\xBEg\xA3ez\x8A\xC2\x91k\xD04DDLo\x9D\xB9\x17\xCD\xC9\x989\x24\x3A\xC3U\xFCFP\x5CQ\xC0\xB9\xA3T\x60";
?>
Et accédons à notre variable d'environnement depuis Twig :
twig:
default_path: '%kernel.project_dir%templates'
globals:
DEMO: '%env(DEMO)%'
DEMO_SECRET: '%env(DEMO_SECRET)%'
Modifions notre fichier twig `templates/test/index.html.twig` :
{% extends 'base.html.twig' %}
{% block title %}Hello TestController!{% endblock %}
{% block body %}
{{ DEMO_SECRET }}
{% endblock %}
Dumpons nos variables d'environnement :
composer dump-env dev
Et constatons le résultat :
Set d'un secret en local :
Vous pouvez aussi set un secret en local (valable uniquement sur votre machine) :
php bin/console secrets:set --local DEMO_SECRET
Dumpons nos variables d'environnement :
composer dump-env dev
Et vérifions :
Quelques outils pour gérer les secrets :
-
Lister les secrets :
php bin/console secrets:list --reveal
------------- ---------------------- ----------------------------
Secret Value Local Value
------------- ---------------------- ----------------------------
DEMO_SECRET "Ceci est un secret" "Ceci est un secret local"
------------- ---------------------- ----------------------------
-
Supprimer un secret :
php bin/console secrets:remove DEMO_SECRET
Étape 32 : Doctrine avancé (select partiel, DTO & DQL) :
Dans cette partie, je vous propose de voir quelques notions avancés concernant Doctrine qui vous permettront d'améliorer les performances et d'écrire des requêtes plus complexes.
Select partiel :
Doctrine est pensé par défaut pour hydrater des entités et ses requêtes sélectionneront systématiquement l'ensemble des champs de l'entité. Cependant, dans certaines situations, on n'a pas besoin de forcément tout récupérer. Dans cette situation, il est possible d'utiliser la méthode select() sur le queryBuilder pour spécifier les champs souhaités.
$recipes = $this
->createQueryBuilder('r')
->select('r.id', 'r.name')
->getQuery()
->getResult();
Dans ce cas-là, chaque enregistrement sera renvoyé sous forme de tableau associatif. Mais, on va aussi pouvoir construire des objets qui serviront à représenter nos données (ce qui permet ensuite un typage). Pour cela, on peut utiliser l'opérateur NEW de Doctrine.
$recipes = $this
->createQueryBuilder('r')
->select('NEW App\\DTO\\LightRecipeDTO(r.id, r.name)')
->getQuery()
->getResult();
Cela utilisera le constructeur de l'objet en lui passant les champs sélectionnés (on ne peut utiliser que des données scalaires).
DQL : Doctrine Query Language :
Doctrine offre aussi un langage appelé DQL qui est un langage proche du SQL mais qui permet les requêtes sur les objets plutôt que les tables.
<?php
$query = $em->createQuery('SELECT u FROM MyProject\Model\User u WHERE u.age > 20');
$users = $query->getResult();
?>
Pour plus de détails sur les fonctionnalités du DQL, n'hésitez pas à vous rendre sur la documentation de Doctrine.
Étape 33 : Asset Mapper :
Parlons maintenant un peu de la partie front-end et de la gestion des assets dans le cadre d'une application Symfony. Même si, pour des besoins de base, il est est possible de placer nos ressources dans le dossier public, le dossier assets offre plusieurs avantages :
-
Le versionning, qui ajoutera un suffixe après le nom des fichiers qui permet de configurer un cache longue durée sur les fichiers CSS, JS, images...
-
Le mapping, qui permettra de faire référence à un fichier par son chemin original.
-
Importmaps qui permettra d'importer une ressource depuis un import JavaScript.
Pendant le développement, les ressources sont automatiquement distribuées si vous utilisez le serveur disponible via la commande Symfony symfony serve.
Quand on passera en production, il faudra utiliser la commande asset-map:compile pour compiler les assets dans le dossier public afin de les rendre accessibles.
php bin/console asset-map:compile
Librairie tierce :
Il est aussi possible d'importer des librairies tierces à l'aide de la commande importmap:require qui téléchargera le fichier du module et qui mettra à jour l'importmap.
php bin/console importmap:require canvas-confetti
On peut ensuite utiliser la librairie classiqueemnt dans notre code JS.
import confetti from 'canvas-confetti';
document.body.addEventListener('click', () => {
confetti();
});
Étape 34 : Le serializer :
Dans cette partie, nous allons voir comment on peut utiliser Symfony dans le cadre de la création d'une API. On a vu lors de la découverte des contrôleurs que l'on disposait d'une méthode json() sur le AbstractController qui permet de renvoyer du JSON.
class RecipesController extends AbstractController
{
#[Route('/api/recipes', methods: ['GET'])]
public function index(
RecipeRepository $repository,
)
{
$recipes = $repository->findAll();
return $this->json($recipes);
}
}
Par défaut, cette méthode va nous renvoyer un tableau contenant l'ensemble des champs de nos recettes. Cela est rendu possible par le système de sérialisation du framework qui repose sur deux composants :
-
Le normalizer qui va convertir un objet en tableau PHP classique.
-
L'encodeur qui va convertir un tableau dans le format choisi (JSON, CSV, XML...).
Dans notre cas, c'est l'ObjectNormalizer qui va être capable de scanner notre objet pour en extirper les informations. Si on souhaite contrôler les champs à exposer, on peut utiliser le contexte de normalisation et notamment les groupes.
return $this->json($recipes, 200, [], [
'groups' => ['recipes.index'],
]);
Ensuite, dans notre entité, on pourra annoter les propriétés que l'on souhaite assigner au groupe.
use Symfony\Component\Serializer\Attribute\Groups;
class Recipe
{
#[Groups(['recipes.index'])]
private ?int $id = null;
#[Groups(['recipes.index', 'recipes.create'])]
private string $title = '';
#[Groups(['recipes.show', 'recipes.create'])]
private string $content = '';
// ...
}
Ces groupes marchent aussi pour les entités imbriquées.
Normalizer personnalisé :
Pour des cas plus complexes, il est aussi possible de créer un normalizer personnalisé.
php bin/console make:serializer:normalizer
Ce normalizer sera automatiquement enregistré grâce au système d'autoconfiguration du gestionnaire de services de Symfony.
<?php
namespace App\Normalizer;
use App\Entity\Recipe;
use Knp\Component\Pager\Pagination\PaginationInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class PaginationNormalizer implements NormalizerInterface
{
public function __construct(
#[Autowire(service: 'serializer.normalizer.object')]
private readonly NormalizerInterface $normalizer
)
{
}
public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
{
if (!($object instanceof PaginationInterface)) {
throw new \RuntimeException();
}
return [
'items' => array_map(fn (Recipe $recipe) => $this->normalizer->normalize($recipe, $format, $context), $object->getItems()),
'total' => $object->getTotalItemCount(),
'page' => $object->getCurrentPageNumber(),
'lastPage' => ceil($object->getTotalItemCount() / $object->getItemNumberPerPage())
];
}
public function supportsNormalization(miwed $data, ?string $format = null, array $context = []): bool
{
return $data instanceof PaginationInterface;
}
public function getSupportedTypes(?string $format): array
{
return [
PaginationInterface::class => true,
];
}
}
?>
Étape 35 : Désérialisation et MapRequestPayload :
La désérialisation est le fonctionnement inverse à celui que l'on a vu précédemment. L'objectif est de convertir les données fournies dans un certain format dans un objet de notre application.
public function index(
Request $request,
SerializerInterface $serializer
): Response
{
$recipe = $serializer->deserialize($request->getContent(), Recipe::class, 'json');
}
Le fonctionnement est similaire à celui que l'on a vu pour le Serializer (on peut utiliser les groupes pour définir les champs à sérialiser).
MapRequestPayload :
Il existe un raccourci permettant de gérer la sérialisation d'une requête dès les arguments du contrôleur. Cela se fait à l'aide de l'attribut MapRequestPayload.
#[Route('/api/recipes', methods: ['POST'])]
public function create(
Request $request,
#[MapRequestPayload(
serializationContext: [
'groups' => ['recipes.create']
]
)]
Recipe $recipe,
EntityManagerInterface $em
)
{
$recipe->setCreatedAt(new \DateTimeImmutable());
$recipe->setUpdatedAt(new \DateTimeImmutable());
$em->persist($recipe);
$em->flush();
return $this->json($recipe, 200, [], [
'groups' => ['recipes.index', 'recipes.show'],
]);
}
Cet attribut va aussi utiliser la validation et, si les données soumises ne sont pas valides, une erreur sera directement renvoyée à l'utilisateur.
Il existe aussi un équivalent pour générer un objet à partir des informations provenant de l'URL à l'aide de MapQueryString.
On peut créer des DTO à la volée. Par exemple, dans le code suivant, si on rentre un numéro de page négatif, ça va lancer une erreur :
#[Route('/api/recipes', methods: ['GET'])]
public function index(RecipeRepository $repository, Request $request)
{
$recipes = $repository->paginateRecipes($request->query->getInt('page', 1));
return $this->json($recipes, 200, [], [
'groups' => ['recipes.index'],
]);
}
Pour corriger cette erreur, on va créer le DTO PaginationDTO :
<?php
namespace App\DTO;
use Symfony\Component\Validator\Constraints as Assert;
class PaginationDTO
{
public function __construct(
#[Assert\Positive()]
public readonly ?int $page = 1
)
{
}
}
?>
On modifiera donc notre code pour utiliser ce DTO :
#[Route('/api/Recipes', methods: ['GET'])]
public function index(
RecipeRepository $repository,
#[MapQueryString]
?PaginationDTO $paginationDTO = null;
)
{
$recipes = $repository->paginateRecipes($paginationDTO?->page);
return $this->json($recipes, 200, [], [
'groups' => ['recipes.index'],
]);
}
Étape 36 : Authenticator stateless :
Dans cette partie, je vous propose de revenir sur le composant Security et on va découvrir comment créer un système d'authentification stateless pour notre partie API.
Le composant Security est composé de plusieurs éléments :
Dans le cadre d'une API, on aura en général une enquête particulière Authorization qui nous permet d'identifier l'utilisateur qui est à l'origine de la requête.
<?php
namespace App\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
class APIAuthenticator extends AbstractAuthenticator
{
public function supports(Request $request): ?bool
{
return $request->headers->has('Authorization') && str_contains($request->headers->get('Authorization'), 'Bearer ');
}
public function authenticate(Request $request): Passport
{
$identifier = str_replace('Bearer ', '', $request->headers->get('Authorization'));
return new SelfValidatingPassport(
new UserBadge($identifier),
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new JsonResponse([
'message' => $exception->getMessage(),
], Response::HTTP_UNAUTHORIZED);
}
}
?>
Dans notre cas, on crée un passeport qui contient comme identifier la clef d'API. Il faudra ensuite modifier la configuration pour ajouter cet authenticator sur les URLs qui commencent par /api.
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
providers:
api_user_provider:
entity:
class: App\Entity\user
property: apiToken
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|xss|images|js)/
security: false
api:
pattern: ^/api/
provider: api_user_provider
custom_authenticator: App\Security\APIAuthenticator
stateless: true
lazy: true
Dans mon cas, je me contente de chercher l'utilisateur qui a la propriété apiToken qui correspond à la clef d'Authorization. Pour des cas plus complexes, vous pouvez utiliser un provider personnalisé.
Étape 37 : Permissions avancées avec les Voter :
Dans cette partie, nous allons revenir sur l'aspect sécurité et voir comment gérer des permissions plus fines qu'un simple système de rôle. Pour cela, on va se reposer sur l'utilisation de Voters qui permettent de juger de l'accès de l'utilisateur à certaines opérations.
Pour créer un voter, on utilise la commande :
php bin/console make:voter RecipeVoter
Exemple :
Par exemple, on permet à tout le monde de lister ses propres recettes, mais il faut être l'autre d'une recette pour l'éditer.
<?php
namespace App\Security\Voter;
use App\Entity\Recipe;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
class RecipeVoter extends Voter
{
public const EDIT = 'RECIPE_EDIT';
public const DELETE = 'RECIPE_DELETE';
public const VIEW = 'RECIPE_VIEW';
public const CREATE = 'RECIPE_CREATE';
public const LIST = 'RECIPE_LIST';
public const LIST_ALL = 'RECIPE_ALL';
protected function supports(string $attribute, mixed $subject): bool
{
return
in_array($attribute, [self::CREATE, self::LIST, self::LIST_ALL]) || (
in_array($attribute, [self::EDIT, self::VIEW])
&& $subjet instanceof \App\Entity\Recipe
);
}
/**
* @param Recipe|null $subject
*/
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
// if the user is anonymous, do not grant access_control
if (!$user instanceof User) {
return false;
}
switch($attribute) {
case self::EDIT:
case self::DELETE:
return $subject->getUser()->getId() === $user->getId();
break;
case self::LIST:
case self::CREATE:
case self::VIEW:
return true;
break;
}
return false;
}
}
?>
Ensuite, dans mon contrôleur, je peux utiliser l'attribut IsGranted pour valider le niveau de permission de l'utilisateur.
#[Route('/', name: 'index')]
#[IsGranted(RecipeVoter::LIST)]
public function index(Security $security): Response {
$page = $request->query->getInt('page', 1);
$userId = $security->getUser()->getId();
$canListAll = $security->isGranted(RecipeVoter::LIST_ALL);
// On limite la liste des recettes à celle de l'utilisateur s'il n'a pas les permissions de tout voir
$recipes = $repository->paginateRecipes($page, $canListAll ? null : $userId);
// ...
}
#[Route('/create', name: 'create')]
#[IsGranted(RecipeVoter::CREATE)]
public function create(Request $request): Response
{
// ...
}
#[Route('/{id}', name: 'edit', methods: ['GET', 'POST'], requirements: ['id' => Requirement::DIGITS])]
#[IsGranted(RecipeVoter::EDIT, subject: 'recipe')]
public function edit(Recipe $recipe, Request $request $request): Response
{
// ...
}
#[Route('/{id}', name: 'delete', methods: ['DELETE'], requirements: ['id' => Requirement::DIGITS])]
public function remove(Recipe $recipe)
{
// ...
}
Super admin :
Par défaut, le système est affirmative, il suffit d'un seul voter qui vote "oui" pour donner l'accès à l'utilisateur à un système. Aussi, on peut créer un voter basé sur le rôle qui répondra "oui" à tout si l'utilisateur a le rôle administrateur.
<?php
namespace App\Security\Voter;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
class AdminVoter extends Voter
{
protected function supports(string $attribute, mixed $subject): bool
{
return true;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!user instanceof UserInterface) {
return false;
}
return in_array('ROLE_ADMIN', $user->getRoles());
}
}
?>
Cela permet de ne pas polluer les autres voters tout en créant une règle qui outrepasse toutes les autres.
Étape 38 : Symfony UX :
Symfony UX est une initiative de l'équipe de Symfony pour fournir un ensemble de bibliothèques visant à simplifier l'intégration d'outils JavaScript dans vos applications Symfony.
Comment ça marche ?
Symfony UX s'appuie sur le framework JavaScript Stimulus, qui permet d'ajouter des comportements interactifs aux éléments HTML de manière automatique. Lorsque l'on va installer un paquet, une nouvelle ligne sera ajoutée au fichier assets/controller.json pour enregistrer automatiquement le contrôleur associé au module. Ce fichier sera lu par l'asset mapper et chargera le fichier JavaScript associé dans votre page de manière automatique.
On va devoir ajouter les dépendances de Symfony UX Turbo avec la commande :
composer require symfony/ux-turbo
On peut utiliser la balise <turbo-frame id="..."> pour intégrer le formulaire d'édition lorsqu'on clique sur le lien d'édition.
On peut également utiliser la condition suivante :
if ($request->getPreferredFormat() == TurboBundle::STREAM_FORMAT)
{
// ...
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
return $this->render('admin/recipe/delete.html.twig', ['recipeId' => $recipeId, 'message' => $message]);
}
Voici le code de la page TWIG qui va être retournée :
<turbo-stream action="remove" target="row_recipe_{{ recipeId }}">
</turbo-stream>
<turbo-stream action="append" target="flash">
<template>
<div class="alert alert-success">
{{ message }}
</div>
</template>
</turbo-stream>
On peut aussi installer les dépendances de Symfony UX Autocomplete avec la commande :
composer require symfony/ux-autocomplete
Grâce à cette installation, on peut rajouter, comme son nom le laisse supposer, un système d'autocomplétion dans nos formulaires en rajoutant 'autocomplete' => true, dans les propriétés de nos AbstractForm.
On peut utiliser la commande suivante pour créer un champ d'autocomplétion pour l'entité Category dans notre formulaire :
php bin/console make:autocomplete-field
Voici le code généré par cette commande :
<?php
// ...
// use ...
#[AsEntityAutocompleteField]
class CategoryAutocompleteField extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults({
'class' => Category::class,
'placeholder' => 'Choose a Category',
'choice_label' => 'name',
'query_builder' => function (CategoryRepository $categoryRepository) {
return $categoryRepository->createQueryBuilder('category');
},
// 'security' => 'ROLE_SOMETHING',
});
}
public function getParent: string
{
return ParentEntityAutocompleteType::class;
}
}
?>
Dans notre RecipeForm, on va remplacer l'EntityType pour nos catégories par ceci :
->add('category', CategoryAutocompleteField::class)
Enfin, pour installer les dépendances de Symfony UX Toggle Password, on utilise la commande :
composer require symfony/ux-toggle-password
Ce composant permet, comme son nom l'indique, d'afficher ou de masquer le mot de passe dans un formulaire en ajoutant la propriété 'toggle' => true, au champ password du AbstractForm correspondant comme ceci :
$builder
// ...
->add('password', PasswordType::class, [
'toggle' => true,
'hidden_label' => 'Masquer',
'visible_label' => 'Afficher',
// 'visible_icon' => null,
// 'hidden_icon' => null,
// 'button_classes' => ['toggle-password-button', 'btn', 'btn-primary', 'my-custom-class'],
// 'toggle' => ['input-group-text', 'my-custom-container'],
])
// ...
;
Il y a d'autres packages de Symfony UX que vous pouvez voir dans la documentation de Symfony UX.
Étape 39 : Formulaires imbriqués :
Dans cette partie, je vous propose de découvrir comment gérer un système de formulaire imbriqué. Cela sera l'occassion de découvrir le type CollectionType qui permet par exemple de gérer les données du relation OneToMany.
Pour commencer, on va créer une classe de formulaire pour la donnée liée, classe que l'on pourra utiliser dans notre formulaire principal.
->add('quantites', CollectionType::class, [
'entry_type' => QuantityType::class,
'allow_add' =>true,
'allow_delete' => true,
'by_reference' => false,
'entry_options' => [
'label' => false,
],
'attr' => [
'data-controller' => 'form-collection',
],
])
-
allow_add permet l'ajout de nouvel élément, cela ajoutera un attribut data-prototype au niveau du conteneur HTML.
-
allow_delete permet de supprimer un élément à la volée.
-
by_reference indique qu'on ne modifie pas directement la collection, mais que l'on doit appeler les méthodes addXXX / removeXXX sur notre entité à la place.
On doit ensuite ajouter du code JavaScript pour ajouter les boutons d'action.
// assets/controller/form-collection_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static values = {
addLabel: String,
deleteLabel: String,
};
/**
* Injecte dynamiquement le bouton "Ajouter" et les boutons "Supprimer"
*/
connect() {
this.index = this.element.childElementCount;
const btn = document.createElement('button');
btn.setAttribute('class', 'btn btn-secondary');
btn.innerText = this.addLabelValue || 'Ajouter un élément';
btn.setAttribute('type', 'button');
btn.addEventListener('click', this.addElement);
this.element.childNodes.forEach(this.addDeleteButton);
this.element.append(btn);
};
/**
* AJoute une nouvelle entrée dans la structure HTML
*
* @param {MouseEvent} e
*/
addElement = () => {
e.preventDefault();
const element = document.createRange().createContextualFragment(
this.element.dataset['prototype'].replaceAll('__name__', this.index)
).firstElementChild;
this.addDeleteButton(element);
this.index++;
e.currentTarget.insertAdjacentElement('beforebegin', element);
};
/**
* Ajoute le bouton pour supprimer une ligne
*
* @param {HTMLElement} item
*/
addDeleteButton = (item) => {
const btn = document.createElement('button');
btn.setAttribute('class', 'btn btn-secondary');
btn.innerText = this.deleteLabelValue || Supprimer;
btn.setAttribute('type', 'button');
item.append(btn);
btn.addEventListener('click', (e) => {
e.preventDefault();
item.remove();
});
};
};
Et voilà, notre entité sera automatiquement hydratée et Doctrine saura porter les modifications en base de données.
Étape 40 : Tâche asynchrone avec Messenger :
Dans cette partie, je vous propose de découvrir comment gérer un système de fil d'attente sur Symfony avec Messenger.
Étape 41 : Internationnalisation :
Dans cette partie, nous allons voir comment gérer l'internationalisation d'une application web avec Symfony.
Traduction de chaîne :
Pour les chaînes présentes dans le code source, il est possible de les traduire de différentes manières en fonction de la situation.
<!-- Via un filtre -->
{{ 'Welcome' | trans }}
<!-- Via un tag -->
{% trans %}Welcome{% endtrans %}
Dans les contrôleurs et les services, il est possible d'utiliser le service de traduction pour obtenir la version traduite d'une chaîne.
use Symfony\Contracts\Translation\TranslatorInterface;
public function index(TranslatorInterface $translator): Response
{
$translated = $translator->trans('Welcome');
}
Ensuite, les traductions de nos chaînes seront placées dans le dossier translations au format yaml. Ce dossier, et la locale à utiliser par défaut sont configurés dans le fichier de configuration translation.yaml. Voici le contenu de ce fichier :
framework:
default_locale: fr
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks:
- en
providers:
# messages.fr.yaml
welcome: Bienvenue
On a également la possibilité de générer de manière dynamique les traductions avec la commande suivante :
php bin/console translation:extract --force fr --format=yaml
Cela ne permet pas de traduire les formulaires créés via make:form. on va utiliser une fonction globale de PHP pour les labels t() qui permet de créer un nouvel objet qui est un TranslatableMessage.
Pour des traductions plus complexes (genre et pluralisation), il est possible d'utiliser le format ICU pour les messages.
<p>{{ 'home_recipe_count' | trans({count: 100}) }}</p>
# translations/messages+intl_icu.fr.yaml
Welcome: __Welcome
home_recipe_count: >-
{count, plural,
=0 {Aucune recette}
=1 {Une recette}
other {# recettes !}
}
<h1>{{ 'welcome' | trans({':name': 'John'}) }}</h1>
# translations/messages/fr/yaml
welcome: Bienvenue :name
Sélection de la langue :
Maintenant que nous avons nos traductions, il faut pouvoir passer d'une traduction à l'autre. Cela peut se faire de plusieurs manières.
Via l'URL :
Le cas classique est de préfixer nos URL avec la langue choisie par l'utilisateur.
# config/routes.yaml
controllers:
prefix: "{_locale}"
requirements:
_locale: en|fr|de
resource:
path: .../src/Controller/
namespace: App\Controller
type: attribute
L'attribut _locale sera automatiquement pris en compte par le système de traduction qui appliquera la locale associée à ce paramètre. Vous pouvez utiliser cet attribut n'importe où dans votre application.
Via une prédérence utilisateur :
Une autre possibilité est d'utiliser une préférence utilisateur pour définir la langue de l'interface. Dans ce cas-là, il sera possible d'utiliser un écouteur d'événement et d'utiliser le service LocaleSwitcher pour changer la locale du site.
<?php
namespace App\EventListener;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Translation\LocaleSwitcher;
final class UserLocaleListener
{
public function __construct(
private readonly Security $security,
private readonly LocaleSwitcher $LocaleSwitcher
)
{
}
#[AsEventListener(event: KernelEvents::REQUEST)]
public function onKernelRequest(RequestEvent $event): void
{
$user = $this->security->getUser();
if ($user && $user instanceof User) {
$this->LocaleSwitcher->setLocale($user->getLocale());
}
}
}
?>
Traduction des entités :
En revanche, Symfony n'intègre pas d'outil interne pour la traduction des champs de votre base de données. Cependant, il est possible d'utiliser le module DoctrineExtension qui permet l'ajout de comportements supplémentaires. Pour simplifier son intégration au sein de Symfony, on pourra utiliser le bundle StofDoctrineExtensionsBundle.
composer require stof/doctrine-extensions-bundle
Vous pourrez ensuite suivre les instructions de la documentation pour activer l'extension gedmo_translatable.
Dans le fichier de configuration de Doctrine, config/packages/doctrine.yaml, il faut rajouter le mapping suivant :
gedmo_translatable:
type: attribute
prefix: Gedmo\Translatable\Entity
dir: '%kernel.projet_dir%/vendor/gedmo/doctrine-extensions/src/Translatable/Entity'
alias: GedmoTranslatable # (optional) it will default to the name set for the mapping
is_bundle: false
Dans le fichier de configuration du bundle, stof_doctrine_extensions.yaml, on rajoute ceci :
stof_doctrine_extensions:
default_locale: fr
orm:
default:
translatable: true
Dans notre entity, on peut rajouter l'annotation #[Translatable] à nos différents champs.
On peut créer une migration qui va créer la table ext_translations qui contient l'ensemble des traductions qui sont associées à nos modèles. Seules les traductions qui sont pas dans la langue de la locale par défaut seront sauvées dans cette fameuse table.
Dans la QueryBuilder, on peut rajouter ceci pour permettre aussi les traductions :
->setHint(
Query::HINT_CUSTOM_OUTPUT_WALKER,
TranslationWalker::class
)
On peut également traduire dans une locale donnée en mettant cela à la place :
->setHint(
TranslatableListener::HINT_TRANSLATABLE_LOCALE,
'en'
)
On peut enfin activer ou désactiver le fallback en utilisant ce code-ci et ça permet de récupérer ce qui a été sauvé en base de données :
->setHint(
TranslatableListener::HINT_FALLBACK,
1
)
Pour la pagination, c'est la même chose :
$paginator = $this->paginator->paginate(
$builder->getQuery()->setHint(
Query::HINT_CUSTOM_OUTPUT_WALKER,
TranslationWalker::class
),
$page,
20,
[
'distinct' => false,
'sortFieldAllowList' => ['r.id', 'r.title']
]
);
Étape 42 : Les ValueResolver :
Dans Symfony, les contrôleurs sont au coeur de la logique de notre application. Les méthodes peuvent recevoir des paramètres qui sont automatiquement résolue par le système de Value Resolver. Ce système peut aussi être étendu avec des attribus pour rajouter de la logique supplémentaire.
// ...
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\Routing\Attribute\Route;
class BlogController
{
#[Route('/post/{$post}')]
public function create(
#[MapEntity] Post $post,
#[MapRequestPayload] CreatePostDTO $data,
): Response
{
}
}
Ce système peut être étendu pour créer votre propre système de résolution de paramètre. Pour cela, il suffit de créer une classe qui implémente l'interface Symfony\Component\HttpKernel\Controller\ValueResolverInterface.
Exemple :
Par exemple, voici un exemple de ValueResolver que j'utilise pour fusionner la logique d'un MapEntity et d'un MapRequestPayload.
Pour commencer, je crée un attribut qui me permettra de marquer le paramètre de mon contrôler comme hydratable.
<?php
namespace App\Http\ValueResolver\Attribute;
use App\Http\ValueResolver\EntityHydratorValueResolver;
use Symfony\Component\HttpKernel\Attribute\ValueResolver;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\Validator\Constraints\GroupSequence;
/**
* Attribut permettant une fusion entre MapEntity & MapRequestPayload
* - Récupère l'entité depuis la base de données (comme MapEntity)
* - Injecte les données provenant de la requête
* - Valide les données.
*/
#[\Attribute(\Attribute::TARGET_PARAMETER)]
final readonly class MapHydratedEntity extends ValueResolver
{
public function __construct(
public array $groups = [],
public string|GroupSequence|array|null $validationGroups = null,
)
{
parent::__construct(EntityHydratorValueResolver::class);
}
}
?>
Ensuite, on crée un ValueResolver qui ne déclenchera sa logique que pour les paramètres avec l'attribut MapHydratedEntity. Cette classe doit implémenter une méthode resolve() qui devra renvoyer un tableau contenant l'élément à injecter dans le contrôleur (ou renvoyer un tableau vide pour passer au ValueResolver suivant).
<?php
namespace App\Http\ValueResolver;
use App\Http\ValueResolver\Attribute\MapHydratedEntity;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Bridge\Doctrine\ArgumentResolver\EntityValueResolver;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Exception\ValidationFailedException;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Récupère une entité (comme MapEntity), l'hydrate et la valide
* Agit comme une combinaison entre MapEntity & MapRequestPayload.
*/
final readonly class EntityHydratorValueResolver implements ValueResolverInterface
{
private EntityValueResolver $entityValueResolver;
public function __construct(
private SerializerInterface $serializer,
private ValidatorInterface $validator,
ManagerRegistry $registry,
?ExpressionLanguage $expressionLanguage = null,
)
{
$this->entityValueResolver = new EntityHydratorValueResolver($registry, $expressionLanguage);
}
public function resolve(Request, ArgumentMetadata $argument): iterable
{
// On ne s'active que sur les paramètres avec l'attribut MapHydratedEntity
$attribute = $argument->getAttributes(MapHydratedEntity::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null;
if (!($attribute instanceof MapHydratedEntity)) {
return [];
}
// Agit comme un MapEntity
$entity = $this->entityValueResolver->resolve($request, $argument)[0] ?? null;
if (!$entity::class || $entity::class !== $argument->getType()) {
throw new NotFoundHttpException(sprintf('"%s" object not found by "%s".', $argument->getType(), self::class));
}
// Hydrate l'objet avec le contenu de la requête
$this->serialize->deserialize($request->getContent(), $entity::class, $request->getContentTypeFormat() ?? 'json', [
'groups' => $attribute->groups,
AbstractNormalizer::OBJECT_TO_POPULATE => $entity,
]);
$violations = $this->validator->validate($entity, groups: $attribute->validationGroups);
if (\count($violations)) {
throw HttpException::fromStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY, implode("\n", array_map(static fn ($e) => $e->getMessage(), iterator_to_array($violations))), new ValidationFailedException($entity, $violations));
}
return [
$entity,
];
}
}
?>
Étape 43 : Héberger Symfony sur un hébergement mutualisé O2Switch (via SSH / Git) :
Dans cette partie, nous allons voir comment déployer notre application Symfony sur un hébergeur qui utilise l'interface CPanel. Ici, nous utiliserons l'hébergement O2Swith.
Préparation de l'environnement :
La première étape est de préparer le serveur pour accueillir notre site :
-
On ajoute notre IP à la liste des IPs autorisées à se connecter en SSH au serveur via Autorisation SSH.
-
On commence par Ajouter un domaine que l'on fait pointer vers un possible spécifique (on pensera à ponter vers le dossier public). Par exemple :~/sites/mondomaine.tld/public.
-
On génère un certificat SSL, depuis "Lets EncryptTM SSL".
-
On crée une base de données avec son utilisateur associé (vous pouvez utiliser l'option "assistant" pour faire cette opération en une fois).
-
Sur "Sélectionner une version de PHP", on activera la version 8.2 nécessaire au fonctionnement de Symfony.
Préparation du site :
Pour que l'application Symfony soit déployée, il faut faire pointer les liens vers le fichier index.php du dossier public. Les hébergeurs utilisent souvent Apache et il faudra ajouter un fichier de configuration .htaccess. Cela peut se faire facilement à l'aide de symfony/apache-pack.
composer require symfony/apache-pack
Maintenant que notre serveur est configuré, on va devoir copier le code source de notre site sur le serveur. Dans notre cas, nous allons cloner notre projet depuis le dépôt distant git.
cd ~/sites
git clone git@github.com:DrissV/mondomaine.tld
Ensuite, on va modifier la configuration du site en créant un fichier .env.local. À adapter en fonction de votre environnement.
APP_ENV=prod
DATABASE_URL="mysql://user:password@127.0.0.1:3306/monsite?serverVersion=11.2.2-MariaDB&charset=utf8mb4"
MAILER_DSN=native://default
Enfin, on installe les dépendances, et on lance les migrations pour mettre à jour notre base de données.
composer install --no-dev --optimize-autoloader
composer dump-env prod
php bin/console importmap:install
php bin/console asset-map:compile
php bin/console doctrine:migrations:migrate
APP_ENV=prod APP_DEBUG=0 php bin/console cache:clear
Et voilà ! Le site devrait maintenant être visible et fonctionnel.
Automatiser avec Make :
Pour automatiser le processus de déploiement d'une nouvelle version, il est possible d'utiliser un fichier Makefile.
server := "user@mondomain.ltd"
domain := "mondomain.ltd"
.PHONY: install deploy
deploy:
ssh -A $(server)$ 'cd sites/$(domain) && git pull origin main && make install'
install: vendor/autoload.php
php bin/console doctrine:migrations:migrate -n
php bin/console importmap:install
php bin/console asset-map:compile
composer dump-env prod
php bin/console cache:clear
vendor/autoload.php: composer.lock composer.json
composer install --no-dev --optimize-autoloader
touch vendor/autoload.php
Si vous souhaitez déployer une mise à jour, il vous suffira de taper la commande make deploy.
Messenger :
Malheureusement, il n'est pas possible d'utiliser un superviseur pour faire fonctionner Messenger. Une solution alternative est d'utiliser une tâche récurrente qui traitera les tâches toutes les 5 minutes.
/usr/local/bin/php /home/USER/sites/mondomain.ltd/bin/console messenger:consume --time-limit=295 --memory-limit=128M async > /home/USER/sites/mondomain.ltd/var/log/cron.log 2>&1
Étape 44 : Héberger Symfony sur un hébergement mutualisé Infomaniak (SSH / Git) :
Dans cette partie, nous allons voir comment héberger notre application Symfony sur un hébergement mutualisé qui supporte SSH. Ici, je vais utiliser l'offre d'hébergement Infomaniak mais vous pouvez reproduire ce que l'on va faire sur n'importe quel hébergeur qui vous laisse un accès SSH sur le serveur.
Préparation du site :
Pour que l'application Symfony soit déployée, il faut faire pointer les liens vers le fichier index.php du dossier public. Les hébergeurs utilisent souvent Apache et il faudra ajouter un fichier de configuration .htaccess. Cela peut se faire facilement à l'aide de symfony/apache-pack.
composer require symfony/apache-pack
Clonage du projet en ligne :
Pour déployer le code source, nous allons utiliser git et cloner le dépôt distant. Pour m'organiser, je met les sites dans un dossier sites mais vous être libres de suivre la convention que vous préférez.
cd ~/sites
git clone git@github.com:DrissV/DemoSF.git
On modifiera ensuite les variables d'environnements en créant un fichier .env.local qui contiendra les variables spécifiques à notre environnement.
Pour la base de données, Infomaniak utilise MariaDB et vous pouvez créer une base depuis votre espace d'Administration. Je vous conseille de créer un utilisateur en même temps pour limiter l'accès à cette base de données seulement.
APP_ENV=prod
APP_SECRET=descaracteresaleatoireici
DATABASE_URL="mysql://user:pass@user.myd.infomaniak.com:3306/database?serverVersion=10.4.25-MariaDB&charset=utf8mb4"
MAILER_DSN=native://default
Une fois l'environnement modifié, on pourra lancer l'installation des dépendances.
/opt/php8.2/bin/composer install --no-dev --optimize-autoloader
/opt/php8.2/bin/composer dump-env prod
/opt/php8.2/bin/php bin/console importmap:install
/opt/php8.2/bin/php bin/console asset-map:compile
/opt/php8.2/bin/php bin/console doctrine:migrations:migrate
APP_ENV=prod APP_DEBUG=0 /opt/php8.2/bin/php bin/console cache:clear
Configuration de l'hébergement :
Maintenant que nos sources sont en ligne, il va falloir configurer l'hébergeur afin de lui dire comment résoudre notre nom de domaines. Pour Infomaniak, cela se passe dans la partie Mes sites > Gestion des sites > Ajouter un site. Sélectionnez l'option "Continuez sans installer d'outil". Ensuite, vous pouvez sélectionner le domaine (ou le sous-domaine) à associer à ce nouveau site et cliquer sur continuer pour finaliser la création du site.
Une fois le site créé, vous devrez faire plusieurs changements :
-
Modifier la version de PHP pour correspondre au besoin de Symfony (version 8.2 minimum) et activer des extensions supplémentaires si nécessaire.
-
Installer le certificat avec Let's encrypt (en un clic).
-
Aller dans les Paramètres avancés pour modifier le dossier du site et pointer vers le dossier public de Symfony /sites/monsite.tld/public.
-
Dans les Paramètres avancés puis dans l'onglet Apache, vous pourrez activer la compression des fichiers mais aussi gérer des variables d'environnement que vous souhaitez ajouter à votre application Symfony.
À partir de cette étape, votre site devrait fonctionner et être visible lorsque vous tapez votre nom de domaine.
Automatiser avec Make :
Pour automatiser le processus de déploiement d'une nouvelle version, il est posssible d'utiliser un fichier Makefile.
server := "user@mondomain.ltd"
domain := "mondomain.ltd"
.PHONY: install deploy
deploy:
ssh -A $(server)$ 'cd sites/$(domain) && git pull origin main && make install'
install: vendor/autoload.php
/opt/php8.2/bin/php bin/console d:m:m -n
/opt/php8.2/bin/composer dump-env prod
/opt/php8.2/bin/php bin/console cache:clear
vendor/autoload.php: composer.json composer.lock
/opt/php8.2/bin/composer install --no-dev --optimize-autoloader
touch vendor/autoload.php
Si vous souhaitez déployer une mise à jour, il vous suffira de taper la commande make deploy.
Dans le tutoriel suivant, nous aborderons Laravel, un autre framework PHP populaire pour le développement web.