Le composant Form Symfony
Symfony Forms lie les objets PHP aux formulaires HTML. Il gère la soumission, la validation, le rendu et la protection CSRF automatiquement.
Créer un Form Type
php bin/console make:form ArticleType App\\Entity\\Article
<?php
namespace App\Form;
use App\Entity\Article;
use App\Entity\Category;
use App\Enum\StatusEnum;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
class ArticleType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, [
'label' => 'Titre',
'attr' => ['placeholder' => 'Titre de l\'article'],
'constraints' => [
new Assert\NotBlank(),
new Assert\Length(min: 5, max: 255),
],
])
->add('body', TextareaType::class, [
'label' => 'Contenu',
'required' => false,
'attr' => ['rows' => 8],
])
->add('published', CheckboxType::class, [
'label' => 'Publier immédiatement',
'required' => false,
])
->add('category', EntityType::class, [
'class' => Category::class,
'choice_label' => 'name',
'placeholder' => 'Choisir une catégorie',
])
->add('status', EnumType::class, [
'class' => StatusEnum::class,
'choice_label' => fn(StatusEnum $s) => $s->label(),
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => Article::class]);
}
}
Controller — traitement du formulaire
<?php
#[Route('/article/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éé !');
return $this->redirectToRoute('article_index');
}
return $this->render('article/new.html.twig', [
'form' => $form,
]);
}
Rendu Twig
{# Rendu complet automatique #}
{{ form(form) }}
{# Rendu manuel champ par champ #}
{{ form_start(form, { attr: { class: 'needs-validation' } }) }}
{{ form_errors(form) }}
<div class="form-group">
{{ form_label(form.title) }}
{{ form_widget(form.title, { attr: { class: 'form-control' } }) }}
{{ form_errors(form.title) }}
{{ form_help(form.title) }}
</div>
<div class="form-group">
{{ form_row(form.body) }} {# label + widget + errors en une ligne #}
</div>
{{ form_rest(form) }} {# champs non encore rendus (dont _token CSRF) #}
<button type="submit" class="btn btn-primary">Enregistrer</button>
{{ form_end(form) }}
Thèmes de formulaire
# config/packages/twig.yaml
twig:
form_themes:
- 'bootstrap_5_layout.html.twig'
# ou : 'foundation_6_layout.html.twig'
# ou : 'tailwind_2_layout.html.twig'
Contraintes de validation
<?php
namespace App\Entity;
use Symfony\Component\Validator\Constraints as Assert;
class Article
{
#[Assert\NotBlank(message: 'Le titre est obligatoire')]
#[Assert\Length(min: 5, max: 255, minMessage: 'Minimum {{ limit }} caractères')]
private string $title = '';
#[Assert\Length(max: 50000)]
private ?string $body = null;
#[Assert\Email]
#[Assert\NotBlank]
private string $authorEmail = '';
#[Assert\Url]
private ?string $website = null;
#[Assert\Range(min: 0, max: 1000)]
private float $price = 0.0;
#[Assert\PositiveOrZero]
private int $stock = 0;
#[Assert\Choice(choices: ['draft', 'published', 'archived'])]
private string $status = 'draft';
#[Assert\Regex(pattern: '/^[a-z0-9-]+$/')]
private string $slug = '';
#[Assert\Date]
private ?string $publishedAt = null;
// Contrainte composite
#[Assert\Valid] // valide les objets imbriqués
private Address $address;
// Callback personnalisé
#[Assert\Callback]
public function validate(\Symfony\Component\Validator\Context\ExecutionContextInterface $context): void
{
if ($this->published && empty($this->body)) {
$context->buildViolation('Un article publié doit avoir un contenu')
->atPath('body')
->addViolation();
}
}
}
Groupes de validation
<?php
class User
{
#[Assert\NotBlank(groups: ['registration', 'profile'])]
#[Assert\Length(min: 2, groups: ['registration', 'profile'])]
private string $name = '';
#[Assert\NotBlank(groups: ['registration'])]
#[Assert\Email(groups: ['registration'])]
private string $email = '';
#[Assert\NotBlank(groups: ['registration'])]
#[Assert\Length(min: 8, groups: ['registration'])]
private ?string $plainPassword = null;
}
<?php
// Dans le Form Type
class RegistrationFormType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => User::class,
'validation_groups' => ['Default', 'registration'],
]);
}
}
Upload de fichiers
<?php
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Validator\Constraints\File as FileConstraint;
class ArticleType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('coverImage', FileType::class, [
'label' => 'Image de couverture (JPEG/PNG)',
'mapped' => false, // ne mappe pas directement sur l'entité
'required' => false,
'constraints' => [
new FileConstraint([
'maxSize' => '2048k',
'mimeTypes' => ['image/jpeg', 'image/png', 'image/webp'],
'mimeTypesMessage' => 'Seuls JPEG, PNG et WebP sont acceptés',
]),
],
]);
}
}
<?php
// Controller — traitement de l'upload
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\String\Slugger\SluggerInterface;
#[Route('/article/new', methods: ['GET','POST'])]
public function new(
Request $request,
EntityManagerInterface $em,
SluggerInterface $slugger,
string $uploadDir, // paramètre injecté depuis services.yaml
): Response {
$article = new Article();
$form = $this->createForm(ArticleType::class, $article);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var UploadedFile|null $file */
$file = $form->get('coverImage')->getData();
if ($file) {
$originalName = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
$safeFilename = $slugger->slug($originalName);
$newFilename = $safeFilename . '-' . uniqid() . '.' . $file->guessExtension();
$file->move($uploadDir, $newFilename);
$article->setCoverImage($newFilename);
}
$em->persist($article);
$em->flush();
return $this->redirectToRoute('article_index');
}
return $this->render('article/new.html.twig', ['form' => $form]);
}
Protection CSRF
Symfony Forms inclut automatiquement un token CSRF dans chaque formulaire. Pour les formulaires hors Form Component (DELETE, actions custom), gérez-le manuellement.
{# Dans Twig — formulaire DELETE #}
<form method="post" action="{{ path('article_delete', {id: article.id}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('delete-article-' ~ article.id) }}">
<button type="submit" class="btn btn-danger">Supprimer</button>
</form>
<?php
// Controller — vérification manuelle du token CSRF
#[Route('/article/{id}/delete', name: 'article_delete', methods: ['POST'])]
public function delete(Article $article, Request $request, EntityManagerInterface $em): Response
{
$token = $request->request->get('_token');
if (!$this->isCsrfTokenValid('delete-article-' . $article->getId(), $token)) {
throw $this->createAccessDeniedException('Token CSRF invalide');
}
$em->remove($article);
$em->flush();
return $this->redirectToRoute('article_index');
}
Validation sans formulaire (API)
<?php
use Symfony\Component\Validator\Validator\ValidatorInterface;
class ArticleController extends AbstractController
{
public function __construct(
private readonly ValidatorInterface $validator,
) {}
#[Route('/api/articles', methods: ['POST'])]
public function create(Request $request, EntityManagerInterface $em): JsonResponse
{
$data = $request->toArray();
$article = (new Article())
->setTitle($data['title'] ?? '')
->setBody($data['body'] ?? null);
$violations = $this->validator->validate($article);
if (count($violations) > 0) {
$errors = [];
foreach ($violations as $v) {
$errors[$v->getPropertyPath()] = $v->getMessage();
}
return $this->json(['errors' => $errors], 422);
}
$em->persist($article);
$em->flush();
return $this->json(['id' => $article->getId()], 201);
}
}