Maîtriser les opérations CRUD sans make:crud
pour une meilleure compréhension du framework
Créer des données
Lire/afficher des données
Modifier des données
Supprimer des données
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
}
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();
}
}
}
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,
]);
}
}
// 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,
]);
}
}
Symfony convertit automatiquement l'ID en entité. Si l'article n'existe pas, une erreur 404 est automatiquement générée.
/**
* ➕ 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,
]);
}
/**
* 🗑️ 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');
}
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.
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ article.id) }}">
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 %}
Cause : L'ID fourni dans l'URL n'existe pas en base
#[Route('/{id}', requirements: ['id' => '\d+'])]
Cause : Oubli de handleRequest() ou problème de validation
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// traitement
}
Cause : Token manquant ou incorrect
Cause : Oubli de persist() pour une nouvelle entité
Créez un CRUD complet pour une entité Task avec :
// 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
}
// 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()
];
}
}
// 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');
}
// 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']
]);
}
}
{# 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 %}
Vous maîtrisez maintenant le CRUD manuel dans Symfony 6.4
Vous pouvez créer des applications web robustes et sécurisées !