Bases PHP
Variables, types, opérateurs
PHP
<?php

// Variables (toujours préfixées par $)
$nom    = "Alice";
$age    = 30;
$prix   = 19.99;
$actif  = true;
$rien   = null;

// Constantes
define('VERSION', '1.0.0');
const APP_NAME = 'MonApp';

// Types
gettype($nom);     // "string"
is_string($nom);   // true
is_int($age);      // true
is_null($rien);    // true
isset($nom);       // true (existe et !== null)
empty($nom);       // false (non vide)

// Casting
(int) "42";        // 42
(float) "3.14";    // 3.14
(string) 42;       // "42"
(bool) 1;          // true
(array) $nom;      // ["Alice"]
intval("42px");    // 42

// Opérateurs de comparaison
$a == $b;   // égal (loose)
$a === $b;  // identique (valeur + type) — à préférer
$a != $b;   // différent
$a !== $b;  // non identique
$a <=> $b;  // spaceship : -1, 0, ou 1

// Opérateurs logiques
$a && $b;   // ET
$a || $b;   // OU
!$a;        // NON
$a and $b;  // ET (priorité plus basse)

// Null coalescing
$valeur = $user['nom'] ?? 'Anonyme';    // PHP 7+
$user['age'] ??= 18;                    // affecte si null (PHP 7.4+)

// Spaceship pour tri
usort($items, fn($a, $b) => $a['prix'] <=> $b['prix']);
Structures de contrôle
PHP
<?php

// if / elseif / else
if ($age >= 18) {
    echo "Majeur";
} elseif ($age >= 13) {
    echo "Adolescent";
} else {
    echo "Enfant";
}

// Ternaire
$statut = $actif ? "Actif" : "Inactif";

// Match (PHP 8) — plus strict que switch
$message = match($code) {
    200       => "OK",
    404       => "Non trouvé",
    500, 503  => "Erreur serveur",
    default   => "Inconnu",
};

// for
for ($i = 0; $i < 10; $i++) {
    echo $i;
}

// while
while ($condition) { ... }

// foreach
foreach ($fruits as $fruit) {
    echo $fruit;
}
foreach ($users as $id => $user) {
    echo "$id: {$user['nom']}";
}

// break / continue
foreach ($items as $item) {
    if ($item === "skip") continue;
    if ($item === "stop") break;
    echo $item;
}
Chaînes de caractères
Fonctions de chaînes essentielles
PHP
<?php

// Interpolation
$nom = "Alice";
echo "Bonjour $nom !";
echo "Bonjour {$nom} !";      // avec accolades (objets, tableaux)
echo 'Pas d\'interpolation';  // guillemets simples = littéral

// Heredoc (interpolation)
$texte = <<<EOT
Bonjour $nom,
Bienvenue !
EOT;

// Nowdoc (pas d'interpolation)
$texte = <<<'EOT'
Bonjour $nom,
EOT;

// Longueur et recherche
strlen("Bonjour");             // 7
mb_strlen("Héllo");            // 5 (multi-byte — UTF-8)
strpos("Bonjour", "jour");     // 3 (ou false)
strrpos("aabaa", "a");         // 4 (dernière occurrence)
str_contains("Bonjour", "jour"); // true (PHP 8)
str_starts_with("Bonjour", "Bon"); // true (PHP 8)
str_ends_with("Bonjour", "jour");  // true (PHP 8)
substr_count("banana", "an");  // 2

// Transformation
strtolower("HELLO");           // "hello"
strtoupper("hello");           // "HELLO"
ucfirst("bonjour");            // "Bonjour"
ucwords("bonjour monde");      // "Bonjour Monde"
trim("  espaces  ");           // "espaces"
ltrim("  gauche");
rtrim("droite  ");
str_pad("5", 3, "0", STR_PAD_LEFT); // "005"
str_repeat("ab", 3);           // "ababab"
wordwrap($texte, 75, "\n", true);

// Remplacement
str_replace("foo", "bar", "foo baz");     // "bar baz"
str_ireplace("FOO", "bar", "foo baz");    // insensible à la casse
substr("Bonjour", 3, 4);                  // "jour"
substr_replace("Bonjour", "soir", 3);     // "Bonsoir"

// Découpage / assemblage
explode(",", "a,b,c");         // ["a", "b", "c"]
implode(", ", ["a", "b", "c"]); // "a, b, c"
str_split("abc", 1);           // ["a", "b", "c"]

// Recherche & extraction
preg_match('/(\d+)/', 'Prix: 42€', $matches); // $matches[1] = "42"
preg_match_all('/\d+/', 'a1b2c3', $matches);  // $matches[0] = ["1","2","3"]
preg_replace('/\s+/', ' ', $texte);
preg_split('/,\s*/', "a, b,c");

// Sécurité
htmlspecialchars($input, ENT_QUOTES, 'UTF-8'); // Escaper pour HTML (anti-XSS)
strip_tags($html);             // Supprimer les balises HTML
addslashes($str);              // Échapper
nl2br($texte);                 // \n → <br />

// Hashage
password_hash("monMotDePasse", PASSWORD_BCRYPT); // hachage sécurisé
password_verify("monMotDePasse", $hash);          // vérification
Tableaux
Tableaux indexés, associatifs, fonctions
PHP
<?php

// Tableaux indexés
$fruits = ["pomme", "banane", "cerise"];
$fruits[] = "fraise";           // ajouter à la fin
$fruits[0];                     // "pomme"

// Tableaux associatifs
$user = [
    "nom"   => "Alice",
    "email" => "alice@exemple.fr",
    "age"   => 30,
];
$user["nom"];                   // "Alice"

// Tableaux multidimensionnels
$users = [
    ["id" => 1, "nom" => "Alice"],
    ["id" => 2, "nom" => "Bob"],
];

// Vérification
in_array("banane", $fruits);    // true
array_key_exists("nom", $user); // true
isset($user["age"]);            // true
count($fruits);                 // 4

// Manipulation
array_push($fruits, "melon");
array_pop($fruits);             // supprime et retourne le dernier
array_shift($fruits);           // supprime et retourne le premier
array_unshift($fruits, "ananas"); // ajoute au début
array_splice($fruits, 1, 2);    // supprime 2 éléments à l'index 1
array_slice($fruits, 0, 2);     // retourne les 2 premiers (non mutant)

// Recherche
array_search("banane", $fruits); // index (ou false)
array_keys($user);               // ["nom", "email", "age"]
array_values($user);             // ["Alice", "alice@...", 30]

// Transformations
array_map(fn($n) => $n * 2, [1, 2, 3]);       // [2, 4, 6]
array_filter([1, 0, 2, false, 3], fn($n) => $n > 0); // [1, 2, 3]
array_reduce([1,2,3,4], fn($acc, $n) => $acc + $n, 0); // 10

// Tri
sort($fruits);                   // tri indexé croissant (mute)
rsort($fruits);                  // décroissant
asort($user);                    // tri associatif par valeur
arsort($user);
ksort($user);                    // tri par clé
usort($users, fn($a, $b) => $a["age"] <=> $b["age"]); // personnalisé

// Fusion / Découpage
array_merge($arr1, $arr2);       // fusionne (ré-indexe si numérique)
array_merge_recursive($arr1, $arr2);
$fusionné = [...$arr1, ...$arr2]; // spread operator PHP 7.4+
array_unique([1, 1, 2, 2, 3]);   // [1, 2, 3]
array_flip(["a"=>"b", "c"=>"d"]); // ["b"=>"a", "d"=>"c"]
array_combine(["a","b"], [1,2]);   // ["a"=>1, "b"=>2]
array_chunk([1,2,3,4,5], 2);      // [[1,2],[3,4],[5]]
array_column($users, "nom");      // ["Alice", "Bob"]
Fonctions
Déclaration, paramètres, closures, arrow functions
PHP
<?php

// Déclaration avec types (PHP 7+)
function additionner(int $a, int $b): int {
    return $a + $b;
}

// Paramètres par défaut
function saluer(string $nom, string $salutation = "Bonjour"): string {
    return "$salutation, $nom !";
}

// Type nullable
function trouverUser(?int $id): ?array {
    if ($id === null) return null;
    return ["id" => $id, "nom" => "Alice"];
}

// Union types (PHP 8)
function traiter(int|string $valeur): void {
    echo $valeur;
}

// Valeur de retour multiple (via array)
function diviser(int $a, int $b): array {
    return [$a / $b, $a % $b]; // [quotient, reste]
}
[$quotient, $reste] = diviser(10, 3);

// Passage par référence
function incrementer(int &$n): void {
    $n++;
}
$x = 5;
incrementer($x); // $x vaut maintenant 6

// Nombre variable d'arguments
function somme(int ...$nombres): int {
    return array_sum($nombres);
}
somme(1, 2, 3, 4); // 10

// Closure (fonction anonyme)
$double = function(int $n): int {
    return $n * 2;
};

// Closure avec capture de variables extérieures
$multiplicateur = 3;
$multiplier = function(int $n) use ($multiplicateur): int {
    return $n * $multiplicateur;
};

// Arrow function (PHP 7.4) — capture automatique
$multiplier = fn(int $n) => $n * $multiplicateur;

// Fonctions de première classe (PHP 8.1)
$fn = strlen(...);  // référence à une fonction native
$fn("Bonjour");     // 7
POO — Classes & Objets
Classes, héritage, interfaces, traits
PHP
<?php

// ===== CLASSE DE BASE =====
class Animal {
    // Propriétés typées (PHP 7.4+)
    private string $nom;
    protected int $age;
    public static int $count = 0;

    // Constructeur (PHP 8 : promotion des propriétés)
    public function __construct(
        private string $espece,
        string $nom,
        protected int $pattes = 4
    ) {
        $this->nom = $nom;
        self::$count++;
    }

    // Méthodes
    public function getNom(): string { return $this->nom; }
    public function setNom(string $nom): void { $this->nom = $nom; }

    public function parler(): string {
        return "{$this->nom} fait un bruit.";
    }

    public static function getCount(): int {
        return self::$count;
    }

    // Méthode magique
    public function __toString(): string {
        return "{$this->nom} ({$this->espece})";
    }
}

// ===== HÉRITAGE =====
class Chien extends Animal {
    public function __construct(string $nom, private string $race) {
        parent::__construct("Canis lupus", $nom, 4);
    }

    public function parler(): string {
        return "{$this->getNom()} aboie : Wouf !";
    }

    public function getRace(): string { return $this->race; }
}

// ===== INTERFACE =====
interface Serializable {
    public function toArray(): array;
    public function toJson(): string;
}

// ===== CLASSE ABSTRAITE =====
abstract class Shape {
    abstract public function area(): float;
    public function describe(): string {
        return "Forme avec surface : " . $this->area();
    }
}

class Cercle extends Shape {
    public function __construct(private float $rayon) {}
    public function area(): float { return M_PI * $this->rayon ** 2; }
}

// ===== TRAIT =====
trait Timestampable {
    private DateTime $createdAt;
    private DateTime $updatedAt;

    public function setCreatedAt(): void { $this->createdAt = new DateTime(); }
    public function getCreatedAt(): DateTime { return $this->createdAt; }
}

class Article {
    use Timestampable;
    // Hérite des méthodes du trait
}

// ===== READONLY (PHP 8.1) =====
class Point {
    public function __construct(
        public readonly float $x,
        public readonly float $y,
    ) {}
}
$p = new Point(1.5, 2.5);
// $p->x = 3.0; // Erreur !

// ===== ENUM (PHP 8.1) =====
enum Statut: string {
    case Actif   = 'actif';
    case Inactif = 'inactif';
    case Banni   = 'banni';
}
$s = Statut::Actif;
$s->value;              // "actif"
Statut::from('actif');  // Statut::Actif
Symfony — Installation & CLI
Créer et gérer un projet Symfony
Symfony 7
BASH
# Installer Symfony CLI
curl -sS https://get.symfony.com/cli/installer | bash

# Créer un projet web complet
symfony new mon-projet --webapp

# Créer un projet minimal (API)
symfony new mon-api --no-interaction

# Lancer le serveur de développement
symfony serve
symfony serve -d              # en arrière-plan
symfony server:stop

# Composer
composer require symfony/orm-pack          # Doctrine ORM
composer require symfony/form              # Formulaires
composer require symfony/security-bundle  # Sécurité
composer require symfony/mailer            # Emails
composer require symfony/validator         # Validation
composer require nelmio/cors-bundle        # CORS (API)
composer require lexik/jwt-authentication-bundle # JWT
composer require --dev symfony/maker-bundle     # Générateurs

# Commandes Symfony
php bin/console list                            # toutes les commandes
php bin/console make:controller NomController   # créer un contrôleur
php bin/console make:entity NomEntite           # créer une entité
php bin/console make:migration                  # créer une migration
php bin/console doctrine:migrations:migrate     # exécuter les migrations
php bin/console make:form NomType               # créer un form type
php bin/console make:user                       # créer l'entité User
php bin/console make:auth                       # créer le système d'auth
php bin/console make:crud NomEntite             # CRUD complet
php bin/console debug:router                    # lister toutes les routes
php bin/console debug:container                 # lister les services
php bin/console cache:clear                     # vider le cache
php bin/console doctrine:fixtures:load          # charger les fixtures
Structure du projet Symfony
Arborescence d'un projet Symfony
BASH
mon-projet/
├── config/
│   ├── packages/           # Configuration des bundles
│   ├── routes/             # Configuration des routes (yaml)
│   └── services.yaml       # Configuration des services
├── migrations/             # Migrations Doctrine
├── public/
│   └── index.php           # Point d'entrée
├── src/
│   ├── Controller/         # Contrôleurs
│   ├── Entity/             # Entités Doctrine (modèles)
│   ├── Form/               # Form Types
│   ├── Repository/         # Repositories Doctrine
│   ├── Service/            # Services métier
│   ├── EventSubscriber/    # Écouteurs d'événements
│   └── Kernel.php
├── templates/              # Templates Twig (.html.twig)
├── tests/                  # Tests unitaires et fonctionnels
├── var/
│   ├── cache/
│   └── log/
├── vendor/                 # Dépendances Composer
├── .env                    # Variables d'environnement
├── .env.local              # Variables locales (gitignore)
├── composer.json
└── symfony.lock

Fichier .env — variables d'environnement :

ENV
APP_ENV=dev
APP_SECRET=un_secret_aleatoire_tres_long
DATABASE_URL="mysql://user:password@127.0.0.1:3306/ma_base?serverVersion=8.0"
# PostgreSQL :
# DATABASE_URL="postgresql://user:password@127.0.0.1:5432/ma_base?serverVersion=15"
MAILER_DSN=smtp://localhost:1025
Routes & Contrôleurs
Créer des routes avec attributs PHP 8
PHP
<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\{Request, Response, JsonResponse, RedirectResponse};
use Symfony\Component\Routing\Attribute\Route;

#[Route('/articles', name: 'article_')]
class ArticleController extends AbstractController
{
    // GET /articles
    #[Route('/', name: 'index', methods: ['GET'])]
    public function index(): Response
    {
        return $this->render('article/index.html.twig', [
            'articles' => [],
        ]);
    }

    // GET /articles/42
    #[Route('/{id}', name: 'show', requirements: ['id' => '\d+'], methods: ['GET'])]
    public function show(int $id): Response
    {
        return $this->render('article/show.html.twig', ['id' => $id]);
    }

    // GET /articles/new
    #[Route('/new', name: 'new', methods: ['GET', 'POST'])]
    public function new(Request $request): Response
    {
        // Récupérer données POST
        $titre = $request->request->get('titre');
        $page  = $request->query->get('page', 1); // query string

        return $this->redirectToRoute('article_index');
    }

    // Réponse JSON (API)
    #[Route('/api/articles', name: 'api_list', methods: ['GET'])]
    public function apiList(): JsonResponse
    {
        $data = ['articles' => [], 'total' => 0];
        return $this->json($data, 200);
    }

    // Redirection
    #[Route('/redirect', name: 'redirect')]
    public function redirect(): RedirectResponse
    {
        return $this->redirectToRoute('article_index', [], 302);
    }

    // Flash messages
    public function withFlash(): Response
    {
        $this->addFlash('success', 'Article créé avec succès !');
        $this->addFlash('error', 'Une erreur est survenue.');
        return $this->redirectToRoute('article_index');
    }
}
Twig — Templates
Syntaxe Twig complète
Twig
{# Commentaire Twig #}

{# Affichage #}
{{ variable }}
{{ user.nom }}
{{ user.getNom() }}
{{ 'Bonjour ' ~ nom ~ ' !' }}

{# Filtres #}
{{ nom | upper }}
{{ nom | lower }}
{{ texte | truncate(100, '...') }}
{{ prix | number_format(2, ',', ' ') }}
{{ date | date('d/m/Y') }}
{{ tableau | length }}
{{ tableau | join(', ') }}
{{ texte | nl2br }}
{{ html | raw }}               {# Ne pas échapper — attention XSS #}
{{ valeur | default('N/A') }}  {# Valeur par défaut #}

{# Conditions #}
{% if user %}
    Bonjour {{ user.nom }} !
{% elseif invité %}
    Bienvenue, invité !
{% else %}
    Veuillez vous connecter.
{% endif %}

{# Boucles #}
{% for article in articles %}
    <h2>{{ article.titre }}</h2>
{% else %}
    <p>Aucun article.</p>
{% endfor %}

{# Variables de boucle #}
{% for item in items %}
    {{ loop.index }}     {# 1, 2, 3... #}
    {{ loop.index0 }}    {# 0, 1, 2... #}
    {{ loop.first }}     {# true pour le premier #}
    {{ loop.last }}      {# true pour le dernier #}
    {{ loop.length }}    {# total #}
{% endfor %}

{# Définir une variable #}
{% set titre = 'Mon titre' %}
{% set users = ['Alice', 'Bob'] %}

{# Include #}
{% include 'partials/_navbar.html.twig' %}
{% include 'partials/_card.html.twig' with { titre: 'Mon titre' } %}

{# Héritage de template #}
{# base.html.twig #}
<!DOCTYPE html>
<html>
<head>
    <title>{% block titre %}Mon Site{% endblock %}</title>
    {% block styles %}{% endblock %}
</head>
<body>
    {% block contenu %}{% endblock %}
    {% block scripts %}{% endblock %}
</body>
</html>

{# page.html.twig — hérite de base #}
{% extends 'base.html.twig' %}

{% block titre %}Ma Page — {{ parent() }}{% endblock %}

{% block contenu %}
    <h1>Contenu de la page</h1>
{% endblock %}

{# Routes et assets #}
<a href="{{ path('article_index') }}">Articles</a>
<a href="{{ path('article_show', { id: article.id }) }}">Voir</a>
<img src="{{ asset('images/logo.png') }}" alt="Logo" />

{# Flash messages #}
{% for message in app.flashes('success') %}
    <div class="alert alert-success">{{ message }}</div>
{% endfor %}

{# Vérifier le rôle de l'utilisateur #}
{% if is_granted('ROLE_ADMIN') %}
    <a href="/admin">Admin</a>
{% endif %}
Doctrine — Entités & Migrations
Créer et gérer des entités
PHP
<?php

namespace App\Entity;

use App\Repository\ArticleRepository;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\DBAL\Types\Types;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: ArticleRepository::class)]
#[ORM\HasLifecycleCallbacks]
class Article
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Assert\NotBlank(message: 'Le titre est obligatoire')]
    #[Assert\Length(min: 3, max: 255)]
    private ?string $titre = null;

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    private ?string $contenu = null;

    #[ORM\Column]
    private bool $publie = false;

    #[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true)]
    private ?string $prix = null;

    #[ORM\Column]
    private \DateTimeImmutable $createdAt;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $updatedAt = null;

    // Relation ManyToOne (plusieurs articles → un auteur)
    #[ORM\ManyToOne(inversedBy: 'articles')]
    #[ORM\JoinColumn(nullable: false)]
    private ?User $auteur = null;

    // Relation OneToMany (un article → plusieurs commentaires)
    #[ORM\OneToMany(mappedBy: 'article', targetEntity: Commentaire::class, cascade: ['persist', 'remove'])]
    private Collection $commentaires;

    // Relation ManyToMany (articles ↔ tags)
    #[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'articles')]
    private Collection $tags;

    public function __construct()
    {
        $this->createdAt   = new \DateTimeImmutable();
        $this->commentaires = new ArrayCollection();
        $this->tags         = new ArrayCollection();
    }

    #[ORM\PreUpdate]
    public function onPreUpdate(): void
    {
        $this->updatedAt = new \DateTimeImmutable();
    }

    // Getters & setters générés par make:entity
    public function getId(): ?int { return $this->id; }
    public function getTitre(): ?string { return $this->titre; }
    public function setTitre(string $titre): static { $this->titre = $titre; return $this; }
    // ...
}
BASH
# Créer la migration après modification d'une entité

#Pour créer une base de donnée de 0 :

    .env.local en fonction du .env
        exemple : DATABASE_URL="mysql://root:@127.0.0.1:3307/bucketlist?serverVersion=10.4.32-MariaDB&charset=utf8mb4"

    symfony console doctrine:database:create

#On remplit les champs de la table :
    symfony console make:entity NomDeLaTable

#Ensuite On met les valeurs par défaut dans le fichier de l'entité par exemple et si besoin :

 /**
     * @param bool|null $isPublished
     */
    public function __construct()
    {
        $this->isPublished = false;
    }

 #[ORM\Column(nullable: true, options: ['default' => false])]
    private ?bool $isPublished = null;

#Ensuite on créé le fichier de migration :

    symfony console make:migration

# Ensuite on execute la migration avec :

    symfony console doctrine:migration:migrate


php bin/console make:migration

# Exécuter les migrations
php bin/console doctrine:migrations:migrate

# Vérifier l'état des migrations
php bin/console doctrine:migrations:status

# Revenir à la migration précédente
php bin/console doctrine:migrations:migrate prev

# Recréer la base depuis zéro (développement)
php bin/console doctrine:database:drop --force
php bin/console doctrine:database:create
php bin/console doctrine:migrations:migrate
Fixtures & Faker
Peupler la BDD avec des données de test

Les fixtures permettent de remplir la base de données avec des données fictives pour le développement et les tests. Faker génère automatiquement ces données réalistes (noms, textes, dates…).

1. Installer les dépendances

BASH
# Installer doctrine/doctrine-fixtures-bundle
symfony composer req orm-fixtures --dev

# Installer fakerphp/faker
symfony composer require fakerphp/faker --dev

2. Créer le fichier de fixtures

Après installation, un répertoire src/DataFixtures/ est créé avec un exemple AppFixtures.php.
Le renommer en WishFixtures.php et le remplacer par :

PHP
<?php
// src/DataFixtures/WishFixtures.php

namespace App\DataFixtures;

use App\Entity\Wish;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;

class WishFixtures extends Fixture
{
    public function load(ObjectManager $manager): void
    {
        $faker = \Faker\Factory::create('en_EN');

        for ($i = 1; $i <= 10; $i++) {
            $wish = new Wish();
            $wish->setTitle($faker->word());
            $wish->setAuthor($faker->name());
            $wish->setDescription($faker->realText);

            $dateCreated = $faker->dateTimeBetween('-6 months', 'now');
            $wish->setDateCreated(\DateTimeImmutable::createFromMutable($dateCreated));

            $dateUpdated = $faker->dateTimeBetween($wish->getDateCreated(), 'now');
            $wish->setDateUpdated(\DateTimeImmutable::createFromMutable($dateUpdated));

            $wish->setPublished($faker->numberBetween(0, 1));

            $manager->persist($wish);
        }

        $manager->flush();
    }
}

3. Créer et exécuter les fixtures

BASH


                # Créer le fichier de fixtures
                symfony console make:fixture

                 The class name of the fixtures to create (e.g. AppFixtures):
                 > WishFixtures

                # Charge les fixtures (vide la BDD et insère les données fictives)
                symfony console doctrine:fixtures:load

            

Méthodes Faker courantes

PHP
$faker = \Faker\Factory::create('fr_FR'); // locale française

$faker->word()                        // mot aléatoire
$faker->words(3, true)               // 3 mots en chaîne
$faker->sentence()                   // phrase aléatoire
$faker->paragraph()                  // paragraphe
$faker->realText(200)                // texte réaliste (~200 chars)

$faker->name()                       // "Marie Dupont"
$faker->firstName()                  // "Marie"
$faker->lastName()                   // "Dupont"
$faker->email()                      // "m.dupont@example.com"

$faker->dateTimeBetween('-1 year', 'now')   // DateTime aléatoire
$faker->numberBetween(0, 1)                 // 0 ou 1
$faker->boolean()                           // true / false
$faker->imageUrl(640, 480)                  // URL d'image
Repository & Requêtes
Requêtes Doctrine — EntityManager & QueryBuilder
PHP
<?php

namespace App\Repository;

use App\Entity\Article;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

class ArticleRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Article::class);
    }

    // ===== MÉTHODES DE BASE =====
    // find(id)               → Article|null
    // findAll()              → Article[]
    // findBy(['publie'=>true]) → Article[]
    // findOneBy(['slug'=>$slug]) → Article|null
    // count(['publie'=>true])   → int

    // ===== QUERYBUILDER =====
    public function findPublished(int $limit = 10, int $offset = 0): array
    {
        return $this->createQueryBuilder('a')
            ->where('a.publie = :publie')
            ->setParameter('publie', true)
            ->orderBy('a.createdAt', 'DESC')
            ->setMaxResults($limit)
            ->setFirstResult($offset)
            ->getQuery()
            ->getResult();
    }

    public function findByAuteur(int $auteurId): array
    {
        return $this->createQueryBuilder('a')
            ->join('a.auteur', 'u')
            ->where('u.id = :id')
            ->setParameter('id', $auteurId)
            ->orderBy('a.createdAt', 'DESC')
            ->getQuery()
            ->getResult();
    }

    public function search(string $query): array
    {
        return $this->createQueryBuilder('a')
            ->where('a.titre LIKE :q OR a.contenu LIKE :q')
            ->setParameter('q', '%' . $query . '%')
            ->andWhere('a.publie = true')
            ->getQuery()
            ->getResult();
    }

    // DQL (Doctrine Query Language)
    public function countByMonth(): array
    {
        return $this->getEntityManager()
            ->createQuery('
                SELECT YEAR(a.createdAt) AS annee,
                       MONTH(a.createdAt) AS mois,
                       COUNT(a.id) AS total
                FROM App\Entity\Article a
                GROUP BY annee, mois
                ORDER BY annee DESC, mois DESC
            ')
            ->getResult();
    }
}

// ===== DANS UN CONTRÔLEUR =====
class ArticleController extends AbstractController
{
    public function __construct(
        private ArticleRepository $articleRepo,
        private EntityManagerInterface $em,
    ) {}

    public function create(Request $request): Response
    {
        $article = new Article();
        $article->setTitre($request->request->get('titre'));

        $this->em->persist($article);  // préparer
        $this->em->flush();             // écrire en base

        return $this->redirectToRoute('article_index');
    }

    public function update(Article $article): Response // ParamConverter automatique
    {
        $article->setTitre("Nouveau titre");
        $this->em->flush(); // pas besoin de persist (entité déjà managée)
        return $this->redirectToRoute('article_index');
    }

    public function delete(Article $article): Response
    {
        $this->em->remove($article);
        $this->em->flush();
        return $this->redirectToRoute('article_index');
    }
}
Formulaires Symfony
Form Type, rendu Twig, validation
Bash
# Installer le composant Form et le Validator
composer require symfony/form
composer require symfony/validator

# Installer les thèmes de formulaire Bootstrap (optionnel)
composer require twig/extra-bundle

# Générer un FormType lié à une entité
symfony console make:form ArticleType Article

# Générer un FormType sans entité
symfony console make:form ContactType

# Lister tous les types de champs disponibles
symfony console debug:form

# Afficher les détails d'un type de champ spécifique
symfony console debug:form TextType

# Vider le cache après modification de config/packages/twig.yaml (thème form)
symfony console cache:clear
PHP
<?php

// src/Form/ArticleType.php
namespace App\Form;

use App\Entity\Article;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\{TextType, TextareaType, CheckboxType, SubmitType};
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;

class ArticleType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('titre', TextType::class, [
                'label'       => 'Titre de l\'article',
                'attr'        => ['placeholder' => 'Titre...', 'class' => 'form-control'],
                'constraints' => [new Assert\NotBlank(), new Assert\Length(min: 3)],
            ])
            ->add('contenu', TextareaType::class, [
                'label'    => 'Contenu',
                'required' => false,
                'attr'     => ['rows' => 8],
            ])
            ->add('publie', CheckboxType::class, [
                'label'    => 'Publier l\'article',
                'required' => false,
            ])
            ->add('sauvegarder', SubmitType::class, [
                'label' => 'Enregistrer',
                'attr'  => ['class' => 'btn btn-primary'],
            ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults(['data_class' => Article::class]);
    }
}

// Dans le contrôleur
public function new(Request $request): Response
{
    $article = new Article();
    $form    = $this->createForm(ArticleType::class, $article);

    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $this->em->persist($article);
        $this->em->flush();
        $this->addFlash('success', 'Article créé !');
        return $this->redirectToRoute('article_index');
    }

    return $this->render('article/new.html.twig', [
        'form' => $form,
    ]);
}
Twig
{{ form_start(form) }}
    {{ form_errors(form) }}

    {{ form_row(form.titre) }}
    {{ form_row(form.contenu) }}
    {{ form_row(form.publie) }}

    {# Rendu personnalisé d'un champ #}
    <div class="mb-3">
        {{ form_label(form.titre) }}
        {{ form_widget(form.titre, {'attr': {'class': 'form-control'}}) }}
        {{ form_errors(form.titre) }}
    </div>

    {{ form_widget(form.sauvegarder) }}
{{ form_end(form) }}
YAML — Thème Bootstrap
# config/packages/twig.yaml
twig:
    form_themes: ['bootstrap_5_layout.html.twig']
YAML — Thème Tailwind
# config/packages/twig.yaml
# Nécessite : composer require symfonycasts/tailwind-bundle
twig:
    form_themes: ['tailwind_2_layout.html.twig']
Twig — Bouton personnalisé (sans SubmitType)
{# Ne pas ajouter de SubmitType dans le FormType :
   laisser le bouton directement dans le template pour plus de flexibilité #}

{{ form_start(courseForm) }}

    {{ form_widget(courseForm) }}
    <button class="btn btn-primary">Ajouter</button>

{{ form_end(courseForm) }}
Contraintes de validation
PHP — Contraintes de base
use Symfony\Component\Validator\Constraints as Assert;

$builder
    // Champ obligatoire non vide
    ->add('titre', TextType::class, [
        'constraints' => [new Assert\NotBlank(message: 'Le titre est obligatoire.')],
    ])

    // Longueur min / max
    ->add('slug', TextType::class, [
        'constraints' => [new Assert\Length(min: 3, max: 100)],
    ])

    // Email valide
    ->add('email', EmailType::class, [
        'constraints' => [new Assert\Email(message: 'Email invalide.')],
    ])

    // Nombre entre deux valeurs
    ->add('prix', NumberType::class, [
        'constraints' => [new Assert\Range(min: 0, max: 9999)],
    ])

    // Valeur positive (> 0)
    ->add('quantite', IntegerType::class, [
        'constraints' => [new Assert\Positive()],
    ])

    // Valeur positive ou zéro (>= 0)
    ->add('stock', IntegerType::class, [
        'constraints' => [new Assert\PositiveOrZero()],
    ])

    // URL valide
    ->add('site', UrlType::class, [
        'constraints' => [new Assert\Url()],
    ])

    // Regex personnalisée
    ->add('code', TextType::class, [
        'constraints' => [new Assert\Regex(pattern: '/^[A-Z]{3}\d{3}$/', message: 'Format attendu : ABC123')],
    ])

    // Choix parmi une liste
    ->add('statut', ChoiceType::class, [
        'choices'     => ['Actif' => 'actif', 'Inactif' => 'inactif'],
        'constraints' => [new Assert\Choice(choices: ['actif', 'inactif'])],
    ])

    // Date dans le passé
    ->add('dateNaissance', DateType::class, [
        'constraints' => [new Assert\LessThanOrEqual('today')],
    ])

    // Date dans le futur
    ->add('dateExpiration', DateType::class, [
        'constraints' => [new Assert\GreaterThan('today')],
    ])

    // Fichier uploadé (image, taille max)
    ->add('photo', FileType::class, [
        'constraints' => [
            new Assert\File(
                maxSize: '2M',
                mimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
                mimeTypesMessage: 'Format accepté : JPG, PNG, WEBP'
            ),
        ],
    ])

    // Plusieurs contraintes sur un même champ
    ->add('motDePasse', PasswordType::class, [
        'constraints' => [
            new Assert\NotBlank(),
            new Assert\Length(min: 8, minMessage: '8 caractères minimum.'),
        ],
    ]);
PHP — Contraintes sur l'entité (annotations)
// src/Entity/Article.php
// Les contraintes peuvent aussi être posées directement sur l'entité
// Symfony les lira automatiquement lors de la validation du formulaire

use Symfony\Component\Validator\Constraints as Assert;

class Article
{
    #[Assert\NotBlank]
    #[Assert\Length(min: 3, max: 150)]
    private string $titre;

    #[Assert\NotBlank]
    #[Assert\Length(min: 10)]
    private string $contenu;

    #[Assert\Email]
    private ?string $email = null;

    #[Assert\Positive]
    private int $prix;

    #[Assert\Valid]  // valide les contraintes d'un objet imbriqué
    private ?Auteur $auteur = null;
}
PHP — Contrainte personnalisée (Callback)
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;

$builder->add('username', TextType::class, [
    'constraints' => [
        new Assert\Callback(function (mixed $value, ExecutionContextInterface $context): void {
            if (str_contains($value, 'admin')) {
                $context->buildViolation('Le nom "admin" est interdit.')
                    ->atPath('username')
                    ->addViolation();
            }
        }),
    ],
]);
Twig — Affichage des erreurs
{{ form_start(form) }}

    {# Toutes les erreurs globales du formulaire #}
    {{ form_errors(form) }}

    {# Erreur champ par champ (incluse automatiquement dans form_row) #}
    {{ form_row(form.titre) }}

    {# Affichage manuel label + widget + erreur #}
    <div class="mb-3">
        {{ form_label(form.email) }}
        {{ form_widget(form.email) }}
        {% if form.email.vars.errors|length %}
            <div class="text-danger">
                {% for error in form.email.vars.errors %}
                    <p>{{ error.message }}</p>
                {% endfor %}
            </div>
        {% endif %}
    </div>

    <button class="btn btn-primary">Enregistrer</button>

{{ form_end(form) }}
Sécurité & Authentification
Configuration security.yaml, accès, rôles
YAML
# config/packages/security.yaml
security:
    password_hashers:
        App\Entity\User:
            algorithm: 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
                default_target_path: app_dashboard
            logout:
                path: app_logout
                target: app_home
            remember_me:
                secret: '%kernel.secret%'

    access_control:
        - { path: ^/admin, roles: ROLE_ADMIN }
        - { path: ^/dashboard, roles: ROLE_USER }
        - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
PHP
<?php

// Protéger une action dans un contrôleur
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[IsGranted('ROLE_ADMIN')]
class AdminController extends AbstractController { ... }

#[IsGranted('ROLE_USER')]
public function monCompte(): Response { ... }

// Dans une méthode
public function edit(Article $article): Response
{
    $this->denyAccessUnlessGranted('ROLE_ADMIN');
    // ou
    if (!$this->isGranted('ROLE_ADMIN')) {
        throw $this->createAccessDeniedException();
    }

    $user = $this->getUser();  // utilisateur connecté
    $user->getEmail();
}

// Voter personnalisé (autorisation fine)
// src/Security/ArticleVoter.php
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ArticleVoter extends Voter
{
    const EDIT   = 'EDIT';
    const DELETE = 'DELETE';

    protected function supports(string $attribute, mixed $subject): bool
    {
        return in_array($attribute, [self::EDIT, self::DELETE])
            && $subject instanceof Article;
    }

    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
    {
        $user = $token->getUser();
        if (!$user instanceof User) return false;

        return match($attribute) {
            self::EDIT, self::DELETE => $subject->getAuteur() === $user
                || in_array('ROLE_ADMIN', $user->getRoles()),
            default => false,
        };
    }
}
// Usage : $this->denyAccessUnlessGranted('EDIT', $article);
Commandes Symfony Console — Sécurité
BASH
# Créer l'entité User (UserInterface + PasswordAuthenticatedUserInterface)
symfony console make:user
# → Nom de la classe ? (User)
# → Stocker en BDD ? (yes)
# → Propriété unique d'identification ? (email)
# → Hasher le mot de passe ? (yes)

# Générer un formulaire de login (LoginFormAuthenticator)
symfony console make:security:form-login
# → Génère : src/Security/LoginFormAuthenticator.php
# →          templates/security/login.html.twig
# → Met à jour security.yaml automatiquement

# Créer un authenticator personnalisé (ex : token API, OAuth…)
symfony console make:security:custom
# → Nom de l'authenticator ? (ex: ApiTokenAuthenticator)
# → Génère : src/Security/ApiTokenAuthenticator.php

# Créer un voter personnalisé (autorisation fine sur une ressource)
symfony console make:voter
# → Nom du voter ? (ex: ArticleVoter)
# → Génère : src/Security/ArticleVoter.php

# Hasher un mot de passe manuellement (debug / fixtures)
symfony console security:hash-password

# Vérifier la configuration de sécurité courante
symfony console debug:security
Reset Password
Installation du bundle & génération
BASH
# 1. Installer le bundle reset-password
composer require symfonycasts/reset-password-bundle

# 2. Générer le contrôleur, formulaires, entité ResetPasswordRequest et templates
symfony console make:reset-password
# → Génère :
#    src/Controller/ResetPasswordController.php
#    src/Entity/ResetPasswordRequest.php
#    src/Form/ChangePasswordFormType.php
#    src/Form/ResetPasswordRequestFormType.php
#    templates/reset_password/check_email.html.twig
#    templates/reset_password/email.html.twig
#    templates/reset_password/request.html.twig
#    templates/reset_password/reset.html.twig
#    → Met à jour config/packages/reset_password.yaml

# 3. Migrer la BDD pour créer la table reset_password_request
symfony console make:migration
symfony console doctrine:migrations:migrate
Configuration reset_password.yaml
YAML
# config/packages/reset_password.yaml
symfonycasts_reset_password:
    request_password_repository: App\Repository\ResetPasswordRequestRepository
    lifetime: 3600          # durée de validité du token en secondes (1h)
    throttle_limit: 5       # nb max de demandes par utilisateur sur la fenêtre
    enable_garbage_collection: true  # nettoyage auto des tokens expirés
Entité ResetPasswordRequest
PHP
<?php
// src/Entity/ResetPasswordRequest.php (généré automatiquement)
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestTrait;

#[ORM\Entity(repositoryClass: ResetPasswordRequestRepository::class)]
class ResetPasswordRequest implements ResetPasswordRequestInterface
{
    use ResetPasswordRequestTrait;

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\ManyToOne]
    #[ORM\JoinColumn(nullable: false)]
    private User $user;

    public function __construct(User $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken)
    {
        $this->user = $user;
        $this->initialize($expiresAt, $selector, $hashedToken);
    }

    public function getUser(): User
    {
        return $this->user;
    }
}
Contrôleur ResetPasswordController — étapes clés
PHP
<?php
// src/Controller/ResetPasswordController.php
namespace App\Controller;

use App\Entity\User;
use App\Form\ChangePasswordFormType;
use App\Form\ResetPasswordRequestFormType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use SymfonyCasts\Bundle\ResetPassword\Controller\ResetPasswordControllerTrait;
use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;

#[Route('/reset-password')]
class ResetPasswordController extends AbstractController
{
    use ResetPasswordControllerTrait;  // fournit storeTokenInSession(), getTokenFromSession()…

    public function __construct(
        private ResetPasswordHelperInterface $resetPasswordHelper,
        private EntityManagerInterface $entityManager,
    ) {}

    // ── ÉTAPE 1 : Formulaire de demande ──────────────────────────────────────
    #[Route('', name: 'app_forgot_password_request')]
    public function request(Request $request, MailerInterface $mailer): Response
    {
        $form = $this->createForm(ResetPasswordRequestFormType::class);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            return $this->processSendingPasswordResetEmail(
                $form->get('email')->getData(),
                $mailer,
            );
        }

        return $this->render('reset_password/request.html.twig', [
            'requestForm' => $form,
        ]);
    }

    // ── ÉTAPE 2 : Page "vérifiez vos emails" ────────────────────────────────
    #[Route('/check-email', name: 'app_check_email')]
    public function checkEmail(): Response
    {
        // Génère un faux token si l'utilisateur arrive ici directement
        if (null === ($resetToken = $this->getTokenFromSession())) {
            $resetToken = $this->resetPasswordHelper->generateFakeResetToken();
        }

        return $this->render('reset_password/check_email.html.twig', [
            'resetToken' => $resetToken,
        ]);
    }

    // ── ÉTAPE 3 : Formulaire de nouveau mot de passe ─────────────────────────
    #[Route('/reset/{token}', name: 'app_reset_password')]
    public function reset(
        Request $request,
        UserPasswordHasherInterface $passwordHasher,
        ?string $token = null,
    ): Response {
        if ($token) {
            // Stocker le token en session pour éviter la fuite via Referer
            $this->storeTokenInSession($token);
            return $this->redirectToRoute('app_reset_password');
        }

        $token = $this->getTokenFromSession();
        if (null === $token) {
            throw $this->createNotFoundException('Aucun token de réinitialisation trouvé en session.');
        }

        try {
            /** @var User $user */
            $user = $this->resetPasswordHelper->validateTokenAndFetchUser($token);
        } catch (ResetPasswordExceptionInterface $e) {
            $this->addFlash('reset_password_error', sprintf(
                '%s - %s', ResetPasswordExceptionInterface::MESSAGE_PROBLEM_VALIDATE, $e->getReason()
            ));
            return $this->redirectToRoute('app_forgot_password_request');
        }

        $form = $this->createForm(ChangePasswordFormType::class);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            // Invalider tous les tokens de cet utilisateur
            $this->resetPasswordHelper->removeResetRequest($token);

            $hashedPassword = $passwordHasher->hashPassword(
                $user,
                $form->get('plainPassword')->getData()
            );
            $user->setPassword($hashedPassword);
            $this->entityManager->flush();

            $this->cleanSessionAfterReset();

            return $this->redirectToRoute('app_login');
        }

        return $this->render('reset_password/reset.html.twig', [
            'resetForm' => $form,
        ]);
    }

    // ── Méthode privée : envoi de l'email ────────────────────────────────────
    private function processSendingPasswordResetEmail(string $emailFormData, MailerInterface $mailer): Response
    {
        $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $emailFormData]);

        if (!$user) {
            // Ne pas révéler qu'aucun compte n'existe
            return $this->redirectToRoute('app_check_email');
        }

        try {
            $resetToken = $this->resetPasswordHelper->generateResetToken($user);
        } catch (ResetPasswordExceptionInterface $e) {
            return $this->redirectToRoute('app_check_email');
        }

        $email = (new TemplatedEmail())
            ->from('noreply@monsite.fr')
            ->to($user->getEmail())
            ->subject('Réinitialisation de votre mot de passe')
            ->htmlTemplate('reset_password/email.html.twig')
            ->context(['resetToken' => $resetToken]);

        $mailer->send($email);

        // Stocker le token en session pour la page check_email
        $this->storeTokenInSession($resetToken);

        return $this->redirectToRoute('app_check_email');
    }
}
Templates Twig essentiels
TWIG
{# templates/reset_password/request.html.twig #}
{# Formulaire : saisie de l'adresse email #}
<h1>Mot de passe oublié ?</h1>
{{ form_start(requestForm) }}
    {{ form_row(requestForm.email, { label: 'Votre adresse email' }) }}
    <button type="submit">Envoyer le lien</button>
{{ form_end(requestForm) }}

{# templates/reset_password/check_email.html.twig #}
{# Message affiché après envoi du mail #}
<h1>Vérifiez vos emails !</h1>
<p>
  Un email a été envoyé avec un lien valable
  {{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}.
</p>

{# templates/reset_password/email.html.twig #}
{# Corps de l'email envoyé à l'utilisateur #}
<p>Bonjour,</p>
<p>
  <a href="{{ url('app_reset_password', {token: resetToken.token}) }}">
    Réinitialiser mon mot de passe
  </a>
</p>
<p>Ce lien expire dans 1 heure.</p>

{# templates/reset_password/reset.html.twig #}
{# Formulaire : saisie du nouveau mot de passe #}
<h1>Choisissez un nouveau mot de passe</h1>
{{ form_start(resetForm) }}
    {{ form_row(resetForm.plainPassword) }}
    <button type="submit">Valider</button>
{{ form_end(resetForm) }}
Flux complet — résumé des étapes
BASH
# ── Flux utilisateur ──────────────────────────────────────────────────────────
# 1. L'utilisateur clique "Mot de passe oublié" → GET /reset-password
# 2. Il saisit son email et soumet → POST /reset-password
#    → Le contrôleur génère un ResetPasswordRequest en BDD
#    → Un email avec un lien signé /reset-password/reset/{token} est envoyé
#    → Redirect vers /reset-password/check-email
# 3. L'utilisateur clique le lien dans l'email → GET /reset-password/reset/{token}
#    → Le token est stocké en session, redirect sur /reset-password/reset (sans token dans l'URL)
# 4. L'utilisateur saisit son nouveau mot de passe → POST /reset-password/reset
#    → Le token est validé, le mot de passe hashé et sauvegardé
#    → Le ResetPasswordRequest est supprimé de la BDD
#    → Redirect vers /login

# ── Points de sécurité importants ────────────────────────────────────────────
# • Le token est stocké en session (pas dans l'URL) pour éviter la fuite via Referer
# • Si l'email n'existe pas, on redirige quand même (anti-enumeration)
# • Le token est à usage unique : il est supprimé après utilisation
# • throttle_limit empêche le spam de demandes
# • Les tokens expirés sont nettoyés automatiquement (garbage collection)
Services & Injection de dépendances
Créer et injecter un service
PHP
<?php

// src/Service/MailService.php
namespace App\Service;

use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;

class MailService
{
    public function __construct(
        private MailerInterface $mailer,
        private string $senderEmail,  // injecté depuis services.yaml
    ) {}

    public function sendWelcome(string $to, string $nom): void
    {
        $email = (new Email())
            ->from($this->senderEmail)
            ->to($to)
            ->subject('Bienvenue !')
            ->html("<p>Bonjour {$nom}, bienvenue !</p>");

        $this->mailer->send($email);
    }
}

// config/services.yaml
services:
    App\Service\MailService:
        arguments:
            $senderEmail: 'noreply@monsite.fr'

// Injection dans un contrôleur
class RegisterController extends AbstractController
{
    public function __construct(private MailService $mailService) {}

    public function register(Request $request): Response
    {
        // ... créer l'utilisateur ...
        $this->mailService->sendWelcome($user->getEmail(), $user->getNom());
        return $this->redirectToRoute('app_login');
    }
}
API Platform
Installation & configuration
Essentiel

API Platform s'installe via Composer et expose automatiquement une API REST + documentation Swagger/OpenAPI à partir de vos entités Doctrine.

BASH
# Installer API Platform dans un projet Symfony existant
composer require api

# Lancer le serveur de dev
symfony server:start

# L'interface Swagger est accessible à :
# http://localhost:8000/api   (navigateur)
# http://localhost:8000/api/docs.json  (spec OpenAPI JSON)
YAML
# config/packages/api_platform.yaml
api_platform:
    title: 'Mon API'
    version: '1.0.0'
    formats:
        jsonld:   ['application/ld+json']
        json:     ['application/json']
        html:     ['text/html']       # active l'interface Swagger
    defaults:
        pagination_items_per_page: 10
        stateless: true
        cache_headers:
            vary: ['Content-Type', 'Authorization']
Déclarer une ressource avec #[ApiResource]
Essentiel

L'attribut #[ApiResource] posé sur une entité Doctrine suffit pour générer toutes les routes CRUD (GET, POST, PUT, PATCH, DELETE).

PHP
<?php
// src/Entity/Article.php
namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use App\Repository\ArticleRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: ArticleRepository::class)]
#[ApiResource]   // ← une seule ligne suffit pour exposer l'entité
class Article
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Assert\NotBlank]
    #[Assert\Length(min: 3, max: 255)]
    private string $titre = '';

    #[ORM\Column(type: 'text')]
    #[Assert\NotBlank]
    private string $contenu = '';

    #[ORM\Column]
    private \DateTimeImmutable $createdAt;

    public function __construct()
    {
        $this->createdAt = new \DateTimeImmutable();
    }

    // Getters / Setters...
    public function getId(): ?int { return $this->id; }
    public function getTitre(): string { return $this->titre; }
    public function setTitre(string $titre): static { $this->titre = $titre; return $this; }
    public function getContenu(): string { return $this->contenu; }
    public function setContenu(string $contenu): static { $this->contenu = $contenu; return $this; }
    public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
}
BASH
# Créer l'entité avec le maker (sans #[ApiResource], on l'ajoute manuellement)
php bin/console make:entity Article

# Générer et exécuter la migration
php bin/console make:migration
php bin/console doctrine:migrations:migrate

# Routes générées automatiquement par API Platform :
# GET    /api/articles          → liste paginée
# POST   /api/articles          → créer un article
# GET    /api/articles/{id}     → lire un article
# PUT    /api/articles/{id}     → remplacer un article
# PATCH  /api/articles/{id}     → modifier partiellement
# DELETE /api/articles/{id}     → supprimer un article
Choisir les opérations exposées
Avancé

Par défaut toutes les opérations sont activées. On peut restreindre, renommer les routes ou ajouter des opérations personnalisées.

PHP
<?php
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Delete;

// N'autoriser que lecture + création + modification partielle
#[ApiResource(
    operations: [
        new GetCollection(),          // GET /api/articles
        new Get(),                    // GET /api/articles/{id}
        new Post(),                   // POST /api/articles
        new Patch(),                  // PATCH /api/articles/{id}
        // Delete et Put sont omis → routes inexistantes
    ]
)]
// Personnaliser le chemin et le nom des routes
#[ApiResource(
    operations: [
        new GetCollection(uriTemplate: '/articles'),          // /api/articles
        new Get(uriTemplate: '/articles/{id}'),
        new Post(uriTemplate: '/articles', routeName: 'api_article_create'),
    ]
)]
class Article { /* ... */ }
Groupes de sérialisation
Essentiel

Les groupes Symfony Serializer permettent de contrôler quelles propriétés sont exposées en lecture (normalization) et en écriture (denormalization).

PHP
<?php
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use Symfony\Component\Serializer\Annotation\Groups;

#[ApiResource(
    normalizationContext:   ['groups' => ['article:read']],   // GET → ces champs
    denormalizationContext: ['groups' => ['article:write']],  // POST/PUT → ces champs
)]
class Article
{
    #[Groups(['article:read'])]           // visible en lecture seulement
    private ?int $id = null;

    #[Groups(['article:read', 'article:write'])]  // lecture ET écriture
    private string $titre = '';

    #[Groups(['article:read', 'article:write'])]
    private string $contenu = '';

    #[Groups(['article:read'])]           // calculé, non modifiable
    private \DateTimeImmutable $createdAt;
}

// Résultat JSON en GET /api/articles/1
// {
//   "id": 1,
//   "titre": "Mon article",
//   "contenu": "Contenu de l'article",
//   "createdAt": "2024-01-15T10:00:00+00:00"
// }

// Corps attendu en POST /api/articles
// {
//   "titre": "Nouvel article",
//   "contenu": "Contenu..."
// }
Filtres intégrés
Essentiel

API Platform fournit des filtres prêts à l'emploi : recherche textuelle, plage de valeurs, tri, existence d'une propriété…

PHP
<?php
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;

#[ApiResource]
// SearchFilter : ?titre=symfony  (partial = contient, exact, start, end, word_start)
#[ApiFilter(SearchFilter::class, properties: [
    'titre'    => 'partial',   // LIKE %symfony%
    'categorie'=> 'exact',     // = 'php'
    'auteur.nom'=> 'start',    // LIKE 'alice%'  (relation imbriquée)
])]
// RangeFilter : ?prix[gt]=10&prix[lte]=50
#[ApiFilter(RangeFilter::class, properties: ['prix'])]
// OrderFilter : ?order[createdAt]=desc&order[titre]=asc
#[ApiFilter(OrderFilter::class, properties: ['titre', 'createdAt', 'prix'])]
// BooleanFilter : ?publie=true
#[ApiFilter(BooleanFilter::class, properties: ['publie'])]
// DateFilter : ?createdAt[after]=2024-01-01
#[ApiFilter(DateFilter::class, properties: ['createdAt'])]
class Article { /* ... */ }

// Exemples d'URL résultantes :
// GET /api/articles?titre=symfony
// GET /api/articles?titre=symfony&order[createdAt]=desc&page=2
// GET /api/articles?prix[gt]=10&prix[lte]=50&publie=true
Pagination
Config

La pagination est activée par défaut. Elle peut être configurée globalement ou par ressource, et contrôlée via un paramètre d'URL.

PHP
<?php
use ApiPlatform\Metadata\ApiResource;

#[ApiResource(
    paginationEnabled: true,
    paginationItemsPerPage: 20,          // 20 items par page
    paginationMaximumItemsPerPage: 100,  // max autorisé via ?itemsPerPage=
    paginationClientItemsPerPage: true,  // le client peut choisir via ?itemsPerPage=30
    paginationClientEnabled: true,       // le client peut désactiver via ?pagination=false
)]
class Article { /* ... */ }

// Réponse JSON-LD d'une collection paginée :
// {
//   "@context": "/api/contexts/Article",
//   "@id": "/api/articles",
//   "@type": "hydra:Collection",
//   "hydra:totalItems": 87,
//   "hydra:member": [ {...}, {...}, ... ],
//   "hydra:view": {
//     "@id": "/api/articles?page=2",
//     "hydra:first": "/api/articles?page=1",
//     "hydra:last":  "/api/articles?page=9",
//     "hydra:next":  "/api/articles?page=3"
//   }
// }
BASH
# Naviguer entre les pages
curl http://localhost:8000/api/articles?page=2
curl http://localhost:8000/api/articles?page=1&itemsPerPage=5

# Désactiver la pagination pour tout récupérer (si autorisé)
curl http://localhost:8000/api/articles?pagination=false
Sécurité des opérations
Sécurité

Les accès se protègent avec l'attribut security (expression Symfony) ou securityPostDenormalize (après désérialisation).

PHP
<?php
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;

#[ApiResource(
    operations: [
        // Tout le monde peut lire
        new GetCollection(),
        new Get(),

        // Seuls les utilisateurs connectés peuvent créer
        new Post(
            security: "is_granted('ROLE_USER')",
            securityMessage: 'Vous devez être connecté pour créer un article.',
        ),

        // Seul le propriétaire ou un admin peut modifier
        new Patch(
            security: "is_granted('ROLE_ADMIN') or object.getAuteur() == user",
            securityMessage: "Vous ne pouvez modifier que vos propres articles.",
        ),

        // Seul un admin peut supprimer
        new Delete(
            security: "is_granted('ROLE_ADMIN')",
        ),
    ]
)]
class Article
{
    #[ORM\ManyToOne]
    private ?User $auteur = null;

    public function getAuteur(): ?User { return $this->auteur; }
}
BASH
# Requête sans token → 401 Unauthorized sur les routes protégées
curl -X POST http://localhost:8000/api/articles \
     -H "Content-Type: application/json" \
     -d '{"titre":"Test","contenu":"..."}'

# Requête avec JWT (si lexik/jwt-authentication-bundle est installé)
curl -X POST http://localhost:8000/api/articles \
     -H "Content-Type: application/json" \
     -H "Authorization: Bearer <votre_token_jwt>" \
     -d '{"titre":"Test","contenu":"..."}'
Relations entre ressources
Avancé

API Platform gère nativement les relations Doctrine. Une relation peut être exposée comme IRI (lien), comme objet imbriqué, ou les deux.

PHP
<?php
// src/Entity/Commentaire.php
use ApiPlatform\Metadata\ApiResource;
use Symfony\Component\Serializer\Annotation\Groups;

#[ORM\Entity]
#[ApiResource(
    normalizationContext:   ['groups' => ['commentaire:read']],
    denormalizationContext: ['groups' => ['commentaire:write']],
)]
class Commentaire
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    #[Groups(['commentaire:read'])]
    private ?int $id = null;

    #[ORM\Column(type: 'text')]
    #[Groups(['commentaire:read', 'commentaire:write'])]
    private string $contenu = '';

    // Relation vers Article : exposée comme IRI "/api/articles/3"
    #[ORM\ManyToOne(targetEntity: Article::class)]
    #[Groups(['commentaire:read', 'commentaire:write'])]
    private ?Article $article = null;
}

// src/Entity/Article.php — exposer les commentaires imbriqués
#[ApiResource(
    normalizationContext: ['groups' => ['article:read']],
)]
class Article
{
    #[ORM\OneToMany(targetEntity: Commentaire::class, mappedBy: 'article')]
    #[Groups(['article:read'])]   // retourne les IRI des commentaires
    private Collection $commentaires;
}
BASH
# Créer un commentaire lié à l'article #3 (on passe son IRI)
curl -X POST http://localhost:8000/api/commentaires \
     -H "Content-Type: application/json" \
     -d '{
       "contenu": "Super article !",
       "article": "/api/articles/3"
     }'

# Réponse :
# {
#   "@id": "/api/commentaires/1",
#   "id": 1,
#   "contenu": "Super article !",
#   "article": "/api/articles/3"   ← IRI, pas l'objet complet
# }
State Processor — logique métier sur écriture
Avancé

Un State Processor remplace l'enregistrement par défaut et permet d'exécuter de la logique métier avant ou après la persistance (envoi d'email, log, etc.).

PHP
<?php
// src/State/ArticleProcessor.php
namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Article;
use App\Service\MailService;
use Doctrine\ORM\EntityManagerInterface;

class ArticleProcessor implements ProcessorInterface
{
    public function __construct(
        private EntityManagerInterface $em,
        private MailService $mailService,
    ) {}

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Article
    {
        // $data est l'entité désérialisée et validée
        /** @var Article $data */

        // Logique métier : assigner l'auteur connecté
        $data->setAuteur($context['security']['user'] ?? null);

        // Persister
        $this->em->persist($data);
        $this->em->flush();

        // Envoyer une notification
        $this->mailService->sendWelcome('admin@site.fr', 'Nouvel article publié');

        return $data;
    }
}

// Rattacher le processor à l'opération POST de l'entité
use ApiPlatform\Metadata\Post;

#[ApiResource(
    operations: [
        new Post(processor: ArticleProcessor::class),
    ]
)]
class Article { /* ... */ }
BASH
# Le service est auto-détecté grâce à l'autowiring Symfony
# Aucune configuration supplémentaire dans services.yaml n'est nécessaire

# Vérifier que le processor est bien enregistré
php bin/console debug:container ArticleProcessor
State Provider — source de données personnalisée
Avancé

Un State Provider permet d'alimenter une ressource depuis une source non-Doctrine (API externe, cache Redis, calcul à la volée…).

PHP
<?php
// src/State/ArticleProvider.php
namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Article;
use App\Repository\ArticleRepository;

class ArticleProvider implements ProviderInterface
{
    public function __construct(private ArticleRepository $repo) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
    {
        // Pour un GET item : récupérer et enrichir
        if (isset($uriVariables['id'])) {
            $article = $this->repo->find($uriVariables['id']);
            // Enrichissement personnalisé, ex: ajout d'un champ calculé
            return $article;
        }

        // Pour une collection : filtrage personnalisé
        return $this->repo->findPublished();
    }
}

// Rattacher le provider
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;

#[ApiResource(
    operations: [
        new GetCollection(provider: ArticleProvider::class),
        new Get(provider: ArticleProvider::class),
    ]
)]
class Article { /* ... */ }
Validation & format des erreurs
Essentiel

Les contraintes Symfony Validator sont automatiquement déclenchées. Les erreurs sont retournées en JSON selon la spécification Hydra.

PHP
<?php
use Symfony\Component\Validator\Constraints as Assert;

class Article
{
    #[Assert\NotBlank(message: 'Le titre est obligatoire.')]
    #[Assert\Length(min: 3, max: 255, minMessage: 'Minimum {{ limit }} caractères.')]
    private string $titre = '';

    #[Assert\NotBlank]
    #[Assert\Length(min: 10)]
    private string $contenu = '';

    #[Assert\Url(message: 'URL invalide.')]
    private ?string $imageUrl = null;
}

// Si la validation échoue, API Platform retourne HTTP 422 Unprocessable Entity :
// {
//   "@context": "/api/contexts/ConstraintViolationList",
//   "@type": "ConstraintViolationList",
//   "hydra:title": "An error occurred",
//   "violations": [
//     {
//       "propertyPath": "titre",
//       "message": "Le titre est obligatoire.",
//       "code": "c1051bb4-d103-4f74-8988-acbcafc7fdc3"
//     }
//   ]
// }
BASH
# Tester la validation avec des données invalides
curl -X POST http://localhost:8000/api/articles \
     -H "Content-Type: application/json" \
     -d '{"titre":"","contenu":"x"}' \
     -w "\nHTTP Status: %{http_code}\n"
# → HTTP Status: 422 avec la liste des violations
Authentification JWT avec LexikJWTBundle
Sécurité

Le bundle lexik/jwt-authentication-bundle est la solution standard pour sécuriser une API Platform avec des tokens JWT.

BASH
# 1. Installer le bundle
composer require lexik/jwt-authentication-bundle

# 2. Générer les clés RSA
php bin/console lexik:jwt:generate-keypair
# → crée config/jwt/private.pem et config/jwt/public.pem

# 3. Obtenir un token (POST /api/login_check)
curl -X POST http://localhost:8000/api/login_check \
     -H "Content-Type: application/json" \
     -d '{"username":"admin@site.fr","password":"motdepasse"}'
# Réponse : { "token": "eyJ0eX..." }

# 4. Utiliser le token dans les requêtes suivantes
curl http://localhost:8000/api/articles \
     -H "Authorization: Bearer eyJ0eX..."
YAML
# config/packages/security.yaml (extrait)
security:
    firewalls:
        login:
            pattern: ^/api/login_check
            stateless: true
            json_login:
                check_path: /api/login_check
                username_path: username
                password_path: password
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure

        api:
            pattern: ^/api
            stateless: true
            jwt: ~

    access_control:
        - { path: ^/api/login_check, roles: PUBLIC_ACCESS }
        - { path: ^/api/docs,        roles: PUBLIC_ACCESS }
        - { path: ^/api,             roles: IS_AUTHENTICATED_FULLY }
Commandes & outils de débogage
CLI

Commandes Symfony Console et outils curl / jq indispensables pour travailler avec API Platform au quotidien.

BASH
# Lister toutes les routes exposées par API Platform
php bin/console debug:router | grep api

# Inspecter la configuration d'une ressource
php bin/console debug:api-resource 'App\Entity\Article'

# Vider le cache (indispensable après un changement d'attributs)
php bin/console cache:clear

# Tester l'API depuis le terminal avec curl + jq (pretty print)
curl -s http://localhost:8000/api/articles | jq .

# Créer un article (JSON)
curl -X POST http://localhost:8000/api/articles \
     -H "Content-Type: application/json" \
     -d '{"titre":"Mon titre","contenu":"Mon contenu"}' | jq .

# Modifier partiellement (PATCH)
curl -X PATCH http://localhost:8000/api/articles/1 \
     -H "Content-Type: application/merge-patch+json" \
     -d '{"titre":"Titre modifié"}' | jq .

# Supprimer
curl -X DELETE http://localhost:8000/api/articles/1 -w "%{http_code}"
# → 204 No Content

# Filtrer et trier
curl "http://localhost:8000/api/articles?titre=symfony&order[createdAt]=desc&page=1" | jq .

# Afficher la spécification OpenAPI générée
curl http://localhost:8000/api/docs.json | jq '.paths | keys'

Aucun résultat pour votre recherche.