PHP / Symfony PHP 8.2 — Symfony 7
Bases PHP, programmation orientée objet, et le framework Symfony complet avec Doctrine ORM.
<?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']);
<?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;
}
<?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
<?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"]
<?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
<?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
# 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
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 :
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
<?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');
}
}
{# 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 %}
<?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; }
// ...
}
# 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
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
# 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
// 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
# 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
$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
<?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');
}
}
# 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
// 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,
]);
}
{{ 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) }}
# config/packages/twig.yaml
twig:
form_themes: ['bootstrap_5_layout.html.twig']
# config/packages/twig.yaml
# Nécessite : composer require symfonycasts/tailwind-bundle
twig:
form_themes: ['tailwind_2_layout.html.twig']
{# 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) }}
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.'),
],
]);
// 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;
}
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();
}
}),
],
]);
{{ 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) }}
# 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
// 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);
# 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
# 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
# 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
<?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;
}
}
<?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/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 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)
<?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 s'installe via Composer et expose automatiquement une API REST + documentation Swagger/OpenAPI à partir de vos entités Doctrine.
# 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)
# 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']
#[ApiResource]L'attribut #[ApiResource] posé sur une entité Doctrine suffit pour générer toutes les routes CRUD (GET, POST, PUT, PATCH, DELETE).
<?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; }
}
# 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
Par défaut toutes les opérations sont activées. On peut restreindre, renommer les routes ou ajouter des opérations personnalisées.
<?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 { /* ... */ }
Les groupes Symfony Serializer permettent de contrôler quelles propriétés sont exposées en lecture (normalization) et en écriture (denormalization).
<?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..."
// }
API Platform fournit des filtres prêts à l'emploi : recherche textuelle, plage de valeurs, tri, existence d'une propriété…
<?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
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
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"
// }
// }
# 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
Les accès se protègent avec l'attribut security (expression Symfony) ou securityPostDenormalize (après désérialisation).
<?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; }
}
# 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":"..."}'
API Platform gère nativement les relations Doctrine. Une relation peut être exposée comme IRI (lien), comme objet imbriqué, ou les deux.
<?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;
}
# 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
# }
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
// 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 { /* ... */ }
# 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
Un State Provider permet d'alimenter une ressource depuis une source non-Doctrine (API externe, cache Redis, calcul à la volée…).
<?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 { /* ... */ }
Les contraintes Symfony Validator sont automatiquement déclenchées. Les erreurs sont retournées en JSON selon la spécification Hydra.
<?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"
// }
// ]
// }
# 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
Le bundle lexik/jwt-authentication-bundle est la solution standard pour sécuriser une API Platform avec des tokens JWT.
# 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..."
# 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 Symfony Console et outils curl / jq indispensables pour travailler avec API Platform au quotidien.
# 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.