Aller au contenu

Symfony 7 — PHP 8 - Développement web MVC

Cours pratique pour développeurs PHP

Framework Symfony · Doctrine ORM · Twig · MySQL


Sommaire


1. Introduction à Symfony

1.1. Qu’est-ce que Symfony ?

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.

1.2. Pourquoi utiliser un framework ?

Sans framework, un projet PHP de taille réelle souffre rapidement de plusieurs problèmes :

Symfony répond à tous ces problèmes en fournissant :

1.3. Symfony dans l’écosystème PHP

Symfony n’est pas le seul framework PHP. Voici une comparaison rapide :

Framework Points forts Cas d’usage typique
Symfony 7 Robustesse, flexibilité, enterprise Applications complexes, API, e-commerce
Laravel 11 Rapidité de développement, écosystème riche Startups, applications web classiques
API Platform API REST/GraphQL clés en main Microservices, backends d’API
CakePHP Convention over configuration Applications CRUD rapides

💡 API Platform est construit sur Symfony. Maîtriser Symfony, c’est avoir accès à tout cet écosystème.

1.4. Les composants Symfony

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.

Composant Rôle
HttpFoundation Abstraction de la requête et de la réponse HTTP
Routing Mapping URL → contrôleur
Form Création et validation de formulaires
Validator Règles de validation des données
Security Authentification et autorisation
Doctrine Bridge Intégration de Doctrine ORM
Twig Bridge Intégration du moteur de templates
Mailer Envoi d’emails
Console Création de commandes CLI

2. Installation et configuration de l’environnement

2.1. Prérequis

Avant de commencer, assurez-vous d’avoir installé :

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

2.2. Créer un nouveau projet Symfony

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

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.

2.3. Structure du projet créé

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

2.4. Configuration de la base de données

Ouvrez le fichier .env à la racine du projet et configurez la connexion MySQL :

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

Créez ensuite la base de données :

php bin/console doctrine:database:create
# Created database `bibliotech` for connection named default

2.5. Le fichier .env et les environnements

Symfony distingue trois environnements :

Environnement Variable APP_ENV Utilisation
dev dev Développement local, barre de débogage active
test test Tests automatisés
prod prod Production, cache activé, pas de débogage
# .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.

2.6. La barre de débogage (Profiler)

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.


3. Projet fil rouge — BiblioTech, une bibliothèque en ligne

3.1. Présentation du projet

Tout au long de ce cours, nous allons construire BiblioTech, une application web de gestion de bibliothèque. Elle permettra de :

3.2. Modèle de données cible

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 :

3.3. Fonctionnalités à développer

Module Fonctionnalités
Accueil Page d’accueil avec liste des derniers livres
Livres Liste, détail, créer, modifier, supprimer
Auteurs Liste, détail, créer, modifier, supprimer
Catégories Liste, créer, modifier, supprimer
Sécurité Connexion / déconnexion administrateur

4. Architecture MVC et structure d’un projet Symfony

4.1. Le patron MVC

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 :

4.2. Le cycle de vie d’une requête Symfony

Comprendre ce cycle est fondamental pour déboguer et optimiser une application Symfony :

  1. Le navigateur envoie une requête HTTP vers https://localhost:8000/livres.
  2. Le serveur web redirige vers public/index.php (front controller).
  3. Le Kernel Symfony se charge et initialise le conteneur de services.
  4. Le Router analyse l’URL et détermine quel contrôleur appeler.
  5. Le Contrôleur est instancié, ses dépendances sont injectées automatiquement.
  6. Le contrôleur exécute sa logique, interroge la base via Doctrine.
  7. Le contrôleur passe les données à Twig qui génère le HTML.
  8. Une Response HTTP est retournée au navigateur.

4.3. Le conteneur de services

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

// ❌ 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
    }
}

4.4. Les bundles

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

5. Le routeur et les contrôleurs

5.1. Créer son premier contrôleur

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 :

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

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

5.2. Les routes

Définir une route avec paramètre

#[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).

Routes avec méthodes HTTP

#[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)
}

Lister toutes les routes

php bin/console debug:router

Générer une URL dans un contrôleur

// 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]);

5.3. La requête HTTP — L’objet Request

Pour accéder aux données de la requête (paramètres GET, POST, fichiers…), on injecte l’objet 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]);
}

5.4. Les différents types de Response

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

5.5. Créer le contrôleur Livre

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 :

<?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/.


6. Les vues avec Twig

6.1. Qu’est-ce que Twig ?

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 :

6.2. Syntaxe de base Twig

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

6.3. Les filtres Twig

Les filtres transforment une valeur à l’affichage. On les applique avec le symbole more | :

Filtre Description Exemple
upper Majuscules {{ nom|upper }}
lower Minuscules {{ nom|lower }}
capitalize Première lettre en majuscule {{ nom|capitalize }}
length Longueur (chaîne ou tableau) {{ liste|length }}
date Formatage de date {{ date|date('d/m/Y') }}
default Valeur par défaut si null {{ val|default('N/A') }}
slice Extraire une portion {{ texte|slice(0, 100) }}
nl2br Convertit \n en <br> {{ texte|nl2br }}
raw Désactive l’échappement {{ html|raw }} ⚠️
number_format Formatage nombre {{ 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.

6.4. Héritage de templates

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.

Template parent : 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">&copy; {{ "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>

Template enfant : templates/accueil/index.html.twig

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

6.5. Les fonctions Twig importantes

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

6.6. Template de liste des livres

Créons le template 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 %}

7. Doctrine ORM — Les entités et la base de données

7.1. Qu’est-ce que Doctrine ORM ?

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.

Sans ORM Avec Doctrine
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 = ? Modifier l’objet → $em->flush()
DELETE FROM livre WHERE id = ? $em->remove($livre); $em->flush()

7.2. Créer une entité

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 :

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

7.3. Créer l’entité Auteur

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

7.4. Créer l’entité Livre

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

7.5. Les migrations

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.

7.6. Les fixtures — Données de test

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

7.7. Les repositories — Requêtes personnalisées

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

Requête personnalisée dans le Repository

<?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…).


8. Les relations entre entités

8.1. Types de relations Doctrine

Relation Description Exemple BiblioTech
ManyToOne N objets → 1 objet Livre → Auteur (plusieurs livres, un auteur)
OneToMany 1 objet → N objets Auteur → Livres (un auteur, plusieurs livres)
ManyToMany N objets → N objets Livre ↔ Tag (livre a plusieurs tags, tag sur plusieurs livres)
OneToOne 1 objet → 1 objet Utilisateur → Profil

8.2. Relation ManyToOne — Livre vers Auteur

Dans l’entité Livre, on déclare la relation côté “propriétaire” :

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

8.3. Relation OneToMany — Auteur vers ses Livres

Dans l’entité Auteur, on déclare la relation côté “inverse” :

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

8.4. L’option cascade

L’option cascade sur une relation définit ce qui arrive aux entités liées lors d’opérations Doctrine :

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

8.5. Accéder aux relations dans Twig

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.

{# 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.

8.6. Mettre à jour les migrations après ajout de relations

# Générer la migration pour les nouvelles relations
php bin/console make:migration

# Appliquer
php bin/console doctrine:migrations:migrate

9. Le CRUD complet — Formulaires et validation

9.1. Générer un CRUD complet automatiquement

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 :

C’est un excellent point de départ. Nous allons analyser et personnaliser chaque partie.

9.2. Les classes de formulaires

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

9.3. Le contrôleur CRUD complet

<?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 !

9.4. Template de formulaire

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

9.5. La validation des données

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 :

Contrainte Description
#[Assert\NotBlank] Champ non vide
#[Assert\NotNull] Champ non null
#[Assert\Length(min:, max:)] Longueur d’une chaîne
#[Assert\Email] Format email valide
#[Assert\Url] Format URL valide
#[Assert\Range(min:, max:)] Valeur dans un intervalle
#[Assert\Positive] Nombre strictement positif
#[Assert\Regex(pattern:)] Correspondance à une regex
#[Assert\Choice(choices:)] Valeur dans une liste
#[Assert\Date] Format de date valide

9.6. Template de détail d’un livre

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

10. Les services et l’injection de dépendances

10.1. Qu’est-ce qu’un service ?

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

10.2. Créer un service métier

Créons un service BibliothequeService qui centralise la logique métier de recherche :

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

10.3. Utiliser le service dans un contrôleur

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

10.4. Les événements Symfony

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

11. Sécurité — Authentification et autorisation

11.1. Générer le système d’authentification

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 :

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

11.2. Configuration de la sécurité

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

11.3. Le contrôleur de sécurité

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

11.4. Template de connexion

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

11.5. Créer un administrateur en ligne de commande

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

11.6. Protéger des actions dans les contrôleurs

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

12. Finitions et bonnes pratiques

12.1. La pagination

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>

12.2. La recherche

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>

12.3. Gestion des erreurs 404 et 500

Créez des templates d’erreur personnalisés dans 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 %}

12.4. Variables Twig globales

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

12.5. Le cache et les performances

# 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")

12.6. Variables d’environnement et déploiement

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

12.7. Récapitulatif du projet BiblioTech

À ce stade, votre application BiblioTech doit disposer de :


13. Exercices d’application

13.1. Exercices guidés

Exercice 1 — Compléter le CRUD Auteur

Générez et complétez le CRUD pour l’entité Auteur :

  1. Générer le formulaire AuteurType avec les champs nom, prénom, biographie, dateNaissance.
  2. Compléter le AuteurController avec les 5 actions (liste, détail, nouveau, modifier, supprimer).
  3. Créer les templates correspondants en héritant de base.html.twig.
  4. Sur la page de détail d’un auteur, afficher la liste de ses livres.

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

13.2. Exercices d’approfondissement

Exercice 5 — Relation ManyToMany

Ajoutez une entité Tag (etiquette thématique). Un livre peut avoir plusieurs tags, un tag peut être associé à plusieurs livres.

  1. Créer l’entité Tag avec les champs nom et couleur.
  2. Ajouter la relation ManyToMany entre Livre et Tag.
  3. Modifier le formulaire LivreType pour permettre de sélectionner plusieurs tags.
  4. Afficher les tags sous forme de badges sur les cartes de livres.

Exercice 6 — Upload de couverture

Ajoutez la possibilité d’uploader une image de couverture pour chaque livre.

  1. Ajouter un champ couverture (nom du fichier) à l’entité Livre.
  2. Créer un service UploadService qui gère le stockage du fichier.
  3. Modifier le formulaire avec un champ FileType.
  4. Afficher la couverture dans les templates.

Exercice 7 — API JSON

Ajoutez des endpoints JSON pour exposer les données :

Utilisez $this->json() du contrôleur et les groupes de sérialisation Symfony.

13.3. Exercice de conception

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 :

  1. Définissez les entités et leurs relations.
  2. Générez les migrations.
  3. Créez les fixtures.
  4. Implémentez le CRUD complet.

Annexe — Commandes Symfony et Composer utiles

Commandes php bin/console

Commande Description
make:controller Nom Créer un contrôleur + template
make:entity Nom Créer ou modifier une entité
make:form NomType Créer un formulaire
make:crud Entite Générer un CRUD complet
make:user Créer l’entité User sécurisée
make:auth Générer le système d’authentification
make:migration Générer une migration depuis les entités
doctrine:migrations:migrate Appliquer les migrations
doctrine:migrations:status État des migrations
doctrine:fixtures:load Charger les données de test
doctrine:database:create Créer la base de données
doctrine:database:drop --force Supprimer la base de données
debug:router Lister toutes les routes
debug:container Lister tous les services
cache:clear Vider le cache
cache:warmup Préchauffer le cache
server:start Démarrer le serveur de développement

Commandes Composer utiles

Commande Description
composer require nom/paquet Installer un paquet
composer require --dev nom/paquet Installer un paquet de développement
composer remove nom/paquet Désinstaller un paquet
composer update Mettre à jour les dépendances
composer install Installer depuis composer.lock
composer dump-autoload -o Régénérer l’autoload (optimisé)

Paquets Symfony utiles

Paquet Description Installation
symfony/orm-pack Doctrine ORM complet composer require orm
symfony/twig-pack Twig + extras composer require twig
symfony/form Composant formulaires composer require form
symfony/validator Validation des données composer require validator
symfony/security-bundle Sécurité complète composer require security
symfony/mailer Envoi d’emails composer require mailer
symfony/webpack-encore-bundle Assets (CSS/JS) composer require encore
knplabs/knp-paginator-bundle Pagination composer require knplabs/knp-paginator-bundle
doctrine/fixtures-bundle Données de test composer require --dev orm-fixtures
symfony/debug-bundle Débogage avancé composer require --dev debug

Raccourcis utiles

# 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 —