Framework Symfony · Doctrine ORM · Twig · MySQL
Symfony est un framework PHP open source créé par Fabien Potencier et maintenu par la société SensioLabs depuis 2005. C’est aujourd’hui l’un des frameworks PHP les plus utilisés au monde, particulièrement dans les contextes professionnels et les applications d’entreprise.
La version actuelle est Symfony 7, sortie fin 2023, qui requiert PHP 8.2 minimum. Elle s’appuie sur les améliorations majeures de PHP 8 : attributs natifs, types d’union, fibers, enums… que vous allez exploiter tout au long de ce cours.
💡 Si vous venez de PHP 7.4, l’essentiel de votre syntaxe reste valable. Ce cours signalera les points où PHP 8 change les habitudes.
Sans framework, un projet PHP de taille réelle souffre rapidement de plusieurs problèmes :
Symfony répond à tous ces problèmes en fournissant :
Symfony n’est pas le seul framework PHP. Voici une comparaison rapide :
💡 API Platform est construit sur Symfony. Maîtriser Symfony, c’est avoir accès à tout cet écosystème.
Symfony est architecturé en composants indépendants. Chacun peut être utilisé seul dans n’importe quel projet PHP (même sans le framework complet). Vous les connaissez peut-être sans le savoir : Laravel utilise plusieurs composants Symfony en interne.
Avant de commencer, assurez-vous d’avoir installé :
php -v
Vérification des prérequis :
php -v # PHP 8.2.x ou supérieur composer -V # Composer version 2.x symfony check:requirements # Vérifie que tous les prérequis Symfony sont satisfaits
# Créer un projet web complet (avec toutes les dépendances standard) symfony new bibliotech --version="7.*" --webapp # Ou avec Composer directement composer create-project symfony/skeleton:"7.*" bibliotech cd bibliotech composer require webapp
L’option --webapp installe automatiquement les paquets essentiels : Twig, Doctrine, Form, Security, Validator, Asset, etc.
--webapp
cd bibliotech # Lancer le serveur de développement local symfony serve -d # L'application est accessible sur https://localhost:8000
💡 La commande symfony serve lance un serveur HTTPS local avec rechargement automatique. C’est l’outil recommandé pour le développement.
symfony serve
bibliotech/ ├── bin/ │ └── console ← CLI Symfony (commandes artisanales) ├── config/ │ ├── packages/ ← Configuration des bundles │ ├── routes/ ← Définition des routes (YAML) │ └── services.yaml ← Déclaration des services ├── migrations/ ← Fichiers de migration Doctrine ├── public/ │ └── index.php ← Point d'entrée unique (front controller) ├── src/ │ ├── Controller/ ← Contrôleurs MVC │ ├── Entity/ ← Entités Doctrine (modèles) │ ├── Form/ ← Classes de formulaires │ ├── Repository/ ← Requêtes personnalisées Doctrine │ └── Service/ ← Services métier ├── templates/ ← Templates Twig ├── tests/ ← Tests unitaires et fonctionnels ├── var/ │ ├── cache/ ← Cache applicatif │ └── log/ ← Fichiers de log ├── vendor/ ← Dépendances Composer (ne pas toucher) ├── .env ← Variables d'environnement └── composer.json ← Déclaration des dépendances
⚠️ Le dossier vendor/ ne doit jamais être commité dans Git. Ajoutez-le à votre .gitignore (il y est déjà par défaut).
vendor/
.gitignore
Ouvrez le fichier .env à la racine du projet et configurez la connexion MySQL :
.env
# .env DATABASE_URL="mysql://root:votre_mot_de_passe@127.0.0.1:3306/bibliotech?serverVersion=8.0&charset=utf8mb4"
💡 Pour les informations sensibles (mots de passe de production), créez un fichier .env.local qui n’est pas commité dans Git. Il surcharge .env localement.
.env.local
Créez ensuite la base de données :
php bin/console doctrine:database:create # Created database `bibliotech` for connection named default
Symfony distingue trois environnements :
APP_ENV
dev
test
prod
# .env APP_ENV=dev APP_SECRET=une_chaine_aleatoire_longue_et_unique
⚠️ Ne commitez jamais un APP_SECRET de production dans votre dépôt Git.
APP_SECRET
En mode dev, Symfony affiche une barre de débogage en bas de chaque page. Elle donne accès à :
C’est l’un des outils les plus puissants de Symfony pour diagnostiquer les problèmes.
Tout au long de ce cours, nous allons construire BiblioTech, une application web de gestion de bibliothèque. Elle permettra de :
Voici le schéma simplifié des trois tables que nous allons créer :
Auteur Livre Categorie ────────────── ───────────────────── ───────────── id (PK) id (PK) id (PK) nom titre nom prenom isbn description biographie anneePublication dateNaissance resume idAuteur (FK → Auteur) idCategorie (FK → Categorie)
Relations :
MVC (Modèle-Vue-Contrôleur) est le patron d’architecture central de Symfony. Il sépare l’application en trois couches aux responsabilités distinctes :
Requête HTTP │ ▼ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Contrôleur │────▶│ Modèle │────▶│ Base de │ │ (Controller)│ │ (Entity / │ │ données │ │ │ │ Repository)│ │ (MySQL) │ │ │◀────│ │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ ▼ ┌─────────────┐ │ Vue │ │ (Twig) │ └─────────────┘ │ ▼ Réponse HTTP
Responsabilités :
Comprendre ce cycle est fondamental pour déboguer et optimiser une application Symfony :
https://localhost:8000/livres
public/index.php
Le conteneur de services (Service Container) est le cœur de Symfony. Il est responsable d’instancier et de câbler automatiquement tous les objets de l’application.
Vous n’avez jamais à écrire new MonService(). Symfony crée les objets pour vous et injecte les dépendances — c’est l’injection de dépendances (nous y reviendrons au chapitre 10).
new MonService()
// ❌ Sans injection de dépendances (à éviter) class LivreController { public function liste() { $repo = new LivreRepository(new EntityManager(...)); // ... } } // ✅ Avec injection de dépendances (Symfony) class LivreController extends AbstractController { public function liste(LivreRepository $repo): Response { // Symfony injecte $repo automatiquement } }
Un bundle est un plugin Symfony qui encapsule un ensemble de fonctionnalités réutilisables (contrôleurs, entités, templates, configuration…). Les bundles tiers s’installent via Composer :
composer require knplabs/knp-paginator-bundle # pagination composer require liip/imagine-bundle # manipulation d'images
Symfony CLI fournit un générateur de contrôleurs qui crée la classe et le template associé :
php bin/console make:controller AccueilController
Cela crée :
src/Controller/AccueilController.php
templates/accueil/index.html.twig
<?php // src/Controller/AccueilController.php namespace App\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; class AccueilController extends AbstractController { #[Route('/', name: 'app_accueil')] public function index(): Response { return $this->render('accueil/index.html.twig', [ 'titre' => 'Bienvenue sur BiblioTech', ]); } }
Plusieurs points importants à noter :
AbstractController
render()
redirectToRoute()
json()
#[Route(...)]
Response
💡 En PHP 7.4, les routes se définissaient avec des annotations (@Route). Depuis PHP 8 et Symfony 6, on utilise les attributs natifs (#[Route]), qui sont la syntaxe officielle en Symfony 7.
@Route
#[Route]
#[Route('/livre/{id}', name: 'app_livre_detail', requirements: ['id' => '\d+'])] public function detail(int $id): Response { // $id est automatiquement converti en entier return $this->render('livre/detail.html.twig', ['id' => $id]); }
Le paramètre requirements définit une contrainte de format (ici : uniquement des chiffres).
requirements
#[Route('/livre/nouveau', name: 'app_livre_nouveau', methods: ['GET', 'POST'])] public function nouveau(Request $request): Response { // Gère à la fois l'affichage du formulaire (GET) // et son traitement (POST) }
php bin/console debug:router
// Redirection vers une route nommée return $this->redirectToRoute('app_livre_detail', ['id' => 42]); // Générer une URL sans rediriger $url = $this->generateUrl('app_livre_detail', ['id' => 42]);
Pour accéder aux données de la requête (paramètres GET, POST, fichiers…), on injecte l’objet Request :
Request
use Symfony\Component\HttpFoundation\Request; #[Route('/livres', name: 'app_livre_liste')] public function liste(Request $request): Response { // Paramètre GET : /livres?page=2 $page = $request->query->getInt('page', 1); // Paramètre POST $titre = $request->request->get('titre'); // Headers $userAgent = $request->headers->get('User-Agent'); // Méthode HTTP if ($request->isMethod('POST')) { // ... } return $this->render('livre/liste.html.twig', ['page' => $page]); }
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\RedirectResponse; // Réponse HTML classique (via Twig) return $this->render('livre/liste.html.twig', $donnees); // Réponse JSON (pour une API) return $this->json(['livres' => $livres]); // Redirection return $this->redirectToRoute('app_accueil'); // Redirection externe return $this->redirect('https://www.symfony.com'); // Réponse avec code HTTP personnalisé return new Response('Non autorisé', Response::HTTP_FORBIDDEN); // Message flash (notification à afficher après redirection) $this->addFlash('success', 'Livre ajouté avec succès !'); return $this->redirectToRoute('app_livre_liste');
Générons maintenant les contrôleurs pour notre projet BiblioTech :
php bin/console make:controller LivreController php bin/console make:controller AuteurController php bin/console make:controller CategorieController
Voici la structure initiale du LivreController avec les routes du CRUD :
LivreController
<?php // src/Controller/LivreController.php namespace App\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; #[Route('/livre', name: 'app_livre_')] class LivreController extends AbstractController { #[Route('/', name: 'liste')] public function liste(): Response { return $this->render('livre/liste.html.twig'); } #[Route('/{id}', name: 'detail', requirements: ['id' => '\d+'])] public function detail(int $id): Response { return $this->render('livre/detail.html.twig'); } #[Route('/nouveau', name: 'nouveau', methods: ['GET', 'POST'])] public function nouveau(): Response { return $this->render('livre/form.html.twig'); } #[Route('/{id}/modifier', name: 'modifier', methods: ['GET', 'POST'])] public function modifier(int $id): Response { return $this->render('livre/form.html.twig'); } #[Route('/{id}/supprimer', name: 'supprimer', methods: ['POST'])] public function supprimer(int $id): Response { return $this->redirectToRoute('app_livre_liste'); } }
💡 L’attribut #[Route('/livre', name: 'app_livre_')] sur la classe définit un préfixe commun à toutes les routes de ce contrôleur. La route liste devient donc app_livre_liste et pointe vers /livre/.
#[Route('/livre', name: 'app_livre_')]
liste
app_livre_liste
/livre/
Twig est le moteur de templates officiel de Symfony. Il sépare proprement la logique PHP de la présentation HTML.
Ses avantages par rapport à du PHP pur dans les vues :
{# Commentaire (non affiché dans le HTML généré) #} {# Afficher une variable #} {{ variable }} {{ livre.titre }} {{ auteur.nom|upper }} {# Condition #} {% if livres|length > 0 %} <p>Il y a {{ livres|length }} livres.</p> {% elseif condition %} ... {% else %} <p>Aucun livre trouvé.</p> {% endif %} {# Boucle #} {% for livre in livres %} <li>{{ livre.titre }}</li> {% else %} <li>Aucun livre.</li> {% endfor %} {# Définir une variable #} {% set total = livres|length %} {# Inclure un template #} {% include 'partials/_carte_livre.html.twig' with { livre: livre } %}
Les filtres transforment une valeur à l’affichage. On les applique avec le symbole more | :
|
upper
{{ nom|upper }}
lower
{{ nom|lower }}
capitalize
{{ nom|capitalize }}
length
{{ liste|length }}
date
{{ date|date('d/m/Y') }}
default
{{ val|default('N/A') }}
slice
{{ texte|slice(0, 100) }}
nl2br
\n
<br>
{{ texte|nl2br }}
raw
{{ html|raw }}
number_format
{{ prix|number_format(2, ',', ' ') }}
⚠️ N’utilisez |raw que sur du contenu que vous avez vous-même généré ou validé. Ne l’appliquez jamais sur une donnée saisie par l’utilisateur.
|raw
L’héritage est la fonctionnalité la plus puissante de Twig. On définit un template parent (layout) avec des blocs, et les templates enfants remplissent ces blocs.
templates/base.html.twig
<!DOCTYPE html> <html lang="fr"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{% block titre %}BiblioTech{% endblock %}</title> {% block stylesheets %} <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"> {% endblock %} </head> <body> <nav class="navbar navbar-expand-lg navbar-dark bg-dark"> <div class="container"> <a class="navbar-brand" href="{{ path('app_accueil') }}">📚 BiblioTech</a> <ul class="navbar-nav ms-auto"> <li class="nav-item"> <a class="nav-link" href="{{ path('app_livre_liste') }}">Livres</a> </li> <li class="nav-item"> <a class="nav-link" href="{{ path('app_auteur_liste') }}">Auteurs</a> </li> <li class="nav-item"> <a class="nav-link" href="{{ path('app_categorie_liste') }}">Catégories</a> </li> </ul> </div> </nav> <main class="container my-4"> {# Affichage des messages flash #} {% for type, messages in app.flashes %} {% for message in messages %} <div class="alert alert-{{ type }} alert-dismissible fade show"> {{ message }} <button type="button" class="btn-close" data-bs-dismiss="alert"></button> </div> {% endfor %} {% endfor %} {% block contenu %}{% endblock %} </main> <footer class="bg-dark text-white text-center py-3 mt-5"> <p class="mb-0">© {{ "now"|date("Y") }} BiblioTech</p> </footer> {% block javascripts %} <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> {% endblock %} </body> </html>
{% extends 'base.html.twig' %} {% block titre %}Accueil — BiblioTech{% endblock %} {% block contenu %} <div class="jumbotron bg-light p-5 rounded"> <h1 class="display-4">{{ titre }}</h1> <p class="lead">Découvrez notre catalogue de livres.</p> <a href="{{ path('app_livre_liste') }}" class="btn btn-primary btn-lg"> Voir le catalogue </a> </div> {% endblock %}
{# Générer une URL à partir du nom de la route #} <a href="{{ path('app_livre_detail', { id: livre.id }) }}">{{ livre.titre }}</a> {# URL absolue #} <a href="{{ url('app_livre_detail', { id: livre.id }) }}">Lien absolu</a> {# Générer une URL d'asset (CSS, JS, images) #} <link rel="stylesheet" href="{{ asset('css/style.css') }}"> <img src="{{ asset('images/couverture.jpg') }}" alt="couverture"> {# Lien de déconnexion #} <a href="{{ path('app_logout') }}">Se déconnecter</a> {# Vérifier si l'utilisateur est connecté #} {% if is_granted('ROLE_ADMIN') %} <a href="{{ path('app_livre_nouveau') }}" class="btn btn-success">+ Ajouter</a> {% endif %} {# Afficher l'utilisateur connecté #} {% if app.user %} Connecté en tant que : {{ app.user.email }} {% endif %}
Créons le template templates/livre/liste.html.twig :
templates/livre/liste.html.twig
{% extends 'base.html.twig' %} {% block titre %}Catalogue des livres — BiblioTech{% endblock %} {% block contenu %} <div class="d-flex justify-content-between align-items-center mb-4"> <h1>📚 Catalogue des livres</h1> {% if is_granted('ROLE_ADMIN') %} <a href="{{ path('app_livre_nouveau') }}" class="btn btn-success"> + Ajouter un livre </a> {% endif %} </div> {% if livres|length == 0 %} <div class="alert alert-info">Aucun livre dans le catalogue pour le moment.</div> {% else %} <div class="row row-cols-1 row-cols-md-3 g-4"> {% for livre in livres %} <div class="col"> <div class="card h-100 shadow-sm"> <div class="card-body"> <h5 class="card-title">{{ livre.titre }}</h5> <h6 class="card-subtitle mb-2 text-muted"> {{ livre.auteur.prenom }} {{ livre.auteur.nom }} </h6> <span class="badge bg-secondary"> {{ livre.categorie.nom }} </span> <p class="card-text mt-2"> {{ livre.resume|slice(0, 120)|default('Pas de résumé.')|nl2br }} {% if livre.resume|length > 120 %}…{% endif %} </p> </div> <div class="card-footer d-flex gap-2"> <a href="{{ path('app_livre_detail', { id: livre.id }) }}" class="btn btn-sm btn-outline-primary">Détail</a> {% if is_granted('ROLE_ADMIN') %} <a href="{{ path('app_livre_modifier', { id: livre.id }) }}" class="btn btn-sm btn-outline-warning">Modifier</a> <form method="post" action="{{ path('app_livre_supprimer', { id: livre.id }) }}" onsubmit="return confirm('Supprimer ce livre ?')"> <input type="hidden" name="_token" value="{{ csrf_token('delete' ~ livre.id) }}"> <button type="submit" class="btn btn-sm btn-outline-danger"> Supprimer </button> </form> {% endif %} </div> </div> </div> {% endfor %} </div> {% endif %} {% endblock %}
Doctrine est l’ORM (Object-Relational Mapper) par défaut de Symfony. Il fait le pont entre le monde objet (PHP) et le monde relationnel (MySQL).
Avec Doctrine, vous ne manipulez plus jamais de requêtes SQL directement pour les opérations courantes — vous travaillez avec des objets PHP (les entités). Doctrine se charge de la traduction SQL.
SELECT * FROM livre WHERE id = 42
$repo->find(42)
INSERT INTO livre (titre, ...) VALUES (...)
$em->persist($livre); $em->flush();
UPDATE livre SET titre = ? WHERE id = ?
$em->flush()
DELETE FROM livre WHERE id = ?
$em->remove($livre); $em->flush()
php bin/console make:entity Categorie
Le générateur interactif vous demande les champs à créer :
New property name (press <return> to stop adding fields): > nom Field type (enter ? to see all types) [string]: > string Field length [255]: > 80 Can this field be null in the database (nullable) (yes/no) [no]: > no New property name (press <return> to stop adding fields): > description Field type [string]: > text Can this field be null in the database (nullable) (yes/no) [no]: > yes New property name (press <return> to stop adding fields): > (entrée pour terminer)
Cela génère la classe src/Entity/Categorie.php :
src/Entity/Categorie.php
<?php // src/Entity/Categorie.php namespace App\Entity; use App\Repository\CategorieRepository; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: CategorieRepository::class)] class Categorie { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 80)] private ?string $nom = null; #[ORM\Column(type: 'text', nullable: true)] private ?string $description = null; // Getters et setters générés automatiquement... public function getId(): ?int { return $this->id; } public function getNom(): ?string { return $this->nom; } public function setNom(string $nom): static { $this->nom = $nom; return $this; } public function getDescription(): ?string { return $this->description; } public function setDescription(?string $description): static { $this->description = $description; return $this; } }
💡 Remarquez les attributs PHP 8 #[ORM\...] qui remplacent les annotations Doctrine (@ORM\...) de la version PHP 7.x. C’est la syntaxe officielle en Symfony 7.
#[ORM\...]
@ORM\...
php bin/console make:entity Auteur
<?php // src/Entity/Auteur.php namespace App\Entity; use App\Repository\AuteurRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: AuteurRepository::class)] class Auteur { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 100)] private ?string $nom = null; #[ORM\Column(length: 100)] private ?string $prenom = null; #[ORM\Column(type: 'text', nullable: true)] private ?string $biographie = null; #[ORM\Column(type: 'date', nullable: true)] private ?\DateTimeInterface $dateNaissance = null; // Relation inverse (ajoutée dans le chapitre 8) #[ORM\OneToMany(mappedBy: 'auteur', targetEntity: Livre::class)] private Collection $livres; public function __construct() { $this->livres = new ArrayCollection(); } // Getters et setters... public function getNomComplet(): string { return $this->prenom . ' ' . $this->nom; } }
php bin/console make:entity Livre
<?php // src/Entity/Livre.php namespace App\Entity; use App\Repository\LivreRepository; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: LivreRepository::class)] class Livre { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 255)] private ?string $titre = null; #[ORM\Column(length: 13, nullable: true)] private ?string $isbn = null; #[ORM\Column(nullable: true)] private ?int $anneePublication = null; #[ORM\Column(type: 'text', nullable: true)] private ?string $resume = null; // Relations (définies dans le chapitre 8) #[ORM\ManyToOne(inversedBy: 'livres')] #[ORM\JoinColumn(nullable: false)] private ?Auteur $auteur = null; #[ORM\ManyToOne] #[ORM\JoinColumn(nullable: false)] private ?Categorie $categorie = null; // Getters et setters... }
Doctrine ne modifie jamais la base de données automatiquement. On crée des migrations : des fichiers PHP qui décrivent les changements SQL à appliquer.
# Générer une migration à partir des entités php bin/console make:migration # Appliquer les migrations en attente php bin/console doctrine:migrations:migrate # Voir l'état des migrations php bin/console doctrine:migrations:status
La migration générée ressemble à ceci :
// migrations/VersionXXXXXXXXXXXXXX.php public function up(Schema $schema): void { $this->addSql('CREATE TABLE auteur ( id INT AUTO_INCREMENT NOT NULL, nom VARCHAR(100) NOT NULL, prenom VARCHAR(100) NOT NULL, biographie LONGTEXT DEFAULT NULL, date_naissance DATE DEFAULT NULL, PRIMARY KEY(id) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); $this->addSql('CREATE TABLE categorie ( id INT AUTO_INCREMENT NOT NULL, nom VARCHAR(80) NOT NULL, description LONGTEXT DEFAULT NULL, PRIMARY KEY(id) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); $this->addSql('CREATE TABLE livre ( id INT AUTO_INCREMENT NOT NULL, auteur_id INT NOT NULL, categorie_id INT NOT NULL, titre VARCHAR(255) NOT NULL, isbn VARCHAR(13) DEFAULT NULL, annee_publication INT DEFAULT NULL, resume LONGTEXT DEFAULT NULL, PRIMARY KEY(id) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB'); } public function down(Schema $schema): void { $this->addSql('DROP TABLE livre'); $this->addSql('DROP TABLE auteur'); $this->addSql('DROP TABLE categorie'); }
💡 Toujours vérifier le SQL généré dans la migration avant de l’appliquer, surtout en production.
Pour alimenter la base avec des données de test, installez le bundle Fixtures :
composer require --dev orm-fixtures php bin/console make:fixtures AppFixtures
<?php // src/DataFixtures/AppFixtures.php namespace App\DataFixtures; use App\Entity\Auteur; use App\Entity\Categorie; use App\Entity\Livre; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Persistence\ObjectManager; class AppFixtures extends Fixture { public function load(ObjectManager $manager): void { // Catégories $roman = new Categorie(); $roman->setNom('Roman')->setDescription('Fiction narrative longue'); $manager->persist($roman); $sf = new Categorie(); $sf->setNom('Science-Fiction')->setDescription('Anticipation et futur'); $manager->persist($sf); // Auteur $auteur = new Auteur(); $auteur->setNom('Orwell') ->setPrenom('George') ->setBiographie('Auteur britannique célèbre pour 1984 et La Ferme des animaux.'); $manager->persist($auteur); // Livres $livre1 = new Livre(); $livre1->setTitre('1984') ->setIsbn('9782070368228') ->setAnneePublication(1949) ->setResume('Dans un futur totalitaire, Winston Smith lutte contre Big Brother.') ->setAuteur($auteur) ->setCategorie($sf); $manager->persist($livre1); $livre2 = new Livre(); $livre2->setTitre('La Ferme des animaux') ->setIsbn('9782070368235') ->setAnneePublication(1945) ->setResume('Une fable politique sur la révolution et la trahison des idéaux.') ->setAuteur($auteur) ->setCategorie($roman); $manager->persist($livre2); $manager->flush(); } }
# Charger les fixtures (attention : vide la base avant !) php bin/console doctrine:fixtures:load
Chaque entité possède un Repository qui centralise toutes les requêtes liées à cette entité. Symfony génère un repository par défaut avec des méthodes prêtes à l’emploi :
// Dans un contrôleur $livreRepository = $this->getDoctrine()->getRepository(Livre::class); // Ou par injection de dépendances (recommandé) public function liste(LivreRepository $repo): Response { $tousLesLivres = $repo->findAll(); $unLivre = $repo->find(42); $livreParTitre = $repo->findOneBy(['titre' => '1984']); $livresSf = $repo->findBy(['categorie' => $categorieSf], ['titre' => 'ASC']); }
<?php // src/Repository/LivreRepository.php namespace App\Repository; use App\Entity\Livre; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; class LivreRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Livre::class); } /** * Recherche des livres par titre ou auteur (recherche partielle) */ public function rechercherParMotCle(string $motCle): array { return $this->createQueryBuilder('l') ->leftJoin('l.auteur', 'a') ->addSelect('a') ->where('l.titre LIKE :mot') ->orWhere('a.nom LIKE :mot') ->orWhere('a.prenom LIKE :mot') ->setParameter('mot', '%' . $motCle . '%') ->orderBy('l.titre', 'ASC') ->getQuery() ->getResult(); } /** * Livres d'une catégorie donnée, triés par date de publication */ public function findParCategorie(int $categorieId): array { return $this->createQueryBuilder('l') ->where('l.categorie = :cat') ->setParameter('cat', $categorieId) ->orderBy('l.anneePublication', 'DESC') ->getQuery() ->getResult(); } }
💡 Le QueryBuilder de Doctrine génère du DQL (Doctrine Query Language), une surcouche orientée objet de SQL. Il est indépendant du SGBD cible (MySQL, PostgreSQL, SQLite…).
ManyToOne
OneToMany
ManyToMany
OneToOne
Dans l’entité Livre, on déclare la relation côté “propriétaire” :
Livre
// src/Entity/Livre.php #[ORM\ManyToOne(inversedBy: 'livres')] #[ORM\JoinColumn(nullable: false)] private ?Auteur $auteur = null; public function getAuteur(): ?Auteur { return $this->auteur; } public function setAuteur(?Auteur $auteur): static { $this->auteur = $auteur; return $this; }
Dans l’entité Auteur, on déclare la relation côté “inverse” :
Auteur
// src/Entity/Auteur.php use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; #[ORM\OneToMany(mappedBy: 'auteur', targetEntity: Livre::class, cascade: ['persist'])] private Collection $livres; public function __construct() { $this->livres = new ArrayCollection(); } public function getLivres(): Collection { return $this->livres; } public function addLivre(Livre $livre): static { if (!$this->livres->contains($livre)) { $this->livres->add($livre); $livre->setAuteur($this); } return $this; } public function removeLivre(Livre $livre): static { if ($this->livres->removeElement($livre)) { if ($livre->getAuteur() === $this) { $livre->setAuteur(null); } } return $this; }
L’option cascade sur une relation définit ce qui arrive aux entités liées lors d’opérations Doctrine :
cascade
// cascade: ['persist'] → persiste automatiquement les livres liés quand on persiste l'auteur // cascade: ['remove'] → supprime les livres quand on supprime l'auteur // cascade: ['all'] → les deux #[ORM\OneToMany(mappedBy: 'auteur', targetEntity: Livre::class, cascade: ['persist', 'remove'])]
⚠️ cascade: ['remove'] est puissant mais dangereux : supprimer un auteur supprimera tous ses livres en cascade. À utiliser consciemment.
cascade: ['remove']
Doctrine charge les relations à la demande (lazy loading). Dans Twig, accéder à livre.auteur.nom déclenche automatiquement la requête SQL pour charger l’auteur si ce n’est pas déjà fait.
livre.auteur.nom
{# Accéder à la relation ManyToOne #} <p>Auteur : {{ livre.auteur.prenom }} {{ livre.auteur.nom }}</p> <p>Catégorie : {{ livre.categorie.nom }}</p> {# Accéder à la relation OneToMany (liste) #} {% for livre in auteur.livres %} <li>{{ livre.titre }} ({{ livre.anneePublication }})</li> {% endfor %}
💡 Pour éviter le problème N+1 requêtes (une requête par livre pour charger son auteur), utilisez un JOIN dans votre repository avec addSelect('a') pour charger l’auteur en même temps que les livres.
JOIN
addSelect('a')
# Générer la migration pour les nouvelles relations php bin/console make:migration # Appliquer php bin/console doctrine:migrations:migrate
Symfony propose un générateur de CRUD complet qui crée contrôleur, formulaire et templates :
php bin/console make:crud Livre
Cela génère :
src/Controller/LivreController.php
src/Form/LivreType.php
templates/livre/
C’est un excellent point de départ. Nous allons analyser et personnaliser chaque partie.
<?php // src/Form/LivreType.php namespace App\Form; use App\Entity\Auteur; use App\Entity\Categorie; use App\Entity\Livre; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints as Assert; class LivreType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { $builder ->add('titre', TextType::class, [ 'label' => 'Titre du livre', 'attr' => ['placeholder' => 'Ex : 1984'], 'constraints' => [ new Assert\NotBlank(message: 'Le titre est obligatoire.'), new Assert\Length(max: 255, maxMessage: 'Le titre ne peut pas dépasser caractères.'), ], ]) ->add('isbn', TextType::class, [ 'label' => 'ISBN', 'required' => false, 'attr' => ['placeholder' => '9782070368228'], 'constraints' => [ new Assert\Isbn(message: 'L\'ISBN n\'est pas valide.'), ], ]) ->add('anneePublication', IntegerType::class, [ 'label' => 'Année de publication', 'required' => false, 'constraints' => [ new Assert\Range( min: 1000, max: (int) date('Y'), notInRangeMessage: 'L\'année doit être comprise entre content<h1 id="séquence-de-cours-sur-java">Séquence de cours sur Java</h1> <ul> <li>Tableaux et Collections</li> <li>Boucles</li> <li>Chaînes</li> </ul> <h2 id="résumé">Résumé</h2> <ul> <li>Découverte des Arrays, ArrayList, HashMap, Iterator, Entry…</li> <li>Boucles for, foreach, while, do… while</li> <li>Classe String et quelques unes de ses nombreuses méthodes.</li> </ul> <h2 id="support-de-cours">Support de cours</h2> <h3 id="tableaux"><a href="/java/sequence/cours/tableaux/">Tableaux</a></h3> <h3 id="boucles"><a href="/java/sequence/cours/boucles/">Boucles</a></h3> <h3 id="chaînes"><a href="/java/sequence/cours/chaines/">Chaînes</a></h3> <h2 id="exercices-individuels">Exercices individuels</h2> <h3 id="liste-des-exercices-et-mini-projets"><a href="/java/sequence/cours/pratiques/">Liste des exercices et mini-projets</a></h3> <h2 id="références">Références</h2> <p><a href="https://www.jmdoudoux.fr/java/dej/indexavecframes.htm">bases de java avec JMDoudoux</a></p> <p><a href="https://docs.oracle.com/javase/8/docs/api/">La documentation officielle (toutes les classes et méthodes)</a></p> <p><a href="https://docs.oracle.com/javase/tutorial/java/index.html">Le tutoriel oracle (anglais)</a></p> <p><a href="https://www.codecademy.com/learn/learn-java">Un bon cours d’introduction pour mieux comprendre : codecademy</a></p> <p><a href="https://www.coursera.org/learn/initiation-programmation-java">Un cours pour approfondir : coursera</a></p> <p><a href="https://www.hackerrank.com/domains/java?filters%5Bdifficulty%5D%5B%5D=easy">Pour pratiquer java en mode challenges</a></p> <h2 id="corrections-des-exercices-et-tp">Corrections des exercices et TP</h2> <h3 id="lien-vers-les-corrections"><a href="/java/cours/bases/corrections/">Lien vers les corrections</a></h3> et siteJekyll::Drops::SiteDrop.' ), ], ]) ->add('resume', TextareaType::class, [ 'label' => 'Résumé', 'required' => false, 'attr' => ['rows' => 5], ]) ->add('auteur', EntityType::class, [ 'label' => 'Auteur', 'class' => Auteur::class, 'choice_label' => fn(Auteur $a) => $a->getPrenom() . ' ' . $a->getNom(), 'placeholder' => '-- Choisir un auteur --', ]) ->add('categorie', EntityType::class, [ 'label' => 'Catégorie', 'class' => Categorie::class, 'choice_label' => 'nom', 'placeholder' => '-- Choisir une catégorie --', ]); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'data_class' => Livre::class, ]); } }
<?php // src/Controller/LivreController.php namespace App\Controller; use App\Entity\Livre; use App\Form\LivreType; use App\Repository\LivreRepository; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; #[Route('/livre', name: 'app_livre_')] class LivreController extends AbstractController { // ── LISTE ─────────────────────────────────────────────────────────────── #[Route('/', name: 'liste')] public function liste(LivreRepository $repo): Response { $livres = $repo->findAll(); return $this->render('livre/liste.html.twig', [ 'livres' => $livres, ]); } // ── DÉTAIL ────────────────────────────────────────────────────────────── #[Route('/{id}', name: 'detail', requirements: ['id' => '\d+'])] public function detail(Livre $livre): Response { // Symfony injecte automatiquement l'entité Livre correspondant à {id} // C'est le "ParamConverter" (ou MapEntity en Symfony 7) return $this->render('livre/detail.html.twig', [ 'livre' => $livre, ]); } // ── CRÉATION ──────────────────────────────────────────────────────────── #[Route('/nouveau', name: 'nouveau', methods: ['GET', 'POST'])] public function nouveau(Request $request, EntityManagerInterface $em): Response { $livre = new Livre(); $form = $this->createForm(LivreType::class, $livre); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $em->persist($livre); $em->flush(); $this->addFlash('success', 'Le livre "' . $livre->getTitre() . '" a été ajouté.'); return $this->redirectToRoute('app_livre_liste'); } return $this->render('livre/form.html.twig', [ 'form' => $form, 'titre' => 'Ajouter un livre', 'bouton' => 'Créer', ]); } // ── MODIFICATION ──────────────────────────────────────────────────────── #[Route('/{id}/modifier', name: 'modifier', methods: ['GET', 'POST'])] public function modifier(Request $request, Livre $livre, EntityManagerInterface $em): Response { $form = $this->createForm(LivreType::class, $livre); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $em->flush(); // Pas besoin de persist() : l'entité est déjà gérée $this->addFlash('success', 'Le livre a été modifié avec succès.'); return $this->redirectToRoute('app_livre_detail', ['id' => $livre->getId()]); } return $this->render('livre/form.html.twig', [ 'form' => $form, 'titre' => 'Modifier : ' . $livre->getTitre(), 'bouton' => 'Enregistrer', ]); } // ── SUPPRESSION ───────────────────────────────────────────────────────── #[Route('/{id}/supprimer', name: 'supprimer', methods: ['POST'])] public function supprimer(Request $request, Livre $livre, EntityManagerInterface $em): Response { // Vérification du token CSRF if ($this->isCsrfTokenValid('delete' . $livre->getId(), $request->getPayload()->getString('_token'))) { $titre = $livre->getTitre(); $em->remove($livre); $em->flush(); $this->addFlash('success', 'Le livre "' . $titre . '" a été supprimé.'); } else { $this->addFlash('danger', 'Token CSRF invalide.'); } return $this->redirectToRoute('app_livre_liste'); } }
💡 ParamConverter / MapEntity : quand un paramètre de route s’appelle {id} et que le type-hint de la méthode est une entité (Livre $livre), Symfony fait automatiquement le findOrFail($id). Si l’entité n’existe pas, une erreur 404 est levée. C’est une fonctionnalité très pratique !
{id}
Livre $livre
findOrFail($id)
{# templates/livre/form.html.twig #} {% extends 'base.html.twig' %} {% block titre %}{{ titre }} — BiblioTech{% endblock %} {% block contenu %} <div class="row justify-content-center"> <div class="col-md-8"> <div class="card shadow-sm"> <div class="card-header bg-primary text-white"> <h2 class="mb-0">{{ titre }}</h2> </div> <div class="card-body"> {{ form_start(form, { attr: { novalidate: 'novalidate' } }) }} <div class="mb-3"> {{ form_label(form.titre) }} {{ form_widget(form.titre, { attr: { class: 'form-control' } }) }} {{ form_errors(form.titre) }} </div> <div class="row"> <div class="col-md-6 mb-3"> {{ form_label(form.isbn) }} {{ form_widget(form.isbn, { attr: { class: 'form-control' } }) }} {{ form_errors(form.isbn) }} </div> <div class="col-md-6 mb-3"> {{ form_label(form.anneePublication) }} {{ form_widget(form.anneePublication, { attr: { class: 'form-control' } }) }} {{ form_errors(form.anneePublication) }} </div> </div> <div class="mb-3"> {{ form_label(form.auteur) }} {{ form_widget(form.auteur, { attr: { class: 'form-select' } }) }} {{ form_errors(form.auteur) }} </div> <div class="mb-3"> {{ form_label(form.categorie) }} {{ form_widget(form.categorie, { attr: { class: 'form-select' } }) }} {{ form_errors(form.categorie) }} </div> <div class="mb-3"> {{ form_label(form.resume) }} {{ form_widget(form.resume, { attr: { class: 'form-control' } }) }} {{ form_errors(form.resume) }} </div> <div class="d-flex gap-2"> <button type="submit" class="btn btn-primary">{{ bouton }}</button> <a href="{{ path('app_livre_liste') }}" class="btn btn-secondary">Annuler</a> </div> {{ form_end(form) }} </div> </div> </div> </div> {% endblock %}
Symfony propose un composant Validator très complet. Les contraintes peuvent être définies directement sur les entités avec des attributs PHP 8 :
// src/Entity/Livre.php use Symfony\Component\Validator\Constraints as Assert; #[ORM\Column(length: 255)] #[Assert\NotBlank(message: 'Le titre est obligatoire.')] #[Assert\Length(max: 255)] private ?string $titre = null; #[ORM\Column(length: 13, nullable: true)] #[Assert\Isbn(message: 'Format ISBN invalide.')] private ?string $isbn = null; #[ORM\Column(nullable: true)] #[Assert\Range(min: 1000, max: 2100)] private ?int $anneePublication = null;
Contraintes courantes :
#[Assert\NotBlank]
#[Assert\NotNull]
#[Assert\Length(min:, max:)]
#[Assert\Email]
#[Assert\Url]
#[Assert\Range(min:, max:)]
#[Assert\Positive]
#[Assert\Regex(pattern:)]
#[Assert\Choice(choices:)]
#[Assert\Date]
{# templates/livre/detail.html.twig #} {% extends 'base.html.twig' %} {% block titre %}{{ livre.titre }} — BiblioTech{% endblock %} {% block contenu %} <nav aria-label="breadcrumb" class="mb-4"> <ol class="breadcrumb"> <li class="breadcrumb-item"><a href="{{ path('app_accueil') }}">Accueil</a></li> <li class="breadcrumb-item"><a href="{{ path('app_livre_liste') }}">Livres</a></li> <li class="breadcrumb-item active">{{ livre.titre }}</li> </ol> </nav> <div class="row"> <div class="col-md-8"> <h1>{{ livre.titre }}</h1> <p class="text-muted"> Par <a href="{{ path('app_auteur_detail', { id: livre.auteur.id }) }}"> {{ livre.auteur.prenom }} {{ livre.auteur.nom }} </a> | <span class="badge bg-secondary">{{ livre.categorie.nom }}</span> {% if livre.anneePublication %} | {{ livre.anneePublication }} {% endif %} </p> {% if livre.isbn %} <p><strong>ISBN :</strong> {{ livre.isbn }}</p> {% endif %} <div class="mt-3"> <h5>Résumé</h5> <p>{{ livre.resume|default('Aucun résumé disponible.')|nl2br }}</p> </div> </div> </div> {% if is_granted('ROLE_ADMIN') %} <div class="mt-4 d-flex gap-2"> <a href="{{ path('app_livre_modifier', { id: livre.id }) }}" class="btn btn-warning">Modifier</a> <form method="post" action="{{ path('app_livre_supprimer', { id: livre.id }) }}" onsubmit="return confirm('Supprimer ce livre ?')"> <input type="hidden" name="_token" value="{{ csrf_token('delete' ~ livre.id) }}"> <button type="submit" class="btn btn-danger">Supprimer</button> </form> <a href="{{ path('app_livre_liste') }}" class="btn btn-secondary">Retour</a> </div> {% endif %} {% endblock %}
Dans Symfony, un service est n’importe quel objet PHP qui réalise une tâche précise. Le conteneur de services Symfony instancie et câble ces objets automatiquement.
Tout ce que vous avez injecté jusqu’ici est un service : LivreRepository, EntityManagerInterface, Request…
LivreRepository
EntityManagerInterface
Créons un service BibliothequeService qui centralise la logique métier de recherche :
BibliothequeService
<?php // src/Service/BibliothequeService.php namespace App\Service; use App\Entity\Livre; use App\Repository\LivreRepository; use App\Repository\AuteurRepository; class BibliothequeService { public function __construct( private readonly LivreRepository $livreRepo, private readonly AuteurRepository $auteurRepo, ) {} /** * Recherche globale dans livres et auteurs */ public function rechercher(string $motCle): array { if (strlen($motCle) < 2) { return ['livres' => [], 'auteurs' => []]; } return [ 'livres' => $this->livreRepo->rechercherParMotCle($motCle), 'auteurs' => $this->auteurRepo->rechercherParNom($motCle), ]; } /** * Statistiques générales */ public function getStatistiques(): array { return [ 'nbLivres' => count($this->livreRepo->findAll()), 'nbAuteurs' => count($this->auteurRepo->findAll()), 'dernierAjout' => $this->livreRepo->findDernierAjout(), ]; } }
💡 La syntaxe private readonly (PHP 8.1+) en paramètre de constructeur est un raccourci pour déclarer et initialiser simultanément une propriété de classe. C’est la pratique recommandée en Symfony 7.
private readonly
Symfony détecte et injecte automatiquement les services par autowiring : il suffit de type-hinter le paramètre avec la classe du service.
// src/Controller/RechercheController.php #[Route('/recherche', name: 'app_recherche')] public function index( Request $request, BibliothequeService $bibliotheque ): Response { $motCle = $request->query->get('q', ''); $resultats = $bibliotheque->rechercher($motCle); return $this->render('recherche/resultats.html.twig', [ 'motCle' => $motCle, 'resultats' => $resultats, ]); }
Symfony utilise un EventDispatcher pour découpler les composants. Vous pouvez écouter des événements du cycle de vie de la requête :
// src/EventListener/MaintenanceListener.php use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; #[AsEventListener] class MaintenanceListener { public function __invoke(RequestEvent $event): void { // Exemple : rediriger vers une page de maintenance // selon un paramètre de configuration } }
Symfony propose un générateur complet pour la sécurité :
# Créer l'entité User php bin/console make:user # Créer le formulaire de connexion php bin/console make:auth
Le générateur make:user crée l’entité User qui implémente UserInterface :
make:user
User
UserInterface
<?php // src/Entity/User.php namespace App\Entity; use App\Repository\UserRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; #[ORM\Entity(repositoryClass: UserRepository::class)] #[ORM\Table(name: '`user`')] #[UniqueEntity(fields: ['email'], message: 'Cet email est déjà utilisé.')] class User implements UserInterface, PasswordAuthenticatedUserInterface { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 180)] private ?string $email = null; #[ORM\Column] private array $roles = []; #[ORM\Column] private ?string $password = null; public function getUserIdentifier(): string { return (string) $this->email; } public function getRoles(): array { $roles = $this->roles; $roles[] = 'ROLE_USER'; // Tous les utilisateurs ont ROLE_USER return array_unique($roles); } public function eraseCredentials(): void {} // Getters/setters... }
# config/packages/security.yaml security: password_hashers: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' providers: app_user_provider: entity: class: App\Entity\User property: email firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false main: lazy: true provider: app_user_provider form_login: login_path: app_login check_path: app_login enable_csrf: true logout: path: app_logout target: app_accueil access_control: - { path: ^/admin, roles: ROLE_ADMIN } - { path: ^/livre/nouveau, roles: ROLE_ADMIN } - { path: ^/livre/.*/modifier, roles: ROLE_ADMIN } - { path: ^/livre/.*/supprimer, roles: ROLE_ADMIN }
<?php // src/Controller/SecurityController.php namespace App\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; class SecurityController extends AbstractController { #[Route('/login', name: 'app_login')] public function login(AuthenticationUtils $authUtils): Response { if ($this->getUser()) { return $this->redirectToRoute('app_accueil'); } $error = $authUtils->getLastAuthenticationError(); $lastUsername = $authUtils->getLastUsername(); return $this->render('security/login.html.twig', [ 'last_username' => $lastUsername, 'error' => $error, ]); } #[Route('/logout', name: 'app_logout')] public function logout(): void { // Symfony intercepte cette route automatiquement throw new \LogicException('Ne devrait jamais être atteint.'); } }
{# templates/security/login.html.twig #} {% extends 'base.html.twig' %} {% block titre %}Connexion — BiblioTech{% endblock %} {% block contenu %} <div class="row justify-content-center mt-5"> <div class="col-md-4"> <div class="card shadow"> <div class="card-header bg-dark text-white text-center"> <h4>🔒 Connexion administrateur</h4> </div> <div class="card-body p-4"> {% if error %} <div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div> {% endif %} <form method="post"> <div class="mb-3"> <label for="inputEmail" class="form-label">Email</label> <input type="email" id="inputEmail" name="_username" class="form-control" value="{{ last_username }}" autocomplete="email" autofocus> </div> <div class="mb-3"> <label for="inputPassword" class="form-label">Mot de passe</label> <input type="password" id="inputPassword" name="_password" class="form-control" autocomplete="current-password"> </div> <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}"> <button type="submit" class="btn btn-dark w-100">Se connecter</button> </form> </div> </div> </div> </div> {% endblock %}
# Créer une commande pour créer un admin php bin/console make:command CreateAdminCommand
Ou directement via les fixtures :
// Dans AppFixtures.php use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; class AppFixtures extends Fixture { public function __construct( private readonly UserPasswordHasherInterface $hasher ) {} public function load(ObjectManager $manager): void { $admin = new User(); $admin->setEmail('admin@bibliotech.fr'); $admin->setRoles(['ROLE_ADMIN']); $admin->setPassword( $this->hasher->hashPassword($admin, 'admin1234') ); $manager->persist($admin); // ... reste des fixtures $manager->flush(); } }
// Méthode 1 : attribut IsGranted (recommandé Symfony 7) use Symfony\Component\Security\Http\Attribute\IsGranted; #[IsGranted('ROLE_ADMIN')] #[Route('/nouveau', name: 'nouveau', methods: ['GET', 'POST'])] public function nouveau(Request $request, EntityManagerInterface $em): Response { // ... } // Méthode 2 : dans le code du contrôleur public function supprimer(...): Response { $this->denyAccessUnlessGranted('ROLE_ADMIN'); // ... }
Installez le bundle KnpPaginator :
composer require knplabs/knp-paginator-bundle
// src/Controller/LivreController.php use Knp\Component\Pager\PaginatorInterface; #[Route('/', name: 'liste')] public function liste(LivreRepository $repo, PaginatorInterface $paginator, Request $request): Response { $query = $repo->createQueryBuilder('l') ->leftJoin('l.auteur', 'a')->addSelect('a') ->leftJoin('l.categorie', 'c')->addSelect('c') ->orderBy('l.titre', 'ASC') ->getQuery(); $livres = $paginator->paginate( $query, $request->query->getInt('page', 1), 12 // 12 livres par page ); return $this->render('livre/liste.html.twig', ['livres' => $livres]); }
{# Dans le template, afficher la pagination #} <div class="d-flex justify-content-center mt-4"> {{ knp_pagination_render(livres) }} </div>
Ajoutons un formulaire de recherche simple dans la barre de navigation :
{# Dans base.html.twig, dans la navbar #} <form class="d-flex ms-3" method="get" action="{{ path('app_recherche') }}"> <input class="form-control me-2" type="search" name="q" placeholder="Rechercher..." value="{{ app.request.query.get('q') }}"> <button class="btn btn-outline-light" type="submit">🔍</button> </form>
Créez des templates d’erreur personnalisés dans templates/bundles/TwigBundle/Exception/ :
templates/bundles/TwigBundle/Exception/
{# templates/bundles/TwigBundle/Exception/error404.html.twig #} {% extends 'base.html.twig' %} {% block titre %}Page introuvable — BiblioTech{% endblock %} {% block contenu %} <div class="text-center py-5"> <h1 class="display-1 text-muted">404</h1> <h2>Page introuvable</h2> <p class="lead">Le livre que vous cherchez n'existe peut-être plus.</p> <a href="{{ path('app_accueil') }}" class="btn btn-primary">Retour à l'accueil</a> </div> {% endblock %}
Pour rendre des données disponibles dans tous les templates sans les passer manuellement depuis chaque contrôleur, utilisez une Twig Extension ou les variables globales dans config/packages/twig.yaml :
config/packages/twig.yaml
# config/packages/twig.yaml twig: globals: app_name: 'BiblioTech' app_version: '1.0'
Ou via une extension Twig pour des données dynamiques :
<?php // src/Twig/AppExtension.php namespace App\Twig; use App\Repository\CategorieRepository; use Twig\Extension\AbstractExtension; use Twig\Extension\GlobalsInterface; class AppExtension extends AbstractExtension implements GlobalsInterface { public function __construct( private readonly CategorieRepository $categorieRepo ) {} public function getGlobals(): array { return [ 'categories_menu' => $this->categorieRepo->findAll(), ]; } }
# Vider le cache (développement) php bin/console cache:clear # Préchauffer le cache (production) php bin/console cache:warmup --env=prod # Analyser les requêtes SQL lentes # → Utiliser la barre de débogage Symfony (onglet "Doctrine")
# .env.local (jamais commité) APP_ENV=dev APP_SECRET=votre_secret_unique_ici DATABASE_URL="mysql://user:password@127.0.0.1:3306/bibliotech?serverVersion=8.0"
# Commandes de déploiement type composer install --no-dev --optimize-autoloader php bin/console doctrine:migrations:migrate --no-interaction php bin/console cache:clear --env=prod php bin/console cache:warmup --env=prod php bin/console assets:install
À ce stade, votre application BiblioTech doit disposer de :
Categorie
Exercice 1 — Compléter le CRUD Auteur
Générez et complétez le CRUD pour l’entité Auteur :
AuteurType
AuteurController
base.html.twig
Exercice 2 — CRUD Catégorie
Sur le même modèle, créez le CRUD complet pour les catégories. La page de détail d’une catégorie doit afficher tous les livres de cette catégorie.
Exercice 3 — Recherche avancée
Ajoutez un formulaire de recherche avancée avec les critères suivants :
Utilisez le QueryBuilder Doctrine pour construire dynamiquement la requête selon les critères remplis.
Exercice 4 — Pagination
Paginez la liste des livres (12 par page) et la liste des auteurs (10 par page).
Exercice 5 — Relation ManyToMany
Ajoutez une entité Tag (etiquette thématique). Un livre peut avoir plusieurs tags, un tag peut être associé à plusieurs livres.
Tag
nom
couleur
LivreType
Exercice 6 — Upload de couverture
Ajoutez la possibilité d’uploader une image de couverture pour chaque livre.
couverture
UploadService
FileType
Exercice 7 — API JSON
Ajoutez des endpoints JSON pour exposer les données :
GET /api/livres
GET /api/livres/{id}
POST /api/livres
Utilisez $this->json() du contrôleur et les groupes de sérialisation Symfony.
$this->json()
Nathalie, la propriétaire de l’élevage Wouaf-Wouaf (vous la connaissez bien maintenant), souhaite une application web Symfony pour gérer ses chiens. Elle veut :
php bin/console
make:controller Nom
make:entity Nom
make:form NomType
make:crud Entite
make:auth
make:migration
doctrine:migrations:migrate
doctrine:migrations:status
doctrine:fixtures:load
doctrine:database:create
doctrine:database:drop --force
debug:router
debug:container
cache:clear
cache:warmup
server:start
composer require nom/paquet
composer require --dev nom/paquet
composer remove nom/paquet
composer update
composer install
composer.lock
composer dump-autoload -o
symfony/orm-pack
composer require orm
symfony/twig-pack
composer require twig
symfony/form
composer require form
symfony/validator
composer require validator
symfony/security-bundle
composer require security
symfony/mailer
composer require mailer
symfony/webpack-encore-bundle
composer require encore
knplabs/knp-paginator-bundle
doctrine/fixtures-bundle
composer require --dev orm-fixtures
symfony/debug-bundle
composer require --dev debug
# Raccourci Symfony CLI symfony console make:controller # équivalent à php bin/console make:controller # Ouvrir le projet dans le navigateur symfony open:local # Vérifier les exigences Symfony symfony check:requirements # Voir les logs en temps réel symfony server:log
— Fin du cours —