Symfony 7 · Révision

QCM : 0 bonnes réponses sur 0 répondues (0 au total)

Cours unsiteavous/symfony7 · cours 1 à 15 · activités 1 à 9 · projet Stages

Tout le cours Symfony, appliqué à un vrai projet.

Chaque chapitre est résumé, illustré par le code du cours, puis relié à ce qui a été fait dans Stages, le gestionnaire de recherche de stage. Un QCM corrigé termine chaque fiche.

Sommaire

Partie 1

Le cours, chapitre par chapitre

Pour chaque fiche : ce qu'il faut retenir, le code type du cours (Film, Catégorie, Classification), puis un encadré « Dans Stages ». Quand une explication dépasse ce que dit le support, elle est marquée hors support.

Introduction

Pourquoi un framework, et quoi installer

Readme du dépôt et guide d'installation Windows

À retenir

  • Un MVC fait main apporte la SOC (séparation des responsabilités), la maintenabilité et le travail en équipe, mais il est long à réécrire : on finissait par copier-coller les mêmes fichiers. Symfony automatise cette architecture par des lignes de commande.
  • Un framework accélère les grosses applications ; pour une très petite, il est lourd et lent.

« Il est inutile d'invoquer l'artillerie lourde pour envoyer une fléchette. »

Installer

OutilCommande
PHP 8.2 minimumphp -v
Composergetcomposer.org
Scoop (Windows)scoop.sh
Symfony CLIscoop install symfony-cli
Vérificationsymfony check:requirements

À l'IUT, sans droits administrateur : PHP copié dans le dossier utilisateur avec un php-perso.ini (extensions curl et openssl activées), proxy déclaré dans chaque terminal (set HTTP_PROXY=..., set HTTPS_PROXY=...), composer.phar téléchargé avec curl, puis composer create-project symfony/skeleton et serveur php -S localhost:8000 -t public ou symfony serve --no-tls.

Le vocabulaire

Bundlepaquet qui ajoute une fonctionnalité (Twig, Doctrine, Security, Maker…)
Makerles générateurs make:...
Contrôleurclasse dont les méthodes répondent aux routes
Entitéclasse PHP qui représente une table
Repositoryclasse qui lit les entités en base
EntityManagerservice qui écrit : persist, flush, remove
Migrationfichier qui contient le SQL d'évolution de la base
FormTypeclasse qui décrit un formulaire
Fixtureclasse qui remplit la base de fausses données
Firewallpartie de security.yaml qui identifie l'utilisateur

Vérifiez-vous

D'après le cours, quand un framework est-il superflu ?

Quelle commande vérifie que la machine est prête pour Symfony ?

Cours 1

Nouveau projet, routes et réponses

symfony new, console, serveur, routes YAML ou attributs, Request, debug:router

Créer et lancer

symfony new --webapp NomDossier   # application complète avec vues
symfony new NomDossier            # squelette minimal : API, micro-service
symfony console                   # liste des commandes
symfony server:start -d           # serveur local en arrière-plan
symfony server:ca:install         # certificat HTTPS au premier lancement

L'arborescence

public/index.php, la porte d'entrée de toutes les requêtes
src/le code : contrôleurs, entités, formulaires, repositories
templates/les vues Twig
config/la configuration YAML
migrations/les migrations (en 7.4 : src/Migrations/)
assets/CSS, JS, images
bin/, var/, vendor/console ; cache et logs ; bibliothèques Composer
.envla configuration de l'environnement (base de données…)

Une route, deux écritures

symfony console make:controller HomeController crée le contrôleur et templates/home/index.html.twig. On peut ensuite déclarer la route dans config/routes.yaml :

home:
    path: /home
    controller: App\Controller\HomeController::index

ou, comme le génère Maker, en attribut au-dessus de la méthode :

class HomeController extends AbstractController
{
    #[Route('/home/{prenom}', name: 'app_home')]
    public function index($prenom = "inconnu"): Response
    {
        return $this->render('home/index.html.twig', ['prenom' => $prenom]);
    }
}
  • render() vient de la classe parente AbstractController.
  • $request->query->get('prenom') équivaut à $_GET['prenom'] ; {prenom} lit un morceau de l'URL.
  • Laravel, lui, garde un fichier de routes séparé.
  • symfony console debug:router liste toutes les routes.

Trois réponses possibles

return new Response("Bonjour $prenom !");
return $this->render('home/index.html.twig', ['prenom' => $prenom]);
return new JsonResponse(['prenom' => $prenom, 'status' => 'OK']);

Vérifiez-vous

Par quel fichier passent toutes les requêtes ?

Que crée symfony new nom sans --webapp ?

Quelle commande affiche toutes les routes ?

Cours 2

Twig

Avec l'activité 1 : extends, block, include, set, filtres, path et url

À retenir

« Les vues affichent : aucun calcul ou autre traitement n'est fait dans une vue. »

  • {{ ... }} affiche, {% ... %} exécute (if, for, set, block), {# ... #} commente.
  • L'héritage remplace le grand switch du MVC fait main : le contrôleur appelle directement la page finale, qui déclare quel parent elle remplit. C'est « la méthode include à l'envers ».
{# base.html.twig #}
<body>
  {% block body %}Texte par défaut{% endblock %}
</body>

{# home/index.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}Contenu qui remplit le bloc du parent{% endblock %}

Activité 1 : la boîte à outils

{% extends 'base.html.twig' %}
{% block title 'Activité twig' %}
{% block body %}
    {{ include('includes/header.html.twig') }}
    {% set NomComplet = user.prenom ~ ' ' ~ user.nom %}
    <h1>Bonjour {{ NomComplet }}</h1>
    {% if user.activated %}<p>✅ Compte activé</p>{% endif %}
    <p>{{ user.createdAt|date('d m Y') }}</p>
    {% for key, value in user %} ... {% endfor %}
    {{ user.slogan|raw }}
{% endblock %}
set, ~créer une variable, concaténer
|date('d/m/Y')formater une date
|format_datetimedate en toutes lettres en français ; une commande Composer est nécessaire (hors support : paquet twig/intl-extra)
|rawaffiche le HTML sans l'échapper : dangereux si le contenu vient d'un utilisateur (XSS)
path() / url()URL relative d'une route interne / URL absolue
route courante{% if app.current_route == 'app_home' %} : afficher un lien selon la page

Par défaut Twig échappe tout : <script> s'affiche comme du texte.

Vérifiez-vous

Que signifie {% extends 'base.html.twig' %} ?

Quel filtre affiche du HTML sans l'échapper ?

Quelle différence entre path() et url() ?

Cours 3

Doctrine

Avec les activités 2 et 3 : ORM, .env, entités, relations, migrations, repositories, injection

Un ORM

Doctrine se place entre le code et la base, et lui parle à notre place.

AvantagesLimites
moins de code, plus de SQL à écrirerequêtes complexes (jointures) difficiles
changer de SGBD sans changer le codeparfois plus lent qu'une requête simple

Les commandes, dans l'ordre

# .env : commenter PostgreSQL, décommenter et compléter MySQL
DATABASE_URL="mysql://user:pass@127.0.0.1:3306/base?serverVersion=8.0.32&charset=utf8mb4"

symfony console make:entity            # crée l'entité et son repository
symfony console make:entity Film       # relancer avec le même nom = compléter
symfony console make:migration         # écrit le SQL dans un fichier
symfony console doctrine:migrations:migrate   # ou d:m:m : l'exécute
  • L'entité contient propriétés, getters, setters et des attributs de mapping #[ORM\Column(...)] à affiner.
  • Le repository paraît vide mais hérite de ServiceEntityRepository → EntityRepository : find($id), findAll(), findBy([critères], [tri], limite), findOneBy(), count().
  • Les migrations s'exécutent dans l'ordre chronologique ; on peut revenir en arrière.

Les relations (activité 2 : Film, Catégorie, Classification)

TypeExempleEn base
ManyToOneplusieurs films → une classificationclé étrangère classification_id
OneToManyune classification → ses filmsl'autre côté du ManyToOne
ManyToManyfilms ↔ catégoriestable intermédiaire
OneToOneun objet ↔ un seul autreclé étrangère unique

Lire dans un contrôleur

public function index(CategorieRepository $categorieRepository): Response
{
    return $this->render('categorie/index.html.twig', [
        'categories' => $categorieRepository->findAll(),
    ]);
}

#[Route('/categorie/{id}', name: 'app_categorie_show', methods: ['GET'])]
public function show(Categorie $categorie): Response
{
    return $this->render('categorie/show.html.twig', ['categorie' => $categorie]);
}

Injection de dépendances : on demande un objet entre les parenthèses, sans new, et Symfony le fournit. Dans show, Symfony fait lui-même le find($id) (ou cherche par une autre propriété unique, comme {nom}) et répond 404 s'il ne trouve rien.

Activité 3 : FilmController, liste à l'URL films, un lien par titre avec path('app_film_show', {'id': film.id}), une vue show, puis la même chose pour les catégories et les classifications.

Vérifiez-vous

Que fait make:migration ?

Avec la route /categorie/{id} et show(Categorie $categorie), que se passe-t-il ?

Un film a plusieurs catégories, une catégorie a plusieurs films. Quelle relation ?

D'où viennent findBy() et count() dans un repository vide ?

Cours 4

Requêtes personnalisées : le QueryBuilder

Avec l'activité 4

Pour « les films de moins d'une heure », les méthodes toutes prêtes ne suffisent pas. Écrire du SQL ferait perdre la portabilité : le QueryBuilder construit la requête en PHP et Doctrine la traduit. On l'écrit dans le repository.

public function findFilmWhereDurationLowerThan(\DateTime $duree): array
{
    return $this->createQueryBuilder('film')
        ->where('film.duree <= :duree')
        ->orderBy('film.duree', 'ASC')
        ->setMaxResults(10)
        ->setParameter('duree', $duree)
        ->getQuery()
        ->getResult();
}
  • 'film' est un alias de la table.
  • setParameter() fonctionne comme bindParam en PDO : protection contre l'injection SQL.
  • Route d'appel : #[Route('films/tri-par-duree-{duree}')], les minutes sont converties en DateTime avant l'appel.
  • Activité 4 : films plus récents / de / plus vieux qu'une année, puis une seule méthode qui reçoit l'action en paramètre.

Vérifiez-vous

Où écrit-on une méthode avec un QueryBuilder ?

Pourquoi passer les valeurs par setParameter() ?

Cours 5

Formulaires et EntityManager

make:form, createForm, form(), CSRF, handleRequest, persist, flush, remove

Créer, afficher

symfony console make:form      # src/Form/FilmType.php, un champ par propriété

$form = $this->createForm(FilmType::class);          # création
$form = $this->createForm(FilmType::class, $film);   # modification

{{ form(form) }}                                     # dans Twig
  • Le <form> produit est en POST, sans action : il revient sur la même route.
  • Les relations apparaissent par leur id : on corrige avec 'choice_label' => 'nom'.
  • Un champ caché avec un jeton protège de la faille CSRF.
  • Style sans effort : form_themes: ['bootstrap_5_layout.html.twig'] dans config/packages/twig.yaml.
  • Bouton : ->add('submit', SubmitType::class, ['label' => 'Enregistrer']), ou form_start / form_rest / form_end et un bouton écrit à la main.

Traiter et enregistrer

public function update(Film $film, Request $request, EntityManagerInterface $em): Response
{
    $form = $this->createForm(FilmType::class, $film);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $em->flush();
        return $this->redirectToRoute('app_show_film', ['id' => $film->getId()]);
    }

    return $this->render('film/edit.html.twig', ['film' => $film, 'form' => $form]);
}
CasCode
objet venu de la base, modifié$em->flush();
nouvel objet$em->persist($film); $em->flush();
suppression$em->remove($film); $em->flush();
raccourci$em->getRepository(Film::class)->find($id);

Formulaire soumis mais invalide : code 422. dd() = dump and die. On n'instancie jamais l'EntityManager : on le reçoit par injection.

Vérifiez-vous

On crée un new Company(). Que faut-il pour l'enregistrer ?

À quoi sert le champ caché _token d'un formulaire Symfony ?

Quelle option affiche le nom d'une entité liée au lieu de son id ?

Cours 6

Redirections, messages flash, UX Turbo

redirectToRoute, addFlash, app.flashes, turbo-frame, turbo-stream

« Les messages sont indispensables » : l'utilisateur doit savoir si son action a réussi.

return $this->redirectToRoute('app_show_film', ['id' => $film->getId()]);

$this->addFlash('success', 'Le film a bien été modifié');

{% for message in app.flashes('success') %}
    <div class="flash-success">{{ message }}</div>
{% endfor %}
  • redirectToRoute prend le nom de la route et ses paramètres (plus de header()).
  • Le flash est stocké en session, lu une seule fois après la redirection : un message « à usage unique ».

Pour aller plus loin : UX Turbo

Installé par défaut depuis Symfony 6.2, il remplace les rechargements de page par des fetch (vers la SPA). Si Turbo échoue, la requête normale prend le relais : on garde donc les routes classiques. Les fetch à la main restent possibles.

<turbo-frame id="success_message"></turbo-frame>

<turbo-stream action="replace" target="success_message">
    <template><div>Le film "{{ film.titre }}" a été créé !</div></template>
</turbo-stream>

if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
    $request->setRequestFormat(TurboBundle::STREAM_FORMAT);
    return $this->render('film/success.html.twig', ['film' => $film]);
}

Actions : append, prepend, replace, remove, refresh. Le target est l'id du turbo-frame. Plusieurs streams = plusieurs zones modifiées d'un coup.

Vérifiez-vous

Que reçoit redirectToRoute() ?

Où est conservé un message flash entre l'action et la page suivante ?

À quoi doit correspondre le target d'un turbo-stream ?

Cours 7

Validation des données

Avec l'activité 5 : contraintes, UniqueEntity, NotBlank, empty_data, make:validator

isValid() ne devine rien : on pose des contraintes sur les propriétés de l'entité.

use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

#[UniqueEntity(fields: 'titre', message: 'Ce titre est déjà utilisé')]
class Film
{
    #[Assert\NotBlank(message: 'Le titre ne peut pas être vide.')]
    #[Assert\Length(min: 5, max: 255, maxMessage: 'Le titre doit faire au plus 255 caractères')]
    #[ORM\Column(length: 255)]
    private ?string $titre = null;

    #[Assert\Positive(message: 'La quantité doit être supérieure à 0.')]
    #[ORM\Column]
    private ?int $quantite = null;
}
  • UniqueEntity se met sur la classe : l'unicité est gérée par Doctrine (il faut interroger la base), les champs par le Validator.
  • NotNull refuse seulement null ; NotBlank refuse aussi '' et les tableaux vides.

Bonus : symfony console make:validator crée une contrainte maison en deux fichiers (la contrainte BlocSpam et son BlocSpamValidator, qui appelle buildViolation()->addViolation()).

Activité 5 : CRUD complet des films et classifications « à la main » : commandes, routes, vues, addFlash, EntityManager, contraintes, empty_data.

Vérifiez-vous

Quelle contrainte refuse une chaîne vide ?

Pourquoi 'empty_data' => '' sur un champ obligatoire ?

Où se place #[UniqueEntity] ?

Cours 8

make:crud

Avec l'activité 6 : générer, puis retoucher

symfony console make:entity
symfony console make:migration
symfony console d:m:m
symfony console make:crud           # la commande « magique »
Fichier généréContenu
FilmController.phpindex, new, show, edit, delete
FilmType.phple formulaire
index, show, new, editles pages
_form.html.twigformulaire partagé entre new et edit
_delete_form.html.twigbouton de suppression avec jeton CSRF

« Ce n'est pas parce que le code est généré qu'il est parfait du premier coup. » Activité 6 : choice_label, champ rendu facultatif (required => false), champ inutile supprimé, suppression passée de POST à DELETE, contraintes ajoutées.

Le support demande le DELETE sans donner la technique (hors support) : un formulaire HTML ne sait faire que GET et POST, donc on active http_method_override: true dans framework.yaml et on ajoute un champ caché _method.

<form method="post" action="{{ path('app_company_delete', {'id': company.id}) }}">
    <input type="hidden" name="_method" value="DELETE">
    <input type="hidden" name="_token" value="{{ csrf_token('delete' ~ company.id) }}">
    <button>Supprimer</button>
</form>

Vérifiez-vous

Comment un formulaire HTML déclenche-t-il une route methods: ['DELETE'] ?

À quoi sert _form.html.twig ?

Cours 9

Retours JSON et API

Avec l'activité 7 : json(), sérialisation, groupes, deserialize, validation, MapRequestPayload

Envoyer

Les contrôleurs d'API vont dans un dossier à part (src/Controller/API/). json() remplace render() et sérialise les données.

#[Route(path: '/api/films')]
class FilmController extends AbstractController
{
    #[Route(path: '/')]
    public function index(FilmRepository $filmRepository): JsonResponse
    {
        return $this->json($filmRepository->findAll(), 200, [], ['groups' => 'api_film_index']);
    }
}

use Symfony\Component\Serializer\Annotation\Groups;

#[Groups(['api_film_index'])]
private ?string $titre = null;
  • Sans groupes : référence circulaire (film → classification → films → …).
  • Seuls les champs du groupe sortent : on ne le met jamais sur password.
  • Pour une relation, mettre le groupe sur le getter, et dans l'entité liée seulement sur les champs voulus.

Recevoir

$categorie = $serializer->deserialize($request->getContent(), Categorie::class, 'json', ['groups' => 'api_categorie_index']);
$errors = $validator->validate($categorie);
if ($errors->count()) {
    return $this->json($messages, Response::HTTP_UNPROCESSABLE_ENTITY);
}
$em->persist($categorie);
$em->flush();
$url = $urlGenerator->generate('app_categories_show', ['id' => $categorie->getId()], UrlGeneratorInterface::ABSOLUTE_URL);
return $this->json($categorie, Response::HTTP_CREATED, ['Location' => $url]);
  • Codes : 200 lecture, 201 création (+ en-tête Location), 422 données invalides.
  • #[MapRequestPayload(serializationContext: ['groups' => [...]])] fait désérialisation + filtre + validation en une fois.
  • Objets liés : json_decode, puis find() des objets existants (sinon le Serializer en créerait de nouveaux).
  • Activité 7 : find, update, delete, filtre par durée, tests avec Postman.

Vérifiez-vous

Comment le cours résout-il l'erreur de référence circulaire de json($films) ?

Quel code HTTP renvoyer après une création réussie ?

Comment Stages empêche-t-il le mot de passe d'apparaître dans /admin/api/users ?

Cours 10

Authentification et rôles

Avec l'activité 8 : make:user, make:auth, make:registration-form, access_control, IsGranted, is_granted

symfony console make:user
symfony console make:migration
symfony console d:m:m
symfony console make:auth
php bin/console make:registration-form
  • make:user fait make:entity « en mieux » pour un utilisateur ; on modifie ensuite avec make:entity User.
  • Il reste des TODO dans les fichiers générés : tout n'est pas fini.

Trois endroits pour contrôler l'accès

# 1. config/packages/security.yaml : un ensemble de chemins
access_control:
    - { path: '/login', roles: ['PUBLIC_ACCESS'] }
    - { path: '/admin', roles: ['ROLE_ADMIN'] }

# 2. Contrôleur : au plus près de la route (méthode préférée du cours)
#[IsGranted('ROLE_ADMIN', statusCode: 403, message: "Vous n'avez pas les droits pour accéder à cette page")]

# 3. Twig : afficher ou cacher (ne protège rien)
{% if is_granted('ROLE_ADMIN') %}<a href="...">Delete</a>{% endif %}
ArgumentQui passe
ROLE_USERtout utilisateur connecté (rôle par défaut)
ROLE_ADMIN, ROLE_...les utilisateurs qui ont ce rôle
IS_AUTHENTICATED_FULLYconnecté avec ses identifiants dans cette session
IS_AUTHENTICATED_REMEMBEREDconnecté, y compris par cookie « se souvenir de moi »
PUBLIC_ACCESStout le monde

Le tableau du cours cite aussi OWNER, VIEW, EDIT… sans montrer comment les mettre en place.

Vérifiez-vous

Lequel de ces mécanismes ne protège pas une route ?

Pourquoi le professeur préfère-t-il #[IsGranted] ?

Alice (ROLE_USER) ouvre /admin. Que se passe-t-il ?

Cours 11

Envoyer des mails

MAILER_DSN et messenger.yaml

MAILER_DSN=smtp://localhost:1025                                                 # maildev
MAILER_DSN=smtp://adresse:mot%20De%20Passe%20Application@smtp.gmail.com:587      # Gmail
MAILER_DSN=native://default                                                      # déconseillé

# config/packages/messenger.yaml : commenter
# Symfony\Component\Mailer\Messenger\SendEmailMessage: async

Avec Gmail, il faut un mot de passe d'application dont les espaces deviennent %20. La ligne async met les mails en file d'attente : on la commente pour qu'ils partent tout de suite.

Vérifiez-vous

Quelle variable du .env configure l'envoi des e-mails ?

Cours 12

Fixtures

Avec l'activité 9 : orm-fixtures, Faker, références, dépendances

composer require --dev orm-fixtures
composer require --dev fakerphp/faker

symfony console doctrine:fixtures:load            # VIDE la base puis la remplit
symfony console doctrine:fixtures:load --append   # ajoute sans vider
  • --dev : paquets absents en production.
  • Plusieurs fichiers plutôt qu'un seul immense.
  • Factory::create('fr_FR') ; $this->faker->unique()->sentence() pour un champ unique.
class FilmFixtures extends Fixture implements DependentFixtureInterface
{
    public function load(ObjectManager $manager): void
    {
        for ($i = 0; $i < 10; $i++) {
            $film = new Film;
            $film->setTitre($this->faker->unique()->sentence())
                ->setClassification($this->getReference(
                    $this->faker->randomElement(ClassificationFixtures::getClassificationsArray())
                ));
            $manager->persist($film);
        }
        $manager->flush();
    }

    public function getDependencies(): array
    {
        return [ClassificationFixtures::class];
    }
}

La fixture parente fait $this->addReference($classification->getIntitule(), $classification). Une interface oblige à écrire certaines méthodes (ici getDependencies()). Activité 9 : un utilisateur en dur au mot de passe connu, et 10 utilisateurs Faker.

Vérifiez-vous

Que fait doctrine:fixtures:load sans option ?

Comment garantir que les utilisateurs sont créés avant les entreprises ?

Cours 13

Mise en production

CORS, apache-pack, dump-env, fichiers à copier, cache, sous-dossier

  1. CORS : Postman semble tout permettre, mais un fetch depuis un autre site est bloqué. composer require nelmio/cors-bundle, puis allow_origin dans nelmio_cors.yaml.
  2. WAMP : virtualhost vers public/ ; composer require symfony/apache-pack ajoute le .htaccess.
  3. Front : npm run build (webpack) ou symfony console asset-map:compile (AssetMapper).
  4. Variables : composer dump-env prod crée .env.local.php, prioritaire.
  5. Copier seulement : assets, bin, config, migrations, public, src, templates, .env.local.php, composer.json, importmap.php, symfony.lock.
  6. composer install --no-dev --optimize-autoloader, cache:clear, cache:warmup --env=prod.
  7. Erreurs : 'APP_DEBUG' => true dans .env.local.php, temporairement.
  8. Site dans un sous-dossier : assets: base_path dans framework.yaml, et prefix: '%ROUTE_PREFIX%' dans routes.yaml avec la valeur dans le .env.

Vérifiez-vous

Que crée composer dump-env prod ?

Un front sur un autre domaine ne peut pas appeler l'API, alors que Postman y arrive. Pourquoi ?

Cours 14

Tests

TDD, make:test, TestCase, KernelTestCase, WebTestCase

Le TDD : écrire le test avant le code. symfony console make:test propose 5 types, du TestCase au PantherTestCase.

composer require --dev symfony/test-pack
php bin/phpunit --env=test

symfony console doctrine:migrations:migrate --env=test
symfony console doctrine:fixtures:load --env=test
TypeTesteExemple
TestCaseune fonction isoléeslug : « ô titre accentué » → o-titre-accentue
KernelTestCasedes composants ensemble, avec la baseassertCount(10, $repository->findAll())
WebTestCasedes requêtes HTTP, sans JStests générés par make:crud
  • Classe dont le nom contient Test, méthodes qui commencent par test, assertions (assertTrue, assertCount…).
  • setUp() s'exécute avant chaque test : bootKernel(), puis getContainer()->get('doctrine')->getManager().
  • Base de test séparée (_test), configurée dans .env.test.
  • Retirer markTestIncomplete() / markTestSkipped() des tests générés.

Vérifiez-vous

Quel type de test envoie des requêtes HTTP aux pages, sans JavaScript ?

Quelle méthode joue le rôle de constructeur, exécutée avant chaque test ?

Cours 15

JWT Token

Authentifier une API sans session

Un front séparé ne garde pas de session PHP. À la connexion, le serveur renvoie un JWT : des informations sur la personne, encodées, pas chiffrées (lisibles sur jwt.io), mais signées. Le front le renvoie à chaque requête.

composer require lexik/jwt-authentication-bundle
php bin/console lexik:jwt:generate-keypair

# .env : JWT_SECRET_KEY, JWT_PUBLIC_KEY, JWT_PASSPHRASE
# security.yaml : firewall "login" (json_login, stateless) et "api" (jwt: ~, stateless)
# access_control : ^/api/login PUBLIC_ACCESS, ^/api IS_AUTHENTICATED_FULLY

POST /api/login   {"username": "email", "password": "..."}   →   {"token": "eyJ..."}
Authorization: Bearer eyJ...

Vérifiez-vous

Le contenu d'un JWT est-il secret ?

Comment envoie-t-on le jeton avec une requête ?

Synthèse

Chaque cours dans Stages

CoursNotionDans Stages
1projet, routes en attributs, debug:routeroui, tous les contrôleurs
2Twig : extends, block, include, pathoui, templates/
3Doctrine, relations, migrations, injectionoui, 3 entités, 1 migration
4QueryBuildernon nécessaire, countByStatus()
5formulaires, EntityManager, CSRFoui, 3 FormTypes
6redirect, flash, Turbooui ; Turbo Drive par défaut, pas de streams
7contraintes, UniqueEntity, empty_dataoui ; pas de contrainte maison
8make:crud et retouches, DELETEoui, 2 CRUD
9json(), groupesoui, 2 routes en lecture
10user, auth, inscription, 3 niveaux de rôlesoui
11mailernon
12fixtures, Faker, référencesoui, 3 fichiers
13mise en productionnon
14testsnon, vérifications manuelles
15JWTnon, session suffisante

Partie 2

Le projet Stages

Un gestionnaire personnel de recherche de stage : chaque utilisateur note les entreprises à contacter et suit ses candidatures. Deux CRUD, une page admin, une petite API JSON, des fixtures.

Modèle

Trois entités

Useremail (unique), roles, password haché
Companyname, city, sector, website, contact, user
Applicationcompany, title, status, sentAt, updatedAt, user
User 1 ───── * Company 1 ───── * Application
  1                                   *
  └───────────────────────────────────┘
  • Company → User (ManyToOne) : une entreprise appartient à un seul utilisateur.
  • Application → Company (ManyToOne, orphanRemoval) : supprimer l'entreprise supprime ses candidatures.
  • Application → User (ManyToOne) : filtrer et compter avec findBy/count, sans jointure.
  • Statuts : constante Application::STATUSES (À contacter, Candidature envoyée, Entretien, Refus, Acceptée), utilisée par le ChoiceType, Assert\Choice, le tableau de bord et les fixtures.

MVC

Le chemin d'une requête : Alice ouvre /company

  1. Porte d'entrée : public/index.php démarre le noyau.
  2. Firewall main : retrouve Alice dans la session ; access_control ne concerne que ^/admin.
  3. Routing : GET /company → #[Route(name: 'app_company_index', methods: ['GET'])] dans CompanyController.
  4. Contrôle : #[IsGranted('ROLE_USER')] sur la classe.
  5. Contrôleur : index(CompanyRepository $companyRepository), repository injecté.
  6. Modèle : findBy(['user' => $this->getUser()], ['name' => 'ASC']), Doctrine génère le SQL.
  7. Vue : render('company/index.html.twig', [...]), qui étend base.html.twig.
  8. Réponse HTML. Pour l'API : $this->json([...]) à la place de render().

Création d'une entreprise

$company = new Company();
$form = $this->createForm(CompanyType::class, $company);
$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
    $company->setUser($this->getUser());
    $entityManager->persist($company);
    $entityManager->flush();
    $this->addFlash('success', "L'entreprise a bien été ajoutée.");
    return $this->redirectToRoute('app_company_index', [], Response::HTTP_SEE_OTHER);
}

return $this->render('company/new.html.twig', ['company' => $company, 'form' => $form]);

Routes

Les routes principales

CheminMéthodeAccèsRôle
/login, /registerGET, POSTpublicconnexion, inscription
/logoutGETconnectédéconnexion (firewall)
/GETROLE_USERtableau de bord
/company, /company/newGET / GET, POSTROLE_USERliste, création
/company/{id}, /company/{id}/editGET / GET, POSTROLE_USERfiche, modification
/company/{id}DELETEROLE_USERsuppression avec ses candidatures
/application/...idemROLE_USERCRUD des candidatures
/adminGETROLE_ADMINadministration
/admin/api/statsGETROLE_ADMINstatistiques JSON
/admin/api/usersGETROLE_ADMINutilisateurs JSON (id, email, roles)

Sécurité

Ce qui est couvert, et la limite connue

  • Mots de passe hachés (auto, bcrypt).
  • CSRF : jeton de connexion (CsrfTokenBadge), jetons des formulaires, csrf_token('delete' ~ id) vérifié par isCsrfTokenValid().
  • Admin et API : access_control et #[IsGranted('ROLE_ADMIN')] ; 403 pour un utilisateur, 302 vers /login pour un visiteur.
  • Propriétaire fixé côté serveur (setUser($this->getUser())), listes et compteurs filtrés par utilisateur.
  • Validation serveur de tous les formulaires ; échappement Twig ; SQL généré par Doctrine.

Vérifiez-vous

Pourquoi Application a-t-elle son propre champ user ?

Où est fixé le propriétaire d'une nouvelle entreprise ?

Pourquoi l'administrateur peut-il aussi utiliser les pages Entreprises ?

Partie 3

Recréer Stages pas à pas

L'ordre réel de construction. Après chaque commande Maker : relire le fichier généré.

  1. Vérifier l'environnement

    php -v
    composer --version
    symfony check:requirements

    Démarrer WAMP, noter le port de MariaDB (3307).

  2. Créer le projet

    symfony new code --webapp --version=7.4
    cd code
  3. Brancher la base

    Créer la base stages, puis dans .env : DATABASE_URL="mysql://root:@127.0.0.1:3307/stages?serverVersion=11.4.9-MariaDB&charset=utf8mb4". Dans translation.yaml : default_locale: fr.

  4. L'utilisateur

    symfony console make:user
    # User · yes (Doctrine) · email · yes (hacher les mots de passe)
  5. L'entité Company

    symfony console make:entity Company
    # name string 255 no · city string 255 no
    # sector, website, contact : string 255 yes
    # user : relation → User, ManyToOne, not null, propriété "companies" dans User

    Lire chaque question : une question imprévue décale les réponses.

  6. L'entité Application

    symfony console make:entity Application
    # company : relation → Company, ManyToOne, not null, "applications", orphanRemoval yes
    # title string 255 yes · status string 30 no
    # sentAt date yes · updatedAt datetime no
    # user : relation → User, ManyToOne, not null, "applications"
  7. Compléter les entités

    Constante STATUSES, contraintes #[Assert\...] avec messages français, #[Groups(['api_admin_users'])] sur id, email, roles de User.

  8. Migrer

    symfony console make:migration
    symfony console doctrine:migrations:migrate
  9. Connexion

    symfony console make:auth
    # Login form authenticator · UserAuthenticator · SecurityController · /logout yes · remember me no

    Compléter le TODO de onAuthenticationSuccess() (redirection vers app_home), logout: target: app_login, renvoyer un utilisateur déjà connecté depuis /login.

  10. Inscription

    symfony console make:registration-form
    # UniqueEntity yes · e-mail de vérification no · connexion automatique yes

    Retirer agreeTerms, EmailType + empty_data, messages français.

  11. Les deux CRUD

    symfony console make:crud Company
    symfony console make:crud Application

    #[IsGranted('ROLE_USER')], findBy par utilisateur, setUser($this->getUser()), setUpdatedAt(new \DateTime()), champs user/updatedAt retirés des formulaires, choice_label, ChoiceType, empty_data, addFlash, DELETE + _method + http_method_override: true.

  12. Tableau de bord

    symfony console make:controller HomeController

    Route /, count par utilisateur, countByStatus() dans ApplicationRepository, 5 dernières candidatures.

  13. Administration et API

    symfony console make:controller AdminController
    symfony console make:controller 'Api\AdminController' --no-template

    access_control: - { path: ^/admin, roles: ROLE_ADMIN }, #[IsGranted('ROLE_ADMIN', statusCode: 403, ...)], routes stats et users avec $this->json(), boutons fetch, lien du menu dans is_granted('ROLE_ADMIN').

  14. Fixtures

    composer require --dev orm-fixtures fakerphp/faker
    symfony console doctrine:fixtures:load

    Supprimer AppFixtures, créer UserFixtures, CompanyFixtures, ApplicationFixtures.

  15. Interface et vérifications

    symfony server:start -d
    symfony console debug:router
    php bin/console lint:twig templates
    php bin/console lint:yaml config

    base.html.twig, includes/header.html.twig, templates, assets/styles/app.css ; puis parcours avec les trois comptes.

Partie 4

Les pièges à connaître

Des difficultés réellement rencontrées. Savoir les expliquer prouve qu'on a compris le fonctionnement.

SymptômeCauseSolution
Un champ nommé no dans l'entitéquestion imprévue de make:entity, réponses décaléessupprimer, relancer, lire chaque question
Erreur « null given » sur un champ videsetter appelé avec null avant la validation'empty_data' => ''
NotNull accepte un champ videil ne refuse que nullNotBlank
Le <select> affiche des idEntityType sans libelléchoice_label
L'utilisateur choisit le propriétairemake:crud met tous les champsretirer user, setUser($this->getUser())
Référence circulaire en JSONobjets liés qui se rappellentgroupes de sérialisation
getReference() réclame 2 argumentsdoctrine/data-fixtures 2.xgetReference('nom', Classe::class)
Dépréciation de Annotation\GroupsSymfony 7.4Serializer\Attribute\Groups
make:auth « deprecated »Maker conseille make:security:*fonctionne encore, commande du cours
Pas de dossier migrations/Symfony 7.4src/Migrations/
Erreur 405 à la suppression<form> ne connaît que GET et POST_method + http_method_override
Suppression sans effetjeton CSRF faux ou absentcsrf_token('delete' ~ id) + isCsrfTokenValid
« Schema not in sync »MariaDB de WAMP en MyISAM, sans clés étrangèrespasser en InnoDB, recréer la base
Connexion à la base refuséeMariaDB sur 3307 sous WAMPbon port dans DATABASE_URL
Déconnecté après les fixturescomptes recréésse reconnecter
Lien admin caché mais page accessibleis_granted Twig ne protège pasaccess_control / #[IsGranted]
fetch externe bloqué, Postman OKCORSnelmio/cors-bundle

Partie 5

Présenter Stages à l'oral

Un déroulé de trois minutes, une démonstration, puis les questions qu'on peut vous poser.

Déroulé

Ce qu'il faut dire, dans l'ordre

  1. Le sujet : un gestionnaire personnel de recherche de stage, entreprises et candidatures, cinq statuts.
  2. Le modèle : User, Company, Application, relations ManyToOne, champ user direct pour éviter les jointures.
  3. La méthode : tout généré avec les commandes du cours, puis relu et retouché.
  4. Une requête : firewall, route en attribut, IsGranted, repository injecté, findBy, render, héritage Twig.
  5. La sécurité : hachage, CSRF, DELETE, double protection admin, groupes de sérialisation.
  6. Les fixtures : trois fichiers reliés par références et dépendances.
  7. La limite : contrôle du propriétaire à ajouter, et comment.

Démonstration

  1. alice@stages.test / alice1234 → tableau de bord.
  2. Entreprise sans nom → messages de validation → création → message flash.
  3. Candidature : création, changement de statut, suppression.
  4. /admin en tant qu'Alice → 403.
  5. admin@stages.test / admin1234 → /admin → bouton GET /admin/api/users : aucun mot de passe.
  6. symfony console debug:router.

Questions

Les questions d'oral

Répondre à voix haute avant d'ouvrir. La liste complète (70 questions) est dans le README.

Comprendre Symfony

Qu'est-ce que l'injection de dépendances ?

On demande un objet dans les paramètres d'une méthode ou d'un constructeur, sans new, et Symfony le fournit. Exemple : index(CompanyRepository $companyRepository).

Pourquoi les routes en attributs plutôt que dans routes.yaml ?

C'est ce que génère Maker et la route est à côté du code qu'elle appelle. Laravel, lui, garde un fichier de routes.

Pourquoi toujours utiliser le nom de la route ?

Si le chemin change, on ne le modifie que dans #[Route] : path() et redirectToRoute() suivent.

Différence entre repository et EntityManager ?

Le repository lit (find, findBy, count) ; l'EntityManager écrit (persist, flush, remove).

Pourquoi persist à la création mais pas à la modification ?

Un objet venu de la base est déjà suivi par l'EntityManager : flush() compare et fait l'UPDATE. Un nouvel objet doit d'abord être présenté avec persist().

Que fait make:migration, et que fait doctrine:migrations:migrate ?

La première écrit un fichier avec le SQL, la seconde l'exécute en base, dans l'ordre chronologique.

Expliquez l'héritage Twig.

La page fait {% extends 'base.html.twig' %} et remplit les blocs title et body du parent. C'est « include à l'envers ».

Pourquoi le filtre raw est-il dangereux ?

Il désactive l'échappement automatique : du HTML saisi par un utilisateur serait exécuté (XSS).

Formulaires et validation

Que font handleRequest, isSubmitted et isValid ?

Lire la requête et remplir l'objet ; savoir si le formulaire a été envoyé ; vérifier toutes les contraintes.

Expliquez le piège empty_data.

Un champ vide arrive en null au setter, qui attend une string : erreur PHP avant même la validation. empty_data => '' envoie une chaîne vide et NotBlank affiche son message.

Pourquoi novalidate sur les formulaires ?

Pour que le navigateur ne bloque pas l'envoi : on voit la validation du serveur, la seule qui protège vraiment.

Que se passe-t-il si on envoie un statut inventé en modifiant le HTML ?

ChoiceType et Assert\Choice le refusent ; la page revient en 422 avec l'erreur.

Pourquoi les codes 303 et 422 ?

303 : après un POST réussi, recharger la cible en GET. 422 : formulaire invalide réaffiché. Turbo Drive, actif par défaut, s'appuie sur ces codes.

Comment fait-on un DELETE depuis un formulaire HTML ?

POST avec _method=DELETE, http_method_override: true, et une route methods: ['DELETE'] ; un jeton CSRF propre à l'élément.

Sécurité et API

Comment est stocké un mot de passe ?

Haché par UserPasswordHasherInterface (algorithme auto, ici bcrypt), jamais en clair, à l'inscription comme dans les fixtures.

Quels sont les trois niveaux de contrôle d'accès ?

access_control (chemins), #[IsGranted] (contrôleur), is_granted() (Twig, affichage seulement).

Pourquoi l'admin est-il protégé deux fois ?

Sécurité en profondeur : si une protection disparaît, l'autre tient. Et IsGranted rend le droit visible à côté du code.

Quel TODO restait après make:auth ?

onAuthenticationSuccess() : choisir où aller après connexion (app_home).

Comment l'API n'expose-t-elle pas le mot de passe ?

Groupes de sérialisation : seuls id, email, roles portent api_admin_users.

Pourquoi pas de JWT pour l'API admin ?

Elle est appelée depuis le même site par un admin connecté : le navigateur envoie le cookie de session avec le fetch.

Alice peut-elle voir l'entreprise de Toto en changeant l'id dans l'URL ?

Oui, aujourd'hui : c'est la limite connue. Les listes sont filtrées mais show/edit/delete ne comparent pas le propriétaire. Correction (hors support) : comparer $company->getUser() à $this->getUser() et lever createAccessDeniedException(), puis filtrer l'EntityType.

Fixtures et choix

Comment les fixtures sont-elles reliées entre elles ?

addReference() dans la parente, DependentFixtureInterface + getDependencies() dans l'enfant, puis getReference('nom', Classe::class).

Que faut-il savoir avant de lancer doctrine:fixtures:load ?

Il vide toute la base. --append ajoute sans vider.

Pourquoi orphanRemoval sur Company::applications ?

Supprimer une entreprise supprime ses candidatures ; sinon la suppression échouerait ou laisserait des orphelins.

Pourquoi ne pas avoir utilisé QueryBuilder, mailer, tests ou JWT ?

Ils n'étaient pas nécessaires à la consigne : pas de jointure grâce au champ user, pas d'e-mail, pas de front séparé. La consigne demande de rester simple.