Créer et gérer des formulaires puissants
Types de champs • Validation • Rendu • Traitement
Formation DWWM - 2026
Pourquoi utiliser les formulaires Symfony ?
<form method="post">
<input type="text" name="name">
<!-- Validation manuelle -->
<!-- Protection CSRF manuelle -->
<!-- Gestion erreurs manuelle -->
<!-- Mapping données manuelle -->
<button type="submit">Envoyer</button>
</form>
<?php
// Dans le contrôleur
$name = $_POST['name'] ?? '';
if (empty($name)) {
$errors[] = 'Nom requis';
}
if (strlen($name) < 3) {
$errors[] = 'Trop court';
}
// ... encore 50 lignes de validation
?>
// Contrôleur
$form = $this->createForm(ArticleType::class, $article);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Données déjà validées et mappées !
$entityManager->persist($article);
$entityManager->flush();
return $this->redirectToRoute('success');
}
// Template Twig
{{ form(form) }}
{# HTML + validation + CSRF automatique ! #}
✅ Avantages Symfony Forms :
Imagine que tu dois remplir un formulaire à la mairie :
Symfony = l'administration qui gère tout le processus !
Premier formulaire avec Symfony
php bin/console make:form
$ php bin/console make:form
The name of the form class (e.g. GrumpyChefType):
> ArticleType
The name of Entity or fully qualified model class name that the new form will be bound to (empty for none):
> Article
created: src/Form/ArticleType.php
Success!
✅ Résultat : Fichier créé dans src/Form/ArticleType.php
<?php
namespace App\Form;
use App\Entity\Article;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
class ArticleType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, [
'label' => 'Titre',
'attr' => ['placeholder' => 'Saisissez le titre']
])
->add('content', TextareaType::class, [
'label' => 'Contenu',
'attr' => ['rows' => 10]
])
->add('createdAt', null, [
'label' => 'Date de création',
'widget' => 'single_text'
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Article::class,
]);
}
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('nomDuChamp', TypeDeChamp::class, [
'label' => 'Libellé',
'required' => true,
'attr' => ['class' => 'mon-css']
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Article::class, // Lié à l'entité Article
]);
}
💡 data_class :
Indique quelle entité est liée au formulaire. Symfony fait automatiquement le mapping des champs !
use App\Entity\Article;
use App\Form\ArticleType;
use Symfony\Component\HttpFoundation\Request;
#[Route('/article/new', name: 'article_new')]
public function new(Request $request, EntityManagerInterface $em): Response
{
// 1. Créer une nouvelle instance
$article = new Article();
// 2. Créer le formulaire
$form = $this->createForm(ArticleType::class, $article);
// 3. Traiter la requête
$form->handleRequest($request);
// 4. Vérifier si soumis et valide
if ($form->isSubmitted() && $form->isValid()) {
// 5. Sauvegarder (les données sont déjà dans $article !)
$em->persist($article);
$em->flush();
$this->addFlash('success', 'Article créé avec succès !');
return $this->redirectToRoute('article_list');
}
// 6. Afficher le formulaire
return $this->render('article/new.html.twig', [
'form' => $form
]);
}
{{ form(form) }}
{{ form_start(form) }}
{{ form_row(form.title) }}
{{ form_row(form.content) }}
{{ form_row(form.createdAt) }}
<button type="submit" class="btn btn-primary">Enregistrer</button>
{{ form_end(form) }}
{{ form_start(form) }}
<div class="form-group">
{{ form_label(form.title) }}
{{ form_widget(form.title) }}
{{ form_errors(form.title) }}
</div>
<div class="form-group">
{{ form_label(form.content) }}
{{ form_widget(form.content) }}
{{ form_errors(form.content) }}
</div>
<button type="submit">Enregistrer</button>
{{ form_end(form) }}
| Fonction | Description | HTML généré |
|---|---|---|
form(form) |
Formulaire complet | Tout le formulaire |
form_start(form) |
Balise <form> ouvrante | <form method="post"> |
form_end(form) |
Balise </form> fermante | </form> + CSRF |
form_row(form.field) |
Label + champ + erreurs | Bloc complet |
form_label(form.field) |
Juste le label | <label>...</label> |
form_widget(form.field) |
Juste le champ | <input> ou <select>... |
form_errors(form.field) |
Messages d'erreur | <ul class="errors"> |
form_rest(form) |
Champs restants | Champs non affichés |
Plus de 30 types de champs !
| Type | HTML | Usage |
|---|---|---|
TextType |
<input type="text"> | Texte court (nom, titre...) |
TextareaType |
<textarea> | Texte long (description...) |
EmailType |
<input type="email"> | Adresse email |
PasswordType |
<input type="password"> | Mot de passe masqué |
SearchType |
<input type="search"> | Champ de recherche |
UrlType |
<input type="url"> | URL (http://...) |
TelType |
<input type="tel"> | Numéro de téléphone |
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
$builder
->add('name', TextType::class)
->add('email', EmailType::class)
->add('password', PasswordType::class)
;
| Type | HTML | Usage |
|---|---|---|
IntegerType |
<input type="number"> | Nombre entier |
NumberType |
<input type="text"> | Nombre décimal |
MoneyType |
<input type="text"> | Montant avec devise |
PercentType |
<input type="text"> | Pourcentage |
RangeType |
<input type="range"> | Curseur (slider) |
$builder
->add('age', IntegerType::class, [
'attr' => ['min' => 0, 'max' => 120]
])
->add('price', MoneyType::class, [
'currency' => 'EUR'
])
->add('discount', PercentType::class, [
'type' => 'integer' // 75 = 75% (pas 0.75)
])
->add('rating', RangeType::class, [
'attr' => ['min' => 1, 'max' => 5, 'step' => 1]
])
;
| Type | HTML | Usage |
|---|---|---|
DateType |
<input type="date"> | Date seulement |
TimeType |
<input type="time"> | Heure seulement |
DateTimeType |
2 inputs | Date + heure |
BirthdayType |
3 selects | Date de naissance |
$builder
->add('publishedAt', DateType::class, [
'widget' => 'single_text', // Input HTML5
'label' => 'Date de publication'
])
->add('birthday', BirthdayType::class, [
'placeholder' => [
'year' => 'Année',
'month' => 'Mois',
'day' => 'Jour'
]
])
->add('eventDateTime', DateTimeType::class, [
'widget' => 'single_text',
'html5' => true
])
;
| Type | HTML | Usage |
|---|---|---|
ChoiceType |
<select> ou radio/checkbox | Liste de choix |
EntityType |
<select> | Entités Doctrine |
CountryType |
<select> | Liste des pays |
LanguageType |
<select> | Liste des langues |
CurrencyType |
<select> | Liste des devises |
$builder->add('status', ChoiceType::class, [
'choices' => [
'Brouillon' => 'draft',
'Publié' => 'published',
'Archivé' => 'archived'
]
]);
$builder->add('gender', ChoiceType::class, [
'choices' => [
'Homme' => 'male',
'Femme' => 'female',
'Autre' => 'other'
],
'expanded' => true, // Radio au lieu de select
]);
$builder->add('interests', ChoiceType::class, [
'choices' => [
'Sport' => 'sport',
'Musique' => 'music',
'Lecture' => 'reading'
],
'expanded' => true, // Checkboxes
'multiple' => true, // Choix multiples
]);
Permet de sélectionner des entités Doctrine dans un select.
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use App\Entity\Category;
$builder->add('category', EntityType::class, [
'class' => Category::class,
'choice_label' => 'name', // Propriété à afficher
'placeholder' => 'Choisir une catégorie'
]);
$builder->add('tags', EntityType::class, [
'class' => Tag::class,
'choice_label' => 'name',
'multiple' => true, // Choix multiples
'expanded' => false // false = select multiple, true = checkboxes
]);
$builder->add('category', EntityType::class, [
'class' => Category::class,
'choice_label' => 'name',
'query_builder' => function (CategoryRepository $repo) {
return $repo->createQueryBuilder('c')
->orderBy('c.name', 'ASC');
}
]);
| Type | Usage |
|---|---|
CheckboxType |
Case à cocher unique (true/false) |
FileType |
Upload de fichier |
HiddenType |
Champ caché |
ButtonType |
Bouton simple |
SubmitType |
Bouton submit |
ResetType |
Bouton reset |
CollectionType |
Collection d'objets (formulaires imbriqués) |
$builder
->add('acceptTerms', CheckboxType::class, [
'label' => 'J\'accepte les conditions',
'required' => true
])
->add('photo', FileType::class, [
'label' => 'Photo de profil',
'required' => false
])
->add('save', SubmitType::class, [
'label' => 'Enregistrer'
])
;
Contraintes et messages d'erreur
Les contraintes se définissent directement dans l'entité avec des attributs PHP.
use Symfony\Component\Validator\Constraints as Assert;
class Article
{
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'Le titre ne peut pas être vide')]
#[Assert\Length(
min: 5,
max: 255,
minMessage: 'Le titre doit faire au moins {{ limit }} caractères',
maxMessage: 'Le titre ne peut pas dépasser {{ limit }} caractères'
)]
private ?string $title = null;
#[ORM\Column(type: Types::TEXT)]
#[Assert\NotBlank]
#[Assert\Length(min: 20)]
private ?string $content = null;
#[ORM\Column(length: 180, unique: true)]
#[Assert\NotBlank]
#[Assert\Email(message: 'L\'email {{ value }} n\'est pas valide')]
private ?string $email = null;
#[ORM\Column]
#[Assert\Positive(message: 'Le prix doit être positif')]
private ?float $price = null;
}
| Contrainte | Description | Exemple |
|---|---|---|
NotBlank |
Non vide | #[Assert\NotBlank] |
NotNull |
Pas null | #[Assert\NotNull] |
Length |
Longueur min/max | #[Assert\Length(min: 3, max: 50)] |
Email |
Email valide | #[Assert\Email] |
Url |
URL valide | #[Assert\Url] |
Regex |
Expression régulière | #[Assert\Regex('/^[0-9]{5}$/')] |
Choice |
Valeur dans liste | #[Assert\Choice(['oui', 'non'])] |
Range |
Nombre entre min/max | #[Assert\Range(min: 0, max: 100)] |
Positive |
Nombre positif | #[Assert\Positive] |
DateTime |
Date/heure valide | #[Assert\DateTime] |
On peut aussi ajouter des contraintes directement dans le formulaire.
use Symfony\Component\Validator\Constraints as Assert;
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, [
'constraints' => [
new Assert\NotBlank(),
new Assert\Length(['min' => 5, 'max' => 255])
]
])
->add('email', EmailType::class, [
'constraints' => [
new Assert\NotBlank(),
new Assert\Email()
]
])
->add('age', IntegerType::class, [
'constraints' => [
new Assert\Range([
'min' => 18,
'max' => 120,
'notInRangeMessage' => 'Vous devez avoir entre {{ min }} et {{ max }} ans'
])
]
])
;
}
💡 Où mettre les contraintes ?
#[Assert\Length(
min: 5,
max: 50,
minMessage: 'Minimum {{ limit }} caractères (vous en avez {{ value|length }})',
maxMessage: 'Maximum {{ limit }} caractères'
)]
private ?string $username = null;
| Paramètre | Description |
|---|---|
{{ value }} |
Valeur saisie |
{{ limit }} |
Limite définie |
{{ min }} |
Minimum |
{{ max }} |
Maximum |
<div class="form-group">
{{ form_label(form.username) }}
{{ form_widget(form.username) }}
{# Les erreurs s'affichent automatiquement #}
{{ form_errors(form.username) }}
</div>
handleRequest et isValid
$article = new Article();$form = $this->createForm(ArticleType::class, $article);$form->handleRequest($request);if ($form->isSubmitted())if ($form->isValid())$article !$entityManager->flush();#[Route('/article/new', name: 'article_new')]
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()) {
// Les données du formulaire sont automatiquement dans $article
// grâce au data_class dans ArticleType
$em->persist($article);
$em->flush();
$this->addFlash('success', 'Article créé !');
return $this->redirectToRoute('article_show', [
'id' => $article->getId()
]);
}
return $this->render('article/new.html.twig', [
'form' => $form
]);
}
#[Route('/article/{id}/edit', name: 'article_edit')]
public function edit(
Article $article, // ParamConverter récupère l'article
Request $request,
EntityManagerInterface $em
): Response {
$form = $this->createForm(ArticleType::class, $article);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Pas besoin de persist() car $article existe déjà en base
$em->flush();
$this->addFlash('success', 'Article modifié !');
return $this->redirectToRoute('article_show', [
'id' => $article->getId()
]);
}
return $this->render('article/edit.html.twig', [
'form' => $form,
'article' => $article
]);
}
💡 ParamConverter :
Symfony convertit automatiquement {id} en objet Article si vous le typez dans les paramètres !
$this->addFlash('success', 'Opération réussie !');
$this->addFlash('error', 'Une erreur s\'est produite.');
$this->addFlash('warning', 'Attention !');
$this->addFlash('info', 'Information importante.');
{% for message in app.flashes('success') %}
<div class="alert alert-success">
{{ message }}
</div>
{% endfor %}
{% for message in app.flashes('error') %}
<div class="alert alert-danger">
{{ message }}
</div>
{% endfor %}
✅ Bonne pratique : Toujours donner un feedback à l'utilisateur après une action !
Gérer les images et documents
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Validator\Constraints\File;
$builder->add('photo', FileType::class, [
'label' => 'Photo (JPG, PNG)',
'mapped' => false, // Ne pas mapper à l'entité directement
'required' => false,
'constraints' => [
new File([
'maxSize' => '2M',
'mimeTypes' => ['image/jpeg', 'image/png'],
'mimeTypesMessage' => 'Veuillez uploader une image JPG ou PNG valide',
])
]
]);
⚠️ mapped => false :
Le fichier n'est pas directement mappé à l'entité. On le traite manuellement dans le contrôleur.
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\String\Slugger\SluggerInterface;
#[Route('/article/new', name: 'article_new')]
public function new(
Request $request,
EntityManagerInterface $em,
SluggerInterface $slugger
): Response {
$article = new Article();
$form = $this->createForm(ArticleType::class, $article);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Récupérer le fichier uploadé
$photoFile = $form->get('photo')->getData();
if ($photoFile) {
// Nom de fichier unique et sécurisé
$originalFilename = pathinfo(
$photoFile->getClientOriginalName(),
PATHINFO_FILENAME
);
$safeFilename = $slugger->slug($originalFilename);
$newFilename = $safeFilename.'-'.uniqid().'.'.$photoFile->guessExtension();
// Déplacer le fichier
try {
$photoFile->move(
$this->getParameter('photos_directory'), // public/uploads/photos
$newFilename
);
} catch (FileException $e) {
$this->addFlash('error', 'Erreur lors de l\'upload');
}
// Stocker le nom dans l'entité
$article->setPhotoFilename($newFilename);
}
$em->persist($article);
$em->flush();
return $this->redirectToRoute('article_show', ['id' => $article->getId()]);
}
return $this->render('article/new.html.twig', ['form' => $form]);
}
parameters:
photos_directory: '%kernel.project_dir%/public/uploads/photos'
mkdir -p public/uploads/photos
class Article
{
#[ORM\Column(length: 255, nullable: true)]
private ?string $photoFilename = null;
public function getPhotoFilename(): ?string
{
return $this->photoFilename;
}
public function setPhotoFilename(?string $photoFilename): void
{
$this->photoFilename = $photoFilename;
}
}
{% if article.photoFilename %}
<img src="{{ asset('uploads/photos/' ~ article.photoFilename) }}"
alt="{{ article.title }}">
{% endif %}
Cross-Site Request Forgery
Le CSRF = quelqu'un fait une action en ton nom sans que tu le saches :
Solution CSRF : Un jeton secret unique par formulaire que seul le vrai site connaît !
✅ Bonne nouvelle : Symfony protège automatiquement tous les formulaires avec CSRF !
{{ form_start(form) }}
{# Un champ caché avec le token CSRF est ajouté automatiquement #}
<input type="hidden" name="_token" value="xyz123abc...">
{{ form_row(form.title) }}
{{ form_row(form.content) }}
{{ form_end(form) }}
$form->handleRequest($request);
// Symfony vérifie automatiquement le token CSRF
if ($form->isSubmitted() && $form->isValid()) {
// Si on arrive ici, le token est valide !
}
💡 Pas besoin de code supplémentaire : La protection CSRF est active par défaut !
Dans certains cas spécifiques (API), on peut désactiver CSRF :
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Article::class,
'csrf_protection' => false, // ← Désactiver CSRF
]);
}
⚠️ DANGER : Ne désactivez CSRF que si vous savez exactement ce que vous faites ! Gardez-le activé pour les formulaires web classiques.
Thèmes et templates personnalisés
Symfony propose des thèmes pour s'adapter aux frameworks CSS populaires.
{# config/packages/twig.yaml #}
twig:
form_themes:
- 'bootstrap_5_layout.html.twig'
twig:
form_themes:
- 'bootstrap_4_layout.html.twig'
twig:
form_themes:
- 'foundation_5_layout.html.twig'
composer require tales-from-a-dev/flowbite-bundle
# Puis configurer dans twig.yaml
$builder->add('title', TextType::class, [
'attr' => [
'class' => 'form-control mon-input',
'placeholder' => 'Saisissez le titre',
'data-tooltip' => 'Aide contextuelle'
]
]);
$builder->add('title', TextType::class, [
'label' => 'Titre de l\'article',
'label_attr' => [
'class' => 'font-bold text-lg'
]
]);
$builder->add('email', EmailType::class, [
'help' => 'Nous ne partagerons jamais votre email',
'help_attr' => [
'class' => 'text-muted small'
]
]);
templates/form/custom_form.html.twig
{% block form_row %}
<div class="custom-form-group mb-4">
{{ form_label(form) }}
{{ form_widget(form) }}
{{ form_errors(form) }}
{% if help is defined %}
<small class="form-help">{{ help }}</small>
{% endif %}
</div>
{% endblock %}
{% block _article_title_widget %}
{# Widget personnalisé pour le champ 'title' de ArticleType #}
<input type="text" {{ block('widget_attributes') }} class="special-input">
{% endblock %}
{% form_theme form 'form/custom_form.html.twig' %}
{{ form_start(form) }}
{{ form_row(form.title) }}
{{ form_row(form.content) }}
{{ form_end(form) }}
CollectionType et sous-formulaires
Cas d'usage : Un formulaire Article avec plusieurs images.
class Article
{
#[ORM\OneToMany(targetEntity: Image::class, mappedBy: 'article', cascade: ['persist'])]
private Collection $images;
public function __construct()
{
$this->images = new ArrayCollection();
}
// getters, adders, removers...
}
class Image
{
#[ORM\ManyToOne(targetEntity: Article::class, inversedBy: 'images')]
private ?Article $article = null;
#[ORM\Column(length: 255)]
private ?string $filename = null;
}
class ImageType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('filename', TextType::class, [
'label' => 'Nom du fichier'
])
->add('description', TextType::class, [
'required' => false
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Image::class,
]);
}
}
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
class ArticleType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class)
->add('content', TextareaType::class)
->add('images', CollectionType::class, [
'entry_type' => ImageType::class,
'entry_options' => ['label' => false],
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
])
;
}
}
{{ form_start(form) }}
{{ form_row(form.title) }}
{{ form_row(form.content) }}
<h3>Images</h3>
<div id="images-collection" data-prototype="{{ form_widget(form.images.vars.prototype)|e('html_attr') }}">
{% for imageForm in form.images %}
<div class="image-item">
{{ form_row(imageForm.filename) }}
{{ form_row(imageForm.description) }}
<button type="button" class="remove-image">Supprimer</button>
</div>
{% endfor %}
</div>
<button type="button" id="add-image">Ajouter une image</button>
<button type="submit">Enregistrer</button>
{{ form_end(form) }}
<script>
// JavaScript pour ajouter/supprimer dynamiquement des images
document.getElementById('add-image').addEventListener('click', function() {
const collection = document.getElementById('images-collection');
const prototype = collection.dataset.prototype;
const index = collection.children.length;
const newForm = prototype.replace(/__name__/g, index);
collection.insertAdjacentHTML('beforeend', newForm);
});
</script>
php bin/console make:form ArticleType$builder->add('title', TextType::class)$form = $this->createForm(ArticleType::class, $article);$form->handleRequest($request);if ($form->isSubmitted() && $form->isValid())$entityManager->flush();{{ form(form) }}| Catégorie | Types disponibles |
|---|---|
| Texte | TextType, TextareaType, EmailType, PasswordType, UrlType, TelType, SearchType |
| Nombres | IntegerType, NumberType, MoneyType, PercentType, RangeType |
| Date/Heure | DateType, TimeType, DateTimeType, BirthdayType |
| Choix | ChoiceType, EntityType, CountryType, LanguageType, CurrencyType |
| Autres | CheckboxType, FileType, HiddenType, CollectionType |
| Boutons | SubmitType, ButtonType, ResetType |
| Contrainte | Usage |
|---|---|
NotBlank |
Champ obligatoire |
Length(min, max) |
Longueur du texte |
Email |
Format email valide |
Url |
Format URL valide |
Range(min, max) |
Nombre entre min et max |
Positive / Negative |
Nombre positif/négatif |
Choice([...]) |
Valeur dans une liste |
Regex(pattern) |
Expression régulière |
File(maxSize, mimeTypes) |
Upload de fichier |
| Fonction | Usage |
|---|---|
{{ form(form) }} |
Formulaire complet en une ligne |
{{ form_start(form) }} |
Balise <form> ouvrante + CSRF |
{{ form_end(form) }} |
Balise </form> + champs cachés |
{{ form_row(form.field) }} |
Label + Widget + Erreurs |
{{ form_label(form.field) }} |
Label uniquement |
{{ form_widget(form.field) }} |
Champ uniquement (input, select...) |
{{ form_errors(form.field) }} |
Messages d'erreur |
{{ form_rest(form) }} |
Champs non encore affichés |
$form = $this->createForm(ArticleType::class, $article);
// ❌ Oublié handleRequest() !
if ($form->isSubmitted()) { ... }
// ✅ BON
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { ... }
if ($form->isSubmitted() && $form->isValid()) {
$em->flush();
return $this->render('success.html.twig'); // ❌ Pas de redirect !
}
// ✅ BON
return $this->redirectToRoute('article_show', ['id' => $article->getId()]);
{{ form_start(form) }}
{{ form_row(form.title) }}
</form> {# ❌ Pas de form_end() = pas de CSRF ! #}
// ✅ BON
{{ form_end(form) }}
🌐 https://symfony.com/doc/current/forms.html
Guide complet des formulaires
🌐 https://symfony.com/doc/current/reference/forms/types.html
Référence de tous les types disponibles
🌐 https://symfony.com/doc/current/validation.html
Toutes les contraintes de validation
🌐 https://symfony.com/doc/current/form/form_customization.html
Personnaliser le rendu des formulaires
💡 Prochaine étape : Sécurité et Authentification !
Vous savez maintenant :
✅ Créer des formulaires puissants
✅ Valider automatiquement les données
✅ Gérer uploads et fichiers
✅ Protéger vos formulaires
💡 Conseil final :
Les formulaires Symfony peuvent sembler complexes au début, mais une fois maîtrisés, ils vous feront gagner un temps fou ! Pratiquez avec des formulaires réels.
Prochaine étape : Sécurité ! 🔐
Formation DWWM - 2026