🐘 Symfony · Intermédiaire

Application MVC complète avec Symfony 7

⏱ 55 minutes🐘 PHP 8.3🎼 Symfony 7🗄️ Doctrine ORM

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.

📖 Terme : Pattern MVC (Model-View-Controller)

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.

1. Créer le projet Symfony

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

2. Configurer la base de données

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

3. Créer les entités

📖 Terme : Entité (Entity)

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.

📖 Terme : ORM (Object-Relational Mapping)

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.

Terminal — générer les entités
# 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
src/Entity/Article.php
<?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; }
}
Les attributs #[ORM\*] mappent les propriétés aux colonnes. #[Assert\*] valident les données avant persistence. #[ORM\PrePersist] est un callback de cycle de vie qui s'exécute avant INSERT.

4. Repository avec requêtes personnalisées

📖 Terme : Repository (Dépôt)

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.

src/Repository/ArticleRepository.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);
    }

    /** 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();
    }
}
QueryBuilder construit des requêtes SQL type-safe : createQueryBuilder('a') crée une requête sur Article aliasée 'a'. Les méthodes where(), orderBy(), getResult() correspondent à SELECT, WHERE, ORDER BY, FETCH.

5. Controller

📖 Terme : Contrôleur

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

📖 Terme : Injection de dépendances

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.

src/Controller/ArticleController.php
<?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,
        ]);
    }
}
Les paramètres des actions (ArticleRepository, EntityManagerInterface, Article) sont injectés automatiquement par le container Symfony. Le paramètre Article $article dans show() utilise le "route value resolver" : Symfony charge l'Article via son slug avant d'appeler la méthode.

6. Template Twig

📖 Terme : Template Twig

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

Syntaxe Twig : {{ variable }} affiche une variable (échappe le HTML par défaut), {% instruction %} exécute une logique (boucles, conditions), {{ variable|filtre }} applique un filtre à la variable.
templates/article/index.html.twig
{% 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 %}

7. Migrations et démarrage

📖 Terme : Migration

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.

Ne modifiez JAMAIS la base de données directement (ALTER TABLE à la main) : cela désynchroniserait vos migrations Doctrine. Toujours passer par make:migration pour que le contrôle de version capture les changements.
Terminal
# 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
Votre blog est accessible sur https://127.0.0.1:8000/articles