1 / 12

🎯 CRUD Manuel dans Symfony 6.4

🚀

Formation Complète

Maîtriser les opérations CRUD sans make:crud
pour une meilleure compréhension du framework

📝 CREATE

Créer des données

👁️ READ

Lire/afficher des données

✏️ UPDATE

Modifier des données

🗑️ DELETE

Supprimer des données

🏗️Architecture Symfony

Request
→
Router
→
Controller
→
Repository
→
Database
Response
←
Twig Template
←
Controller

🔧 Composants nécessaires

🎯 Entité

  • Représente la structure de données
  • Mapping ORM avec la base
  • Validation des contraintes

🗄️ Repository

  • Interface avec la base de données
  • Requêtes personnalisées
  • Méthodes de persistence

🎮 Contrôleur

  • Logique métier
  • Gestion des requêtes HTTP
  • Coordination des services

📝 Formulaires

  • Validation côté serveur
  • Génération HTML automatique
  • Protection CSRF

🎯L'Entité Article

L'entité représente la structure de données et sert de modèle pour la base de données.


// src/Entity/Article.php
namespace App\Entity;

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

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

    #[ORM\Column(length: 255)]
    #[Assert\NotBlank(message: "Le titre ne peut pas être vide")]
    #[Assert\Length(min: 5, max: 255)]
    private ?string $title = null;

    #[ORM\Column(type: 'text')]
    #[Assert\NotBlank(message: "Le contenu ne peut pas être vide")]
    private ?string $content = null;

    #[ORM\Column(type: 'datetime')]
    private ?\DateTimeInterface $createdAt = null;

    public function __construct()
    {
        $this->createdAt = new \DateTime();
    }
    
    // ... getters et setters
}

💡 Points clés à retenir

  • Nullable types : ?int, ?string permettent les valeurs null
  • Validation : Les contraintes Assert s'appliquent lors de la soumission
  • DateTime : Gestion automatique de la sérialisation
  • Encapsulation : Propriétés privées + getters/setters

🗄️Repository Personnalisé

Le repository centralise la logique d'accès aux données et étend les fonctionnalités de base de Doctrine.


// src/Repository/ArticleRepository.php
namespace App\Repository;

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

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

    /**
     * Recherche des articles par titre
     */
    public function findByTitle(string $title): array
    {
        return $this->createQueryBuilder('a')
            ->andWhere('a.title LIKE :title')
            ->setParameter('title', '%' . $title . '%')
            ->orderBy('a.createdAt', 'DESC')
            ->getQuery()
            ->getResult();
    }

    /**
     * Sauvegarde un article
     */
    public function save(Article $article, bool $flush = false): void
    {
        $this->getEntityManager()->persist($article);
        
        if ($flush) {
            $this->getEntityManager()->flush();
        }
    }
}

🔍 QueryBuilder vs SQL

  • Plus lisible et sécurisé que le SQL brut
  • Paramètres liés : Protection contre l'injection SQL
  • Persist vs Flush : persist() prépare, flush() exécute

📝Formulaire Article

Les formulaires Symfony génèrent automatiquement le HTML, valident les données et gèrent la sécurité CSRF.


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

use App\Entity\Article;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;

class ArticleType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('title', TextType::class, [
                'label' => 'Titre',
                'attr' => [
                    'class' => 'form-control',
                    'placeholder' => 'Saisir le titre de l\'article'
                ]
            ])
            ->add('content', TextareaType::class, [
                'label' => 'Contenu',
                'attr' => [
                    'class' => 'form-control',
                    'rows' => 10
                ]
            ])
            ->add('submit', SubmitType::class, [
                'label' => 'Enregistrer',
                'attr' => ['class' => 'btn btn-primary mt-3']
            ]);
    }

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

🎨 Types de champs

  • TextType, TextareaType
  • DateTimeType, CheckboxType
  • SubmitType

🔄 Mapping automatique

  • Noms des champs = propriétés
  • Validation intégrée
  • data_class liaison

👁️Contrôleur - READ Operations


// src/Controller/ArticleController.php
#[Route('/articles')]
class ArticleController extends AbstractController
{
    /**
     * 📋 READ - Liste des articles
     */
    #[Route('/', name: 'article_index', methods: ['GET'])]
    public function index(ArticleRepository $articleRepository): Response
    {
        $articles = $articleRepository->findBy([], ['createdAt' => 'DESC']);
        
        return $this->render('article/index.html.twig', [
            'articles' => $articles,
        ]);
    }

    /**
     * 👁️ READ - Article spécifique
     */
    #[Route('/{id}', name: 'article_show', methods: ['GET'], requirements: ['id' => '\d+'])]
    public function show(Article $article): Response
    {
        // ParamConverter : Symfony trouve automatiquement l'article par son ID
        return $this->render('article/show.html.twig', [
            'article' => $article,
        ]);
    }
}

🎯 ParamConverter

Symfony convertit automatiquement l'ID en entité. Si l'article n'existe pas, une erreur 404 est automatiquement générée.

✏️Contrôleur - CREATE & UPDATE

/**
 * ➕ CREATE - Créer un nouvel article
 */
#[Route('/new', name: 'article_new', methods: ['GET', 'POST'])]
public function new(Request $request, ArticleRepository $articleRepository): Response
{
    $article = new Article();
    $form = $this->createForm(ArticleType::class, $article);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $articleRepository->save($article, true);
        $this->addFlash('success', 'Article créé avec succès !');
        return $this->redirectToRoute('article_index');
    }

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

/**
 * ✏️ UPDATE - Modifier un article existant
 */
#[Route('/{id}/edit', name: 'article_edit', methods: ['GET', 'POST'])]
public function edit(Request $request, Article $article, ArticleRepository $articleRepository): Response
{
    $form = $this->createForm(ArticleType::class, $article);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        // Pas besoin de persist() car l'entité est déjà managée
        $articleRepository->save($article, true);
        $this->addFlash('success', 'Article modifié avec succès !');
        return $this->redirectToRoute('article_index');
    }

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

🔄 Workflow des formulaires

  1. handleRequest() : Récupère les données POST
  2. isSubmitted() : Vérifie si le formulaire a été soumis
  3. isValid() : Applique les validations
  4. save() : Persiste les données
  5. redirect() : Redirection après succès

🗑️Contrôleur - DELETE Operation

/**
 * 🗑️ DELETE - Supprimer un article
 */
#[Route('/{id}', name: 'article_delete', methods: ['POST'], requirements: ['id' => '\d+'])]
public function delete(Request $request, Article $article, ArticleRepository $articleRepository): Response
{
    // Vérification du token CSRF pour la sécurité
    if ($this->isCsrfTokenValid('delete'.$article->getId(), $request->request->get('_token'))) {
        $articleRepository->remove($article, true);
        $this->addFlash('success', 'Article supprimé avec succès !');
    } else {
        $this->addFlash('error', 'Token de sécurité invalide !');
    }

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

🔒 Sécurité CSRF

Le token CSRF protège contre les attaques de falsification de requêtes. Il doit être généré côté template et vérifié côté contrôleur.

Template :
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ article.id) }}">

🎨Templates Twig

Les templates séparent la logique de présentation et sécurisent automatiquement les sorties.

{# templates/article/index.html.twig #}
{% extends 'base.html.twig' %}

{% block title %}Liste des articles{% endblock %}

{% block body %}
<div class="container mt-4">
    <h1>Articles</h1>
    
    {# Messages flash #}
    {% for message in app.flashes('success') %}
        <div class="alert alert-success">{{ message }}</div>
    {% endfor %}

    {# Liste des articles #}
    {% if articles %}
        {% for article in articles %}
            <div class="card mb-3">
                <div class="card-body">
                    <h5>{{ article.title }}</h5>
                    <p>{{ article.content|slice(0, 100) }}...</p>
                    <small>{{ article.createdAt|date('d/m/Y H:i') }}</small>
                    
                    <a href="{{ path('article_show', {'id': article.id}) }}">Voir</a>
                    <a href="{{ path('article_edit', {'id': article.id}) }}">Modifier</a>
                    
                    {# Formulaire de suppression #}
                    <form method="post" action="{{ path('article_delete', {'id': article.id}) }}" 
                          onsubmit="return confirm('Supprimer ?')">
                        <input type="hidden" name="_token" value="{{ csrf_token('delete' ~ article.id) }}">
                        <button type="submit">Supprimer</button>
                    </form>
                </div>
            </div>
        {% endfor %}
    {% else %}
        <p>Aucun article trouvé.</p>
    {% endif %}
</div>
{% endblock %}

⚠️Erreurs Fréquentes

1. "Entity not found"

Cause : L'ID fourni dans l'URL n'existe pas en base

Solution : Ajouter des contraintes dans la route
#[Route('/{id}', requirements: ['id' => '\d+'])]

2. Formulaire non traité

Cause : Oubli de handleRequest() ou problème de validation

Solution : Vérifier la séquence complète
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
    // traitement
}

3. Token CSRF invalide

Cause : Token manquant ou incorrect

Solution : Générer et transmettre correctement le token

4. Entité non persistée

Cause : Oubli de persist() pour une nouvelle entité

Solution : persist() puis flush()

🎯Exercice Pratique : CRUD Task

📋 Énoncé

Créez un CRUD complet pour une entité Task avec :

🏗️ Propriétés

  • id (int, auto-increment)
  • title (string, 255 max)
  • description (text, nullable)
  • completed (boolean, false défaut)
  • dueDate (datetime, nullable)
  • createdAt (datetime, auto)

✅ Contraintes

  • Titre obligatoire (min 3 caractères)
  • Description optionnelle
  • Marquer comme terminée
  • Tri par date d'échéance

🚀 Fonctionnalités

  • Liste avec statut visuel
  • Création avec validation
  • Modification du statut
  • Suppression avec confirmation
  • Filtrage terminées/en cours

🔧 Commandes

  • make:entity Task
  • make:migration
  • doctrine:migrations:migrate
  • make:form TaskType
  • make:controller TaskController

💡 Nouveautés à découvrir

  • CheckboxType pour le statut completed
  • DateTimeType avec widget single_text
  • Filtrage par paramètre GET
  • Méthode toggle pour changer le statut rapidement

📋Correction : Entité Task


// src/Entity/Task.php
namespace App\Entity;

use App\Repository\TaskRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: TaskRepository::class)]
class Task
{
    #[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 $title = null;

    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $description = null;

    #[ORM\Column(type: 'boolean')]
    private bool $completed = false;

    #[ORM\Column(type: 'datetime', nullable: true)]
    private ?\DateTimeInterface $dueDate = null;

    #[ORM\Column(type: 'datetime')]
    private ?\DateTimeInterface $createdAt = null;

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

    /**
     * Méthode helper pour vérifier si la tâche est en retard
     */
    public function isOverdue(): bool
    {
        return $this->dueDate && $this->dueDate < new \DateTime() && !$this->completed;
    }

    // ... getters et setters
}

🔍 Nouveautés

  • Propriété boolean : completed avec valeur par défaut
  • Nullable datetime : dueDate peut être null
  • Méthode helper : isOverdue() pour la logique métier

🗄️Repository Task Avancé


// src/Repository/TaskRepository.php
class TaskRepository extends ServiceEntityRepository
{
    /**
     * Filtre les tâches par statut
     */
    public function findByStatus(bool $completed): array
    {
        return $this->createQueryBuilder('t')
            ->andWhere('t.completed = :completed')
            ->setParameter('completed', $completed)
            ->orderBy('t.dueDate', 'ASC')
            ->getQuery()
            ->getResult();
    }

    /**
     * Tri intelligent : tâches terminées en dernier
     */
    public function findAllOrderedByDueDate(): array
    {
        return $this->createQueryBuilder('t')
            ->orderBy('t.completed', 'ASC')      // Non terminées d'abord
            ->addOrderBy('t.dueDate', 'ASC')     // Puis par échéance
            ->getQuery()
            ->getResult();
    }

    /**
     * Statistiques des tâches
     */
    public function getTasksStats(): array
    {
        $qb = $this->createQueryBuilder('t');
        
        return [
            'total' => $qb->select('COUNT(t.id)')->getQuery()->getSingleScalarResult(),
            'completed' => $qb->select('COUNT(t.id)')
                             ->where('t.completed = true')
                             ->getQuery()->getSingleScalarResult(),
            'overdue' => $qb->select('COUNT(t.id)')
                           ->where('t.dueDate < :now')
                           ->andWhere('t.completed = false')
                           ->setParameter('now', new \DateTime())
                           ->getQuery()->getSingleScalarResult()
        ];
    }
}

🔍 Filtrage

  • Par statut de completion
  • Tri multiple avec addOrderBy
  • Logique métier intégrée

📊 Statistiques

  • Comptage total
  • Tâches terminées
  • Tâches en retard

🎮Contrôleur Task : Action Toggle


// src/Controller/TaskController.php

/**
 * 📋 READ - Liste avec filtrage
 */
#[Route('/', name: 'task_index', methods: ['GET'])]
public function index(Request $request, TaskRepository $taskRepository): Response
{
    $filter = $request->query->get('filter', 'all');
    
    switch ($filter) {
        case 'completed':
            $tasks = $taskRepository->findByStatus(true);
            break;
        case 'pending':
            $tasks = $taskRepository->findByStatus(false);
            break;
        default:
            $tasks = $taskRepository->findAllOrderedByDueDate();
    }

    return $this->render('task/index.html.twig', [
        'tasks' => $tasks,
        'current_filter' => $filter,
        'stats' => $taskRepository->getTasksStats()
    ]);
}

/**
 * ✅ TOGGLE - Basculer le statut rapidement
 */
#[Route('/{id}/toggle', name: 'task_toggle', methods: ['POST'])]
public function toggle(Request $request, Task $task, TaskRepository $taskRepository): Response
{
    if ($this->isCsrfTokenValid('toggle'.$task->getId(), $request->request->get('_token'))) {
        $task->setCompleted(!$task->isCompleted());
        $taskRepository->save($task, true);
        
        $status = $task->isCompleted() ? 'terminée' : 'en cours';
        $this->addFlash('success', "Tâche marquée comme {$status} !");
    }

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

🚀 Fonctionnalités avancées

  • Filtrage dynamique : Paramètre GET pour filtrer
  • Action toggle : Changement rapide de statut
  • Statistiques : Affichage des métriques
  • Messages contextuels : Feedback adapté

📝Formulaire Task Complet


// src/Form/TaskType.php
class TaskType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('title', TextType::class, [
                'label' => 'Titre de la tâche',
                'attr' => [
                    'class' => 'form-control',
                    'placeholder' => 'Entrez le titre'
                ]
            ])
            ->add('description', TextareaType::class, [
                'label' => 'Description',
                'required' => false,
                'attr' => [
                    'class' => 'form-control',
                    'rows' => 5,
                    'placeholder' => 'Description détaillée (optionnel)'
                ]
            ])
            ->add('completed', CheckboxType::class, [
                'label' => 'Tâche terminée',
                'required' => false,
                'attr' => ['class' => 'form-check-input']
            ])
            ->add('dueDate', DateTimeType::class, [
                'label' => 'Date d\'échéance',
                'required' => false,
                'widget' => 'single_text',  // Interface moderne
                'attr' => ['class' => 'form-control']
            ])
            ->add('submit', SubmitType::class, [
                'label' => 'Enregistrer',
                'attr' => ['class' => 'btn btn-primary mt-3']
            ]);
    }
}

🆕 Nouveaux types

  • CheckboxType pour boolean
  • DateTimeType avec widget
  • required => false pour optionnels

🎨 Interface moderne

  • single_text pour les dates
  • Classes Bootstrap
  • Placeholders informatifs

🎨Template Task Avancé

{# templates/task/index.html.twig #}
{% extends 'base.html.twig' %}

{% block body %}
<div class="container">
    {# Filtres de navigation #}
    <div class="btn-group mb-4">
        <a href="{{ path('task_index') }}" 
           class="btn {% if current_filter == 'all' %}btn-primary{% else %}btn-outline-primary{% endif %}">
            Toutes ({{ stats.total }})
        </a>
        <a href="{{ path('task_index', {'filter': 'pending'}) }}" 
           class="btn {% if current_filter == 'pending' %}btn-warning{% else %}btn-outline-warning{% endif %}">
            En cours ({{ stats.total - stats.completed }})
        </a>
        <a href="{{ path('task_index', {'filter': 'completed'}) }}" 
           class="btn {% if current_filter == 'completed' %}btn-success{% else %}btn-outline-success{% endif %}">
            Terminées ({{ stats.completed }})
        </a>
    </div>

    {# Liste des tâches avec statuts visuels #}
    {% for task in tasks %}
        <div class="card mb-3 {% if task.completed %}border-success{% elseif task.isOverdue %}border-danger{% else %}border-warning{% endif %}">
            <div class="card-header d-flex justify-content-between">
                <span class="badge {% if task.completed %}bg-success{% elseif task.isOverdue %}bg-danger{% else %}bg-warning{% endif %}">
                    {% if task.completed %}✅ Terminée{% elseif task.isOverdue %}⏰ En retard{% else %}🔄 En cours{% endif %}
                </span>
                
                {# Bouton toggle rapide #}
                <form method="post" action="{{ path('task_toggle', {'id': task.id}) }}">
                    <input type="hidden" name="_token" value="{{ csrf_token('toggle' ~ task.id) }}">
                    <button class="btn btn-sm {% if task.completed %}btn-warning{% else %}btn-success{% endif %}">
                        {% if task.completed %}↩️{% else %}✅{% endif %}
                    </button>
                </form>
            </div>
            
            <div class="card-body">
                <h5 class="{% if task.completed %}text-decoration-line-through text-muted{% endif %}">
                    {{ task.title }}
                </h5>
                {% if task.dueDate %}
                    <small class="{% if task.isOverdue %}text-danger fw-bold{% endif %}">
                        Échéance : {{ task.dueDate|date('d/m/Y H:i') }}
                    </small>
                {% endif %}
            </div>
        </div>
    {% endfor %}
</div>
{% endblock %}

🎉Récapitulatif CRUD Manuel

✅ CRUD de base

  • Create : Formulaires avec validation
  • Read : Liste et détail
  • Update : Modification complète
  • Delete : Suppression sécurisée

🚀 Fonctionnalités avancées

  • Filtrage dynamique
  • Actions rapides (toggle)
  • Indicateurs visuels
  • Statistiques temps réel

🔒 Sécurité

  • Tokens CSRF
  • Validation serveur
  • Paramètres liés (SQL)
  • Échappement automatique

🎨 UX/UI

  • Interface responsive
  • Messages flash
  • Confirmations utilisateur
  • Design moderne

🎯 Compétences acquises

  • Architecture Symfony : Compréhension complète du MVC
  • Doctrine ORM : Entités, repositories, migrations
  • Formulaires : Types, validation, rendu
  • Twig : Templates, filtres, héritage
  • Contrôleurs : Routes, actions, responses
  • Bonnes pratiques : Sécurité, UX, maintenabilité

🎓 Félicitations !

Vous maîtrisez maintenant le CRUD manuel dans Symfony 6.4
Vous pouvez créer des applications web robustes et sécurisées !