Symfony est le framework PHP de référence en entreprise. Ce tutoriel crée une application blog complète : entités Doctrine avec migrations, controllers RESTful, formulaires avec validation, templates Twig et authentification utilisateur.
Définition : Séparation de l'application en trois couches : Model (données), View (présentation), Controller (logique métier).
But : Organiser le code pour que chaque couche ait une responsabilité unique, facilitant la maintenance et les tests.
Pourquoi ici : Symfony impose ce pattern : requête HTTP → Router → Controller → Model/ORM → View (template Twig) → réponse HTML/JSON.
# Installer le CLI Symfony
curl -sS https://get.symfony.com/cli/installer | bash
# Créer un projet web complet
symfony new mon-blog --version="7.*" --webapp
cd mon-blog
# Démarrer le serveur de développement
symfony server:start -d
# Installer les bundles nécessaires
composer require security
composer require form validator
composer require twig/twig
composer require knplabs/knp-paginator-bundle
# SQLite pour le développement
DATABASE_URL="sqlite:///%kernel.project_dir%/var/app.db"
# PostgreSQL pour la production
# DATABASE_URL="postgresql://user:password@127.0.0.1:5432/blog?serverVersion=16"
Définition : Classe PHP mappée à une table de base de données via Doctrine ORM. Chaque propriété public correspond à une colonne.
But : Représenter les données métier en PHP plutôt que avec du SQL brut.
Pourquoi ici : Doctrine utilise les annotations/attributes PHP pour mapper les propriétés aux colonnes et définir les relations, les validations et les callbacks.
Définition : Couche d'abstraction qui traduit les objets PHP en requêtes SQL et vice versa.
But : Écrire du code métier en PHP sans toucher à du SQL, pour la maintenabilité et la sécurité (prévention SQL injection).
Pourquoi ici : Doctrine est l'ORM PHP de référence, offrant lazy loading, lazy evaluation et requêtes type-safe.
# Créer l'entité Article interactivement
php bin/console make:entity Article
# Saisir les champs :
# title → string(255) not null
# content → text not null
# excerpt → string(500) nullable
# slug → string(255) not null unique
# publishedAt → datetime nullable
# views → integer default 0
<?php
namespace App\Entity;
use App\Repository\ArticleRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
#[ORM\HasLifecycleCallbacks]
#[UniqueEntity(fields: ['slug'], message: 'Ce slug est déjà utilisé')]
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(max: 255)]
private string $title = '';
#[ORM\Column(type: 'text')]
#[Assert\NotBlank]
#[Assert\Length(min: 10)]
private string $content = '';
#[ORM\Column(length: 255, unique: true)]
private string $slug = '';
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $publishedAt = null;
#[ORM\Column(options: ['default' => 0])]
private int $views = 0;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\PrePersist]
public function onPrePersist(): void
{
$this->createdAt = new \DateTimeImmutable();
$this->generateSlug();
}
private function generateSlug(): void
{
$this->slug = strtolower(trim(preg_replace(
'/[^A-Za-z0-9-]+/', '-', $this->title
), '-'));
}
public function isPublished(): bool
{
return $this->publishedAt !== null && $this->publishedAt <= new \DateTimeImmutable();
}
// Getters et setters générés automatiquement...
public function getId(): ?int { return $this->id; }
public function getTitle(): string { return $this->title; }
public function setTitle(string $title): static { $this->title = $title; return $this; }
public function getContent(): string { return $this->content; }
public function setContent(string $content): static { $this->content = $content; return $this; }
public function getSlug(): string { return $this->slug; }
public function getPublishedAt(): ?\DateTimeImmutable { return $this->publishedAt; }
public function setPublishedAt(?\DateTimeImmutable $date): static { $this->publishedAt = $date; return $this; }
public function getViews(): int { return $this->views; }
public function incrementViews(): static { $this->views++; return $this; }
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
}
Définition : Classe contenant les requêtes SQL (via QueryBuilder) pour une entité. Elle encapsule la logique d'accès aux données.
But : Garder la logique de requête en dehors du controller, pour réutilisabilité et testabilité.
Pourquoi ici : Au lieu d'écrire du SQL dans le controller, on appelle $this->articleRepository->findPublished(), une méthode métier lisible et réutilisable.
<?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);
}
/** Retourne les articles publiés, triés par date de publication */
public function findPublished(int $limit = 10): array
{
return $this->createQueryBuilder('a')
->where('a.publishedAt IS NOT NULL')
->andWhere('a.publishedAt <= :now')
->setParameter('now', new \DateTimeImmutable())
->orderBy('a.publishedAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
/** Recherche full-text dans le titre et le contenu */
public function search(string $query): array
{
return $this->createQueryBuilder('a')
->where('a.title LIKE :q OR a.content LIKE :q')
->setParameter('q', '%' . $query . '%')
->andWhere('a.publishedAt IS NOT NULL')
->orderBy('a.publishedAt', 'DESC')
->getQuery()
->getResult();
}
}
createQueryBuilder('a') crée une requête sur Article aliasée 'a'. Les méthodes where(), orderBy(), getResult() correspondent à SELECT, WHERE, ORDER BY, FETCH.Définition : Classe contenant les actions (méthodes publiques) qui traitent les requêtes HTTP et retournent des réponses.
But : Orchestrer la logique : récupérer les données du repository, exécuter la logique métier, appeler la vue.
Pourquoi ici : Les actions sont mappées aux routes via attributs #[Route(...)]. Symfony injecte automatiquement les dépendances (repository, EntityManager).
Définition : Technique où l'application fournit les objets nécessaires (dépendances) aux classes, au lieu que celles-ci les créent.
But : Découpler les dépendances, faciliter les tests (mocker les dépendances) et favoriser la réutilisabilité.
Pourquoi ici : Symfony injecte ArticleRepository et EntityManagerInterface directement dans les actions, sans les instancier manuellement.
<?php
namespace App\Controller;
use App\Entity\Article;
use App\Form\ArticleType;
use App\Repository\ArticleRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/articles')]
class ArticleController extends AbstractController
{
#[Route('/', name: 'article_index', methods: ['GET'])]
public function index(ArticleRepository $repo): Response
{
return $this->render('article/index.html.twig', [
'articles' => $repo->findPublished(),
]);
}
#[Route('/{slug}', name: 'article_show', methods: ['GET'])]
public function show(Article $article, EntityManagerInterface $em): Response
{
$article->incrementViews();
$em->flush();
return $this->render('article/show.html.twig', [
'article' => $article,
]);
}
#[Route('/admin/new', name: 'article_new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $em): Response
{
$article = new Article();
$form = $this->createForm(ArticleType::class, $article);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->persist($article);
$em->flush();
$this->addFlash('success', 'Article créé avec succès !');
return $this->redirectToRoute('article_show', ['slug' => $article->getSlug()]);
}
return $this->render('article/new.html.twig', [
'form' => $form,
]);
}
}
Article $article dans show() utilise le "route value resolver" : Symfony charge l'Article via son slug avant d'appeler la méthode.Définition : Fichier HTML avec syntaxe Twig qui mélange HTML et logique de présentation (boucles, conditions, filtres).
But : Séparer la présentation (HTML/CSS) de la logique métier (PHP).
Pourquoi ici : Twig compile les templates en PHP optimisé, offrant sécurité (échappement auto des variables) et réutilisabilité (héritage, includes).
{% extends 'base.html.twig' %}
{% block title %}Blog — Articles{% endblock %}
{% block body %}
<div class="container">
<h1>Articles</h1>
{% for article in articles %}
<article class="card">
<h2>
<a href="{{ path('article_show', {slug: article.slug}) }}">
{{ article.title }}
</a>
</h2>
<p class="meta">
Publié le {{ article.publishedAt|date('d/m/Y') }}
· {{ article.views }} vue(s)
</p>
{% if article.excerpt %}
<p>{{ article.excerpt }}</p>
{% else %}
<p>{{ article.content|slice(0, 200) }}...</p>
{% endif %}
</article>
{% else %}
<p>Aucun article publié pour l'instant.</p>
{% endfor %}
</div>
{% endblock %}
Définition : Fichier PHP décrivant les changements de schéma de base de données (CREATE TABLE, ALTER COLUMN, etc.).
But : Versionner les changements de schéma comme le code, pour collaborer en équipe et déployer en production sans surprises.
Pourquoi ici : Doctrine génère les migrations à partir des entités : make:migration compare le schéma actuel avec les entités et génère le SQL.
make:migration pour que le contrôle de version capture les changements.# Créer la migration
php bin/console make:migration
# Exécuter la migration
php bin/console doctrine:migrations:migrate
# Charger des fixtures de test
php bin/console doctrine:fixtures:load
# Vérifier les routes disponibles
php bin/console debug:router