Symfony et Laravel :

Pour ceux qui utilisent Visual Studio Code, vous devez installer les extensions "PHP Namespace Resolver" et "PHP Docblocker" pour PHP mais également "PHP Intelephense" et "PHP Getters & Setters" si besoin.

Le choix entre Symfony et Laravel :

Ce choix dépend de plusieurs facteurs, notamment vos besoins spécifiques, vos compétences en programmation, la taille du projet et la communauté de soutien. Les deux frameworks sont populaires et largement utilisés dans le développement web en PHP, mais ils ont des approches et des fonctionnalités différentes. Voici un bref aperçu de chacun :

Symfony :

  • Symfony est un framework PHP mature et robuste, qui suit les principes de développement SOLID et encourage une approche modulaire pour la construction d'application web.

  • Il est hautement personnalisable et modulaire, vous permettant de choisir les composants dont vous avez besoin pour votre projet spécifique.

  • Symfony est connu pour sa stabilité, sa documentaion détaillée et sa grande communauté de développeurs. Il est souvent préféré pour les projets d'entreprise de grande envergure.

  • Il met l'accent sur la réutilisation du code grâce à ses composants réutilisables, ce qui peut accélérer le processus de développement.

  • Cependant, il peyt être plus verbeux que Laravel, nécessitant parfois plus de code pour accomplir certaines tâches.

Laravel :

  • Laravel est également un framework PHP populaire qui se concentre sur la simplicité et l'élégance du code. Il suit le modèle MVC (Modèle-Vue-Contrôleur) et propose une syntaxe expressive et concise.

  • Il est livré avec de nombreuses fonctionnalités intégrées, telles que l'ORM Eloquent, la gestion des migrations de base de données, le système de routage convivial, l'intégration de la messagerie et bien plus encore.

  • Laravel est souvent choisi pour les projets de taille moyenne à grande, mais il est également adapté aux petites applications en raison de sa courbe d'apprentissage douce.

  • Il dispose d'une communauté active et de documentation complète, ce qui facilite la résolution des problèmes et l'apprentissage du framework.

  • Cependant, en raison de son approche "magique", il peut être moins prévisible et modulaire que Symfony.

En fin de compte, le choix entre Symfony et Laravel dépend de vos préférences personnelles, de votre expérience en développement PHP et des besoins spécifiques de votre projet. Si vous appréciez la modularité, la personnalisation et la réutilisation du code, Symfont pourrait être un bon choix. Si vous recherchez la simplicité, l'efficacité et la rapidité de développement, Laravel pourrait mieux vous convenir.

Il est recommandé de prendre le temps de tester les deux frameworks, de lire leur documentation, de consulter des tutoriels (dans la suite, j'introduis chacun d'eux) et de participer aux communautés pour vous faire une idée plus précise de leurs avantages et de leurs inconvénients. Quel que soit votre choix, les deux frameworks offrent d'excellentes solutions pour le développement web en PHP.

Le développement SOLID :

Le développement SOLID est un ensemble de principes fondamentaux de conception de logiciels qui visent à créer des systèmes logiciels flexibles, maintenables et évolutifs. Le terme SOLID est un acronyme qui représente les cinq principes individuels qui le composent. Ces principes ont été formulés par Robert C. Martin (également connu sous le nom de "Uncle Bob") et sont largement utilisés dans le développement logiciel orienté objet.

Voici une brève description de chaque principe SOLID :

  1. Principe de Responsabilité Unique (Single Responsibility Principle - SRP) :

    Ce principe stipule qu'une classe (ou un module) ne devrait avoir qu'une seule raison de changer. En d'autres termes, une classe ne doit être responsable que d'une seule fonctionnalité ou tâche spécifique. Cela favorise la sépération des responsabilités et permet de rendre les classes plus cohérentes et plus facilement modifiables.

  2. Principe Ouvert/Fermé (Open/Closed Principle - OCP) :

    Le principe OCP indique qu'une classe doit être ouverte à l'extension (ajout de nouvelles fonctionnalités) mais fermée à la modification (le comportement existant ne doit être modifié). Plutôt que de modifier une classe existante, vous devriez étendre ses fonctionnalités en créant de nouvelles classes qui héritent des fonctionnalités de la classe de base.

  3. Principe de Substitution de Liskov (Liskov Substitution Principle - LSP) :

    Ce principe souligne que les objets d'une classe dérivée doivent pouvoir être substitués aux objets de la classe de base sans affecter la cohérence du programme. En d'autres termes, une classe dérivée doit être en mesure de remplacer sa classe de base sans provoquer d'effets indésirables ou d'erreurs.

  4. Principe de Ségrégation des Interfaces (Interface Segregation Principle - ISP) :

    L'ISP suggère que les interfaces clients ne devraient pas être forcées de dépendre des méthodes qu'elles n'utilisent pas. Plutôt que de créer de grandes interfaces contenant de nombreuses méthodes, vous devriez diviser les interfaces en petites interfaces spécifiques à chaque client.

  5. Principe d'Inversion des Dépendances (Dependency Inversion Principle - DIP) :

    Le DIP met l'accent sur le fait que les modules de haut niveau ne doivent pas dépendre des modules de bas niveau, mais des abstractions. Cela signifie que les détails d'implémentation doivent dépendre des abstractions plutôt que l'inverse. En utilisant l'inversion des dépendances, vous pouvez rendre vos applications plus flexibles et plus faciles à étendre.

En suivant ces principes SOLID, les développeurs peuvent créer des codes plus robustes, éviter les dépendances rigides et faciliter la maintenance et l'évolution des applications. Les principes SOLID sont largement considérés comme les meilleures pratiques de conception orientée objet et sont utilisés pour créer des logiciels de haute quantité et faciles à maintenir.

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é.

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.

  1. Ouvrez une ligne de commande ou un terminal.

  2. Installez Symfony en utilisant Composer avec la commande suivante :

    composer create-project symfony/website-skeleton 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.

  3. Changez de répertoire vers votre nouveau projet :

    cd nom_du_projet

    Dans un projet Symfony typique, vous trouverez généralement les dossiers suivants à la racine du projet :

    1. bin/ : Contient des scripts exécutables, tels que console, qui est utilisé pour exécuté des commandes Symfony en ligne de commande.

    2. config/ : Contient les fichiers de configuration de l'application, tels que config.yaml (ou config.yml), services.yaml, routes.yaml, etc.

    3. 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.

    4. 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.

    5. 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.

    6. templates/ : Contient les fichiers de templates Twig utilisés pour générer les vues de l'application.

    7. tests/ : Contient les tests unitaires et fonctionnels de l'application.

    8. translations/ : Contient les fichiers de traduction de l'application.

    9. var/ : Contient les fichiers générés par l'application, tels que les caches, les logs, les sessions, etc.

    10. vendor/ : Contient les dépendances installées via Composer, le gestionnaire de dépendances de PHP.

    11. .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.

    12. composer.json et composer.lock : Fichiers utilisés par Composer pour gérer les dépendances du projet.

    13. 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

Étape 3 : Création d'une route et d'un contrôleur :

  1. Créez le contrôleur nommé DefaultController en utilisant la commande suivante :

    php bin/console make:controller DefaultController

    Ou encore on utilise la syntaxe suivante qui fait la même chose :

    symfony console make:controller DefaultController
  2. 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',
                ]);
            }
        }
    
    ?>
  3. 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) :

  1. 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).

  2. 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>
  3. 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 %}
  4. Vous pouvez également modifier votre template de base pour intégrer Bootstrap : dans le block stylesheets, on met le lien CDN CSS de Bootstrap et, dans le block javascripts, le lien CDN JS de Bootstrap.

  5. 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.

Étape 5 : Définir la route :

  1. Ouvrez le fichier `config/routes.yaml` avec votre éditeur de code.

  2. Définissez une nouvelle route pour accéder à la page d'accueil :

    # Ajoutez cette ligne au fichier routes.yaml
    homepage:
        path: /
        controller: App\Controller\DefaultController::homepage

    En Symfony 7 et plus, on a plus besoin de la définir car elle est directement écrit dans le contrôleur.

É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 : Notre première entité :

Dans le fichier .env, on va modifier la variable DATABASE_URL pour que ça correspond bien à notre base de données. Sa syntaxe sera :

DATABASE_URL="mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=5.7"

On doit remplacer "db_user", "db_password" et "db_name" par les valeurs que l'on a définies. 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 :

php bin/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.

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, il demande 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.

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, 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

Dans cette migration, on a du SQL pur et dur. 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.

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. 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;
        }

    }

?>

Étape 8 : Création des Fixtures :

Dans la suite de ce tutoriel, on va créer des fixtures qui permettent de nous générer un jeu de fausses données comme un Faker.

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 :

php bin/console d:f:l

Étape 9 : 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 la pagination dans la page, on va utiliser le bundle "KnpPaginatorBundle" en l'instalalnt 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/sortable_link.html.twig' # sort link template
        filtration: '@KnpPaginator/Pagination/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    
    );
    // dd($ingredients);

    return $this->render('pages/ingredient/index.html.twig', [
        'ingredients' => $ingredients
    ]);
}

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.

Créer un nouvel ingrédient :

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,
            ]);
        }
    }

?>

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()
    ]);
}

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 %}

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 %}

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()
    ]);
}

Ç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>

Étape 10 : 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.

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.

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 setUpdatedAtValue()
        {
            $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 :

php bin/console d:d:d --force

Avant de la recréer avec la commande :

php bin/console d:d:c

Afin de créer une nouvelle migration avec la commande :

php bin/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 :

php bin/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.

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
        ])
        ->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');
}

Étape 11 : 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 bundle "Security" déjà installé 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 classe 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;
        }
    }

?>

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 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 12 : 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

Étape 13 : 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 14 : É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 15 : 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 16 : 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 17 : 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 18 : 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 19 : Upload une image :

Pour uploader une image pour chaque recette, on va d'abord utiliser le bundle "VichUploaderBundle" 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')]
    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): void
    {
        $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();
        }
    }

    public function getImageFile(): ?File
    {
        return $this->imageFile;
    }

    public function setImageName(?string $imageName): void
    {
        $this->imageName = $imageName;
    }

    ...
    
}

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') }}">
</div>

Étape 20 : Formulaire de contact, email et reCaptcha v3 :

Cette fonctionnalité consiste en l'implémentation d'un formulaire de contact classique.

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);
}

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`. 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

Pour avoir une configuration SMTP, on peut utiliser l'outil "mailtrap". 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',
])

Étape 21 : Les services :

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.

Pour visualiser tous les services présents au sein de Symfony, 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

Ce qui est aussi 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 22 : 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 22 : 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()->('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()->('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()->('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()->('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 Symfont\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 23 : 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 24 : 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 25 : 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 26 : Les Event Listener & Event Subscriber :

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.

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());
        }
    }

?>

Étape 27 : 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

On va ensuite créer une page d'authentification avec la commande :

symfony console make:auth

On va donc créer un login form authenticator dans une classe "AppAuthenticator". On peut également créer une page d'inscription avec la commande :

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".

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 28 : 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 :

  • Dans le fichier `.env` `DEMO=`

  • Dans le fichier `.env.local` `DEMO="Ceci est une démo"`

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 :

  • config/secrets/dev/dev.decrypt.private.php

  • config/secrets/dev/dev.decrypt.public.php

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 :

  • config/secrets/prod/prod.decrypt.private.php

  • config/secrets/prod/prod.decrypt.public.php

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

Dans le tutoriel suivant, nous aborderons Laravel, un autre framework PHP populaire pour le développement web.

Introduction au Framework PHP Laravel :

Dans ce tutoriel, nous allons vous guider à travers les étapes pour commencer à utiliser le framework PHP Laravel. Laravel est un framework web élégant et puissant qui facilite le développement rapide d'applications web modernes.

Étape 1 : Installation de Laravel :

Avant de commencer, assurez-vous d'avoir PHP et le grestionnaire de dépendances Composer installés sur votre système.

  1. Ouvrez une ligne de commande ou un terminal.

  2. Installez Laravel en utilisant Composer avec la commande suivante :

    composer create-project --prefer-dist laravel/laravel nom_du_projet

    Remplacez "nom_du_projet" par le nom que vous souhaitez donner à votre projet.

  3. Changez de répertoire vers votre nouveau projet :

    cd nom_du_projet

Étape 2 : Lancer le serveur web local :

Laravel propose un serveur de développement pour faciliter le test de votre application en local. Pour le lancer, utilisez la commande suivante :

php artisan serve

Étape 3 : Création d'une route et d'un contrôleur :

  1. Ouvre le fichier `routes/web.php` avec votre éditeur de code préféré.

  2. Définissez une nouvelle route pour gérer une page d'accueil :

    // Ajoutez cette ligne au fichier web.php
    Route::get('/', 'App\Http\Controllers\HomeController@index');
  3. Créez un nouveau contrôleur pour gérer cette route :

    php artisan make:controller HomeController

Étape 4 : Implémenter la logique du contrôleur :

  1. Ouvrez le fihcier `app/Http/Controllers/HomeController.php` avec votre éditeur de code.

  2. Implémentez la logique du contôleur pour retourner la vue (template) :

    // Ajoutez cette méthode à la classe HomeController
    public function index() {
        return view('homepage');
    }

Étape 5 : Création de la vue (template) :

  1. Créez un nouveau fichier `ressources/views/homepage.blade.php` dans le répertoire du projet.

  2. Ajoutez du contenu HTML à votre template :

    <!DOCTYPE html>
    <html lang="fr">
        <head>
            <title>Mon Projet Laravel</title>
        </head>
        <body>
            <h1>Bienvenue sur Mon Projet Laravel !</h1>
        </body>
    </html>

É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 Laravel !" sur la page d'accueil.

Félicitations ! Vous avez maintenant créé une application web simple en utilisant Laravel. Laravel propose de nombreuses fonctionnalités pratiques telles que la gestion des migrations de base de données, l'ORM Eloquent, les formulaires, l'authentification et bien olus encore.

Vous pouvez continuer à explorer la documentation officielle de Laravel pour découvrir davantage de fonctionnalités et d'outils puissants offerts par ce framework. Laravel dispose également d'une communauté active qui partage de nombreux packages et ressources pour faciliter le développement de vos projets web.