Chapitre 7.4 — Le back-office à la main
Partie 7 : Twig & back-office — Chapitre 4/5 Version de référence : Symfony 7.4.
⏱️ TL;DR
- Un back-office CRUD = 5 actions par entité : list, show, new, edit, delete. On les écrit avec des contrôleurs (Partie 4) + des templates Twig (7.1-7.2).
make:crud Servicegénère tout ce squelette (contrôleur + templates + formulaire) — un excellent point de départ à personnaliser.- Les modifications suivent le pattern POST → Redirect → GET avec un message flash (Partie 4.5).
- Les formulaires sont détaillés en Partie 9 ; ici on se concentre sur la structure du CRUD et le rendu des listes/détails.
- Faire son back-office « à la main » donne un contrôle total sur l’UI, au prix de la verbosité. Le chapitre 7.5 (EasyAdmin) montre l’alternative rapide.
- Pour un dev Next.js : c’est bâtir un dashboard admin rendu serveur, une page Twig par vue, au lieu d’une SPA — direct et robuste.
🎯 Objectifs
- Structurer un CRUD complet (routes + contrôleur + templates).
- Utiliser
make:crudet comprendre ce qu’il génère. - Afficher une liste paginée et une fiche détail.
- Gérer les modifications avec flash + redirection.
1. L’anatomie d’un CRUD
Pour une entité Service, on veut cinq opérations :
| Action | Route | Méthode | Rôle |
|---|---|---|---|
| index | /admin/services | GET | lister |
| show | /admin/services/{id} | GET | consulter une fiche |
| new | /admin/services/new | GET/POST | créer |
| edit | /admin/services/{id}/edit | GET/POST | modifier |
| delete | /admin/services/{id} | POST | supprimer |
2. make:crud : le squelette en une commande
php bin/console make:crud ServiceLe Maker génère : src/Controller/Admin/ServiceController.php (les 5 actions), src/Form/ServiceType.php (le formulaire — Partie 9), et les templates templates/service/{index,show,new,edit,_form,_delete_form}.html.twig. C’est un point de départ complet que vous adaptez ensuite. Beaucoup de développeurs partent de là plutôt que d’écrire à blanc.
3. Le contrôleur (structure)
// src/Controller/Admin/ServiceController.php
declare(strict_types=1);
namespace App\Controller\Admin;
use App\Entity\Service;
use App\Repository\ServiceRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/admin/services', name: 'admin_service_')]
final class ServiceController extends AbstractController
{
#[Route('', name: 'index', methods: ['GET'])]
public function index(ServiceRepository $repository): Response
{
return $this->render('service/index.html.twig', [
'services' => $repository->findBy([], ['name' => 'ASC']),
]);
}
#[Route('/{id<\d+>}', name: 'show', methods: ['GET'])]
public function show(Service $service): Response
{
return $this->render('service/show.html.twig', ['service' => $service]);
}
#[Route('/{id<\d+>}', name: 'delete', methods: ['POST'])]
public function delete(Service $service, EntityManagerInterface $em): Response
{
// (protection CSRF détaillée en Partie 9)
$em->remove($service);
$em->flush();
$this->addFlash('success', 'Service supprimé.');
return $this->redirectToRoute('admin_service_index'); // Redirect après POST
}
// new() et edit() : voir Partie 9 (formulaires)
}Notez le préfixe de route au niveau de la classe (/admin/services, nom admin_service_), les contrôleurs fins (Partie 4.3) qui délèguent au repository/EntityManager, et le pattern POST → Redirect après suppression.
4. La liste (template)
{# templates/service/index.html.twig #}
{% extends 'base.html.twig' %}
{% block title %}Services — {{ parent() }}{% endblock %}
{% block body %}
<h1>Services</h1>
<a href="{{ path('admin_service_new') }}" class="btn">Nouveau service</a>
<table>
<thead>
<tr><th>Nom</th><th>Prestataire</th><th>Créneaux</th><th></th></tr>
</thead>
<tbody>
{% for service in services %}
<tr>
<td><a href="{{ path('admin_service_show', { id: service.id }) }}">{{ service.name }}</a></td>
<td>{{ service.provider.name }}</td>
<td>{{ service.slots|length }}</td>
<td><a href="{{ path('admin_service_edit', { id: service.id }) }}">Modifier</a></td>
</tr>
{% else %}
<tr><td colspan="4">Aucun service.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}⚠️ Piège N+1 :
service.provider.nameetservice.slots|lengthdans la boucle déclenchent des requêtes par ligne (chapitre 6.6) ! Sur une grande liste, chargez les relations en fetch join dans le repository (->addSelect('p')->join('s.provider', 'p')) ou, pour un simple compteur, une requête agrégée. Regardez le profiler : une liste admin est le lieu n°1 des N+1.
5. La fiche détail
{# templates/service/show.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
<h1>{{ service.name }}</h1>
<p>Prestataire : {{ service.provider.name }}</p>
<h2>Créneaux</h2>
<ul>
{% for slot in service.slots %}
<li>{{ slot.start|date('d/m/Y H:i') }} — {{ slot.seats }} places</li>
{% else %}
<li>Aucun créneau.</li>
{% endfor %}
</ul>
<a href="{{ path('admin_service_edit', { id: service.id }) }}">Modifier</a>
{# suppression : un mini-formulaire POST (jamais un simple lien GET !) #}
<form method="post" action="{{ path('admin_service_delete', { id: service.id }) }}"
onsubmit="return confirm('Supprimer ce service ?');">
<button>Supprimer</button>
</form>
{% endblock %}⚠️ Piège sécurité : une suppression (ou toute action modifiant l’état) ne doit jamais passer par un lien
GET(<a href="/delete/1">). Un GET peut être préchargé par le navigateur, indexé, ou déclenché par une image → suppressions accidentelles, et vulnérabilité CSRF. Toujours un formulaire POST (idéalement DELETE) avec un token CSRF (Partie 9). Règle : GET lit, POST/PUT/DELETE modifient.
6. Pagination
Sur une grande table, on pagine (chapitre 6.6). Le contrôleur passe la page courante et le total ; le template affiche des liens :
<nav class="pagination">
{% if page > 1 %}<a href="{{ path('admin_service_index', { page: page - 1 }) }}">Précédent</a>{% endif %}
<span>Page {{ page }} / {{ pages }}</span>
{% if page < pages %}<a href="{{ path('admin_service_index', { page: page + 1 }) }}">Suivant</a>{% endif %}
</nav>7. Le prix du « à la main »
Ce back-office est entièrement sous votre contrôle (UI, comportement, sécurité fine). Mais pour chaque entité, c’est 5 actions + 4-5 templates + un formulaire. Pour un admin standard (CRUD répétitif sur beaucoup d’entités), c’est beaucoup de code répétitif. D’où l’intérêt d’EasyAdmin (chapitre suivant), qui génère tout ça et vous laisse personnaliser ce qui compte.
💡 Pour un dev Next.js : « à la main », c’est comme coder chaque page admin de votre dashboard une par une. Parfait quand l’UI est spécifique, fastidieux quand elle est standard. EasyAdmin est l’équivalent d’un « admin panel » généré (façon React-Admin, mais rendu serveur). On choisit selon le degré de personnalisation voulu.
✏️ Exercices
Exercice 1. Écrivez l’action index d’un ProviderController admin qui liste les prestataires triés par nom, en évitant le N+1 sur le nombre de services.
✅ Solution
Contrôleur fin qui délègue à un repository chargeant les services en une requête :
#[Route('', name: 'index', methods: ['GET'])]
public function index(ProviderRepository $repo): Response
{
return $this->render('provider/index.html.twig', [
'providers' => $repo->findAllWithServiceCount(),
]);
}Dans le repository, soit un leftJoin('p.services', 's')->addSelect('s') (fetch join), soit — mieux pour un simple compteur — une requête agrégée SELECT p, COUNT(s.id) ... GROUP BY p.id. On évite ainsi une requête par ligne dans le template.
Exercice 2. Pourquoi la suppression d’un service ne doit-elle pas être un lien <a href="/admin/services/1/delete"> ?
✅ Solution
Un lien est une requête GET, qui peut être préchargée par le navigateur, indexée par un crawler, ou déclenchée à l’insu de l’utilisateur (image, prefetch) → suppressions accidentelles et vulnérabilité CSRF. Les actions qui modifient l’état doivent utiliser POST/DELETE, via un formulaire avec token CSRF. Règle : GET lit, POST/PUT/DELETE modifient.
Exercice 3. Dans quel cas préférer un back-office « à la main » plutôt qu’EasyAdmin ?
✅ Solution
Quand l’interface est spécifique/sur-mesure (workflow métier particulier, UI très personnalisée, écrans qui ne sont pas de simples CRUD, intégration front poussée). Pour un admin standard (CRUD répétitif sur de nombreuses entités), EasyAdmin fait gagner énormément de temps. On peut aussi mélanger : EasyAdmin pour le gros du CRUD, quelques écrans sur-mesure à la main.
🧠 Quiz de révision
1. Quelles sont les 5 actions d’un CRUD ?
index (list), show, new, edit, delete.
2. Que génère make:crud Service ?
make:crud Service ?Le contrôleur (5 actions), le formulaire (ServiceType), et les templates (index, show, new, edit, _form, _delete_form) — un squelette complet à personnaliser.
3. Quel pattern suit une modification (suppression, édition) ?
POST → Redirect → GET avec un message flash : on traite en POST, on ajoute un flash, on redirige (évite la re-soumission au rechargement).
4. Quel piège de perf guette les listes admin ?
Le N+1 : accéder à une relation (service.provider.name, service.slots|length) par ligne. On charge les relations en fetch join ou via une requête agrégée.
5. Comment déclencher une suppression proprement ?
Par un formulaire POST (ou DELETE) avec token CSRF, jamais par un lien GET.
Prochain chapitre : 7.5 — EasyAdmin — générer un back-office complet en quelques minutes.