Módulo 07: Formularios
Objetivos del módulo
- Entender qué problema resuelve el componente Form y cómo es su ciclo de vida
- Crear clases de formulario con
make:formy comprenderbuildForm()yconfigureOptions() - Conocer los tipos de campo más usados y sus opciones
- Crear y procesar formularios en el controlador (
createForm,handleRequest,isSubmitted,isValid) - Renderizar formularios en Twig, campo a campo o de golpe, y aplicar el tema de Bootstrap 5
- Usar campos no mapeados, subir ficheros y escuchar eventos de formulario
- Crear formularios sin clase y entender la protección CSRF automática
- Construir el formulario completo para crear/editar artículos y para publicar comentarios
1. ¿Por qué un componente de formularios?
Un formulario HTML parece sencillo: unas etiquetas <input> y un botón. Pero en una aplicación real tienes que:
- Generar el HTML con los valores actuales (al editar).
- Recoger los datos enviados y convertirlos al tipo correcto (texto →
DateTimeImmutable, id → entidadCategory…). - Validarlos y mostrar los errores junto a cada campo.
- Protegerte frente a ataques CSRF.
- Volcar los datos válidos en tu objeto y guardarlo.
📘 Concepto: El componente Form de Symfony automatiza todo ese ciclo. Tú describes qué campos tiene el formulario y a qué objeto se asocian; Symfony se encarga de pintar el HTML, leer la petición, transformar los datos, validarlos y rellenar el objeto.
Piensa en él como en un traductor bidireccional: de tu objeto Article a HTML y del HTML enviado de vuelta a tu objeto Article.
sequenceDiagram
participant U as Navegador
participant C as Controlador
participant F as Form
participant A as Article
participant DB as Base de datos
U->>C: GET /articulo/nuevo
C->>F: createForm(ArticleType, $article)
F-->>U: HTML del formulario vacío
U->>C: POST /articulo/nuevo (datos)
C->>F: handleRequest($request)
F->>A: Transforma y asigna los datos
F->>F: Valida (CSRF + restricciones)
alt Válido
C->>DB: persist + flush
C-->>U: Redirección 303
else Con errores
C-->>U: HTML con errores (422)
end
Instalación
Si creaste el proyecto con symfony new blog --webapp, ya lo tienes. Si no:
composer require symfony/form
2. Tu primera clase de formulario
Symfony recomienda definir cada formulario en su propia clase de tipo (form type). MakerBundle te la genera:
php bin/console make:form ArticleType Article
created: src/Form/ArticleType.php
Success!
Next: Add fields to your form and start using it.
Find the documentation at https://symfony.com/doc/current/forms.html
2.1 Anatomía de un FormType
<?php
// src/Form/ArticleType.php
declare(strict_types=1);
namespace App\Form;
use App\Entity\Article;
use Symfony\Component\Form\AbstractType;
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;
class ArticleType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, [
'label' => 'Título',
])
->add('content', TextareaType::class, [
'label' => 'Contenido',
'attr' => ['rows' => 12],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Article::class,
]);
}
}
| Parte | Qué hace |
|---|---|
extends AbstractType | Clase base de todos los tipos de formulario |
buildForm() | Define los campos con $builder->add(nombre, Tipo::class, opciones) |
El nombre del campo ('title') | Debe coincidir con una propiedad del objeto (usa getTitle()/setTitle()) |
configureOptions() | Opciones del formulario completo |
data_class | Clase del objeto que el formulario lee y rellena |
📘 Concepto: Un formulario es un árbol: el formulario
ArticleTypees el padre y cada campo (title,content) es a su vez un formulario hijo. Por eso un campo puede ser otroFormTypecompleto (formularios embebidos).
💡 Consejo: Si omites el tipo (
->add('title')), Symfony intenta adivinarlo a partir de los metadatos de Doctrine y de las restricciones de validación. Es cómodo para prototipos, pero en código profesional indica siempre el tipo y la etiqueta: el formulario queda explícito y predecible.
3. Tipos de campo
Todos los tipos viven en el espacio de nombres Symfony\Component\Form\Extension\Core\Type, salvo EntityType, que está en Symfony\Bridge\Doctrine\Form\Type.
| Tipo | HTML generado | Uso típico |
|---|---|---|
TextType | <input type="text"> | Títulos, nombres |
TextareaType | <textarea> | Contenido largo |
EmailType | <input type="email"> | Correos |
PasswordType | <input type="password"> | Contraseñas (no se repinta el valor) |
IntegerType / NumberType | <input type="number"> | Cantidades, precios |
UrlType | <input type="url"> | Enlaces |
ChoiceType | <select>, radios o checkboxes | Lista fija de opciones |
EnumType | Igual que ChoiceType | Opciones a partir de un enum PHP |
EntityType | <select>, radios o checkboxes | Elegir entidades de Doctrine |
DateType / DateTimeType | <input type="date"> / datetime-local | Fechas |
CheckboxType | <input type="checkbox"> | Booleanos |
FileType | <input type="file"> | Subida de ficheros |
HiddenType | <input type="hidden"> | Valores ocultos |
CollectionType | Lista de subformularios | Varios elementos del mismo tipo |
RepeatedType | Dos campos que deben coincidir | “Repite la contraseña” |
SubmitType | <button type="submit"> | Botón de envío |
3.1 ChoiceType
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
$builder->add('status', ChoiceType::class, [
'label' => 'Estado',
'choices' => [
// etiqueta visible => valor guardado
'Borrador' => 'draft',
'En revisión' => 'review',
'Publicado' => 'published',
],
'expanded' => false, // true → radios / checkboxes
'multiple' => false, // true → selección múltiple
'placeholder' => 'Elige un estado',
]);
expanded | multiple | Resultado |
|---|---|---|
false | false | <select> simple |
false | true | <select multiple> |
true | false | Botones de radio |
true | true | Casillas de verificación |
3.2 EntityType
EntityType es un ChoiceType cuyas opciones son entidades de la base de datos. Ideal para elegir la categoría y las etiquetas de un artículo.
use App\Entity\Category;
use App\Entity\Tag;
use App\Repository\CategoryRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
$builder
->add('category', EntityType::class, [
'class' => Category::class,
'choice_label' => 'name', // propiedad que se muestra
'placeholder' => 'Selecciona una categoría',
'query_builder' => static fn (CategoryRepository $repo) =>
$repo->createQueryBuilder('c')->orderBy('c.name', 'ASC'),
])
->add('tags', EntityType::class, [
'class' => Tag::class,
'choice_label' => 'name',
'multiple' => true,
'expanded' => true, // casillas de verificación
'by_reference' => false, // usa addTag()/removeTag()
'required' => false,
]);
⚠️ Importante: En colecciones (
multiple: true) usa'by_reference' => false. Así el formulario llama aaddTag()yremoveTag()en vez de sustituir la colección entera, y la relación se sincroniza bien. Es imprescindible cuando editas desde el lado inverso de la relación.
3.3 DateTimeType
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
$builder->add('publishedAt', DateTimeType::class, [
'label' => 'Fecha de publicación',
'widget' => 'single_text', // un solo <input type="datetime-local">
'input' => 'datetime_immutable',
'required' => false,
'help' => 'Déjalo vacío para guardarlo como borrador.',
]);
4. Opciones comunes
Casi todos los tipos aceptan estas opciones:
| Opción | Ejemplo | Para qué sirve |
|---|---|---|
label | 'label' => 'Título' | Texto de la etiqueta (false la oculta) |
required | 'required' => false | Añade o quita el atributo HTML required (no valida en servidor) |
attr | 'attr' => ['placeholder' => 'Escribe...', 'class' => 'font-monospace'] | Atributos HTML del widget |
label_attr | 'label_attr' => ['class' => 'fw-bold'] | Atributos HTML de la etiqueta |
row_attr | 'row_attr' => ['class' => 'mb-4'] | Atributos del contenedor de la fila |
help | 'help' => 'Máximo 255 caracteres' | Texto de ayuda bajo el campo |
data | 'data' => 'valor' | Valor inicial (sobrescribe el del objeto) |
empty_data | 'empty_data' => '' | Valor si el usuario envía el campo vacío |
disabled | 'disabled' => true | Campo no editable; se ignora al enviar |
mapped | 'mapped' => false | El campo no se asocia a una propiedad del objeto |
constraints | 'constraints' => [new Assert\NotBlank()] | Validación del campo (útil en campos no mapeados) |
⚠️ Importante:
requiredsolo añade el atributo HTML5required, que el usuario puede saltarse fácilmente. La validación real se hace en el servidor con las restricciones del módulo 08 (#[Assert\NotBlank], etc.).
5. Crear y procesar el formulario en el controlador
5.1 El patrón estándar
<?php
// src/Controller/ArticleAdminController.php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Article;
use App\Form\ArticleType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\String\Slugger\SluggerInterface;
#[Route('/admin/articulos')]
final class ArticleAdminController extends AbstractController
{
#[Route('/nuevo', name: 'app_admin_article_new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $em, SluggerInterface $slugger): Response
{
$article = new Article();
// 1. Crear el formulario asociado al objeto
$form = $this->createForm(ArticleType::class, $article);
// 2. Leer la petición: si es POST, rellena $article y valida
$form->handleRequest($request);
// 3. ¿Se ha enviado y es válido?
if ($form->isSubmitted() && $form->isValid()) {
// $article ya contiene los datos del formulario
$article->setSlug($slugger->slug($article->getTitle())->lower()->toString());
$em->persist($article);
$em->flush();
$this->addFlash('success', 'Artículo creado correctamente.');
// 4. Redirigir (patrón Post/Redirect/Get)
return $this->redirectToRoute('app_admin_article_index', status: Response::HTTP_SEE_OTHER);
}
// 5. GET inicial o formulario con errores
return $this->render('admin/article/new.html.twig', [
'form' => $form,
]);
}
}
| Método | Qué hace |
|---|---|
createForm(Tipo::class, $objeto, $opciones) | Crea el formulario a partir de un FormType |
handleRequest($request) | Si la petición tiene datos del formulario, los envía (submit) |
isSubmitted() | true si la petición traía este formulario |
isValid() | true si no hay errores de validación ni de CSRF |
getData() | Devuelve el objeto (o array) con los datos |
get('campo')->getData() | Valor de un campo concreto (útil en no mapeados) |
📘 Concepto: El patrón Post/Redirect/Get evita que, al refrescar la página después de enviar, el navegador vuelva a enviar el formulario y se cree un artículo duplicado. Tras un POST correcto, siempre redirige.
💡 Consejo: Cuando pasas un formulario enviado e inválido a
$this->render(), Symfony responde automáticamente con código 422 Unprocessable Content. Esto es lo que espera Turbo (módulo 04) para mostrar los errores sin recargar la página.
5.2 Editar reutiliza el mismo formulario
#[Route('/{id}/editar', name: 'app_admin_article_edit', methods: ['GET', 'POST'])]
public function edit(Article $article, Request $request, EntityManagerInterface $em): Response
{
// El formulario se rellena con los datos actuales del artículo
$form = $this->createForm(ArticleType::class, $article);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// No hace falta persist(): Doctrine ya gestiona este objeto
$em->flush();
$this->addFlash('success', 'Cambios guardados.');
return $this->redirectToRoute('app_admin_article_edit', ['id' => $article->getId()], Response::HTTP_SEE_OTHER);
}
return $this->render('admin/article/edit.html.twig', [
'article' => $article,
'form' => $form,
]);
}
💡 Consejo:
php bin/console make:crud Articlegenera de una vez el controlador, el FormType y las plantillas de listado, alta, edición y borrado. Es una excelente forma de ver el patrón completo; luego adapta el código a tu gusto.
6. Renderizar formularios en Twig
6.1 Todo de golpe
{# templates/admin/article/new.html.twig #}
{% extends 'base.html.twig' %}
{% block title %}Nuevo artículo{% endblock %}
{% block body %}
<h1>Nuevo artículo</h1>
{{ form(form) }}
{% endblock %}
form(form) pinta la etiqueta <form>, todos los campos y el campo oculto CSRF. Es perfecto para empezar, pero no te deja controlar el diseño ni añadir el botón donde quieras.
6.2 Campo a campo
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
{# Errores globales del formulario (por ejemplo, CSRF inválido) #}
{{ form_errors(form) }}
<div class="row">
<div class="col-md-8">
{{ form_row(form.title) }}
</div>
<div class="col-md-4">
{{ form_row(form.category) }}
</div>
</div>
{# Control total sobre cada pieza #}
<div class="mb-3">
{{ form_label(form.content, 'Contenido del artículo') }}
{{ form_widget(form.content, {attr: {class: 'font-monospace'}}) }}
{{ form_help(form.content) }}
{{ form_errors(form.content) }}
</div>
{# Pinta los campos que falten (incluido el token CSRF) #}
{{ form_rest(form) }}
<button type="submit" class="btn btn-primary">Guardar</button>
{{ form_end(form) }}
| Función | Qué pinta |
|---|---|
form(form) | Formulario completo |
form_start(form) | Etiqueta <form> con method, action y enctype |
form_end(form) | Campos pendientes + </form> |
form_row(form.campo) | Etiqueta + widget + ayuda + errores de un campo |
form_label(form.campo) | Solo la etiqueta |
form_widget(form.campo) | Solo el control (<input>, <select>…) |
form_errors(form.campo) | Errores del campo (o globales si pasas form) |
form_help(form.campo) | Texto de ayuda |
form_rest(form) | Todos los campos que aún no se han pintado |
⚠️ Importante:
form_end()pinta automáticamente los campos que no hayas renderizado, incluido el token CSRF. Si te olvidas deform_end(), el formulario fallará con “The CSRF token is invalid”.
💡 Consejo: El atributo
novalidatedesactiva la validación del navegador. Úsalo mientras desarrollas para comprobar que la validación del servidor funciona y muestra bien los errores.
6.3 Acceder a las variables de un campo
Cada campo expone sus datos en vars:
{% if form.title.vars.errors|length > 0 %}
<p class="text-danger">Revisa el título</p>
{% endif %}
<p>Valor actual: {{ form.title.vars.value }}</p>
<p>¿Obligatorio?: {{ form.title.vars.required ? 'sí' : 'no' }}</p>
7. Temas de formulario: Bootstrap 5
Por defecto Symfony genera HTML muy básico. Con un tema (form theme) todos los formularios adoptan las clases de Bootstrap (form-control, form-label, is-invalid, invalid-feedback…) sin tocar una sola plantilla.
# config/packages/twig.yaml
twig:
file_name_pattern: '*.twig'
form_themes: ['bootstrap_5_layout.html.twig']
| Tema incluido | Resultado |
|---|---|
bootstrap_5_layout.html.twig | Etiqueta sobre el campo, clases de Bootstrap 5 |
bootstrap_5_horizontal_layout.html.twig | Etiqueta a la izquierda, campo a la derecha (rejilla) |
form_div_layout.html.twig | Tema por defecto, sin estilos |
tailwind_2_layout.html.twig | Clases de Tailwind CSS |
Puedes cambiar el tema solo en una plantilla:
{% form_theme form 'bootstrap_5_horizontal_layout.html.twig' %}
{{ form(form) }}
7.1 Personalizar un bloque concreto
Los temas se basan en bloques de Twig. Puedes sobrescribir solo uno dentro de la propia plantilla con _self:
{% form_theme form _self %}
{# Bloque para el widget del campo "content" del formulario "article" #}
{% block _article_content_widget %}
<div class="border rounded p-2 bg-body-tertiary">
{{ block('textarea_widget') }}
</div>
<small class="text-muted">Admite Markdown.</small>
{% endblock %}
{{ form(form) }}
📘 Concepto: El nombre del bloque sigue el patrón
_<nombreFormulario>_<campo>_<parte>. El nombre del formulario se deriva de la clase:ArticleType→article. Lo puedes ver en el panel Forms del Profiler.
8. Campos no mapeados
Un campo con 'mapped' => false aparece en el formulario pero no se lee ni escribe en el objeto. Sirve para datos que no pertenecen a la entidad: aceptar condiciones, un fichero que transformarás, un “enviar notificación a suscriptores”…
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Validator\Constraints as Assert;
$builder->add('notifySubscribers', CheckboxType::class, [
'label' => 'Avisar por email a los suscriptores',
'mapped' => false,
'required' => false,
]);
$builder->add('agreeTerms', CheckboxType::class, [
'label' => 'Acepto las normas de publicación',
'mapped' => false,
// Al no estar en la entidad, la validación va aquí
'constraints' => [
new Assert\IsTrue(message: 'Debes aceptar las normas.'),
],
]);
En el controlador lees su valor así:
if ($form->isSubmitted() && $form->isValid()) {
$notify = $form->get('notifySubscribers')->getData(); // bool
// ...
}
9. Subida de ficheros
Vamos a permitir una imagen de portada para cada artículo. En la entidad guardamos solo el nombre del fichero, no el fichero en sí.
// src/Entity/Article.php (fragmento)
#[ORM\Column(length: 255, nullable: true)]
private ?string $coverFilename = null;
public function getCoverFilename(): ?string
{
return $this->coverFilename;
}
public function setCoverFilename(?string $coverFilename): static
{
$this->coverFilename = $coverFilename;
return $this;
}
9.1 El campo en el formulario
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Validator\Constraints as Assert;
$builder->add('cover', FileType::class, [
'label' => 'Imagen de portada (JPG, PNG o WebP)',
'mapped' => false, // la entidad guarda un string, no un fichero
'required' => false, // para no obligar a resubirla al editar
'constraints' => [
new Assert\File(
maxSize: '2M',
extensions: ['jpg', 'jpeg', 'png', 'webp'],
extensionsMessage: 'Sube una imagen JPG, PNG o WebP.',
),
],
]);
💡 Consejo: Cuando un formulario contiene un
FileType,form_start()añade automáticamenteenctype="multipart/form-data". Si pintas la etiqueta<form>a mano, no lo olvides.
9.2 Servicio FileUploader
La lógica de mover ficheros no pertenece al controlador. La encapsulamos en un servicio (los servicios se estudian a fondo en el módulo 09):
<?php
// src/Service/FileUploader.php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\String\Slugger\SluggerInterface;
final readonly class FileUploader
{
public function __construct(
#[Autowire('%kernel.project_dir%/public/uploads/covers')]
private string $targetDirectory,
private SluggerInterface $slugger,
) {
}
public function upload(UploadedFile $file): string
{
$originalName = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
$safeName = $this->slugger->slug($originalName)->lower();
// uniqid evita colisiones; guessExtension usa el tipo MIME real, no el nombre
$fileName = sprintf('%s-%s.%s', $safeName, uniqid(), $file->guessExtension());
try {
$file->move($this->targetDirectory, $fileName);
} catch (FileException $e) {
throw new \RuntimeException('No se pudo guardar el fichero.', previous: $e);
}
return $fileName;
}
}
9.3 Usarlo en el controlador
use App\Service\FileUploader;
use Symfony\Component\HttpFoundation\File\UploadedFile;
if ($form->isSubmitted() && $form->isValid()) {
/** @var UploadedFile|null $coverFile */
$coverFile = $form->get('cover')->getData();
if ($coverFile !== null) {
$article->setCoverFilename($uploader->upload($coverFile));
}
$em->flush();
// ...
}
Y en Twig:
{% if article.coverFilename %}
<img src="{{ asset('uploads/covers/' ~ article.coverFilename) }}"
class="img-fluid rounded mb-3" alt="{{ article.title }}">
{% endif %}
⚠️ Importante: Añade
public/uploads/a.gitignore: los ficheros subidos por usuarios no se versionan. Y nunca confíes en el nombre o la extensión que envía el cliente: valida conAssert\File/Assert\Imagey genera tú el nombre final.
10. CollectionType: listas de subformularios
CollectionType permite editar una lista de elementos del mismo tipo en un solo formulario. Imagina que el administrador quiere crear etiquetas nuevas directamente desde el formulario del artículo:
<?php
// src/Form/TagType.php
declare(strict_types=1);
namespace App\Form;
use App\Entity\Tag;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TagType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('name', TextType::class, ['label' => false]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => Tag::class]);
}
}
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
$builder->add('tags', CollectionType::class, [
'entry_type' => TagType::class,
'entry_options' => ['label' => false],
'allow_add' => true, // permite añadir elementos nuevos
'allow_delete' => true, // permite quitarlos
'by_reference' => false, // llama a addTag()/removeTag()
'prototype' => true, // genera una plantilla HTML para nuevos elementos
]);
Con allow_add, Symfony expone en form.tags.vars.prototype el HTML de un elemento vacío con el marcador __name__. Añadir filas en el navegador requiere un poco de JavaScript: un pequeño controlador Stimulus (módulo 04) que clone el prototipo sustituyendo __name__ por un índice. Para el blog real usaremos EntityType múltiple, que es más sencillo; CollectionType brilla en casos como líneas de un pedido o direcciones de un cliente.
11. Eventos de formulario
A veces el formulario debe cambiar según los datos. Por ejemplo: el campo slug solo debe poder editarse en artículos que todavía no se han publicado (cambiar la URL de un artículo publicado rompería enlaces).
use App\Entity\Article;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, ['label' => 'Título'])
// ... resto de campos
;
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
/** @var Article|null $article */
$article = $event->getData();
$form = $event->getForm();
// Solo artículos existentes y sin publicar pueden cambiar el slug
if ($article?->getId() !== null && $article->getPublishedAt() === null) {
$form->add('slug', TextType::class, [
'label' => 'Slug (URL)',
'help' => 'Solo editable mientras el artículo es un borrador.',
]);
}
});
}
| Evento | Momento | Uso típico |
|---|---|---|
PRE_SET_DATA | Antes de cargar los datos iniciales en el formulario | Añadir/quitar campos según el objeto |
POST_SET_DATA | Después de cargarlos | Ajustes que necesitan el formulario ya rellenado |
PRE_SUBMIT | Antes de procesar los datos enviados (array crudo) | Normalizar datos, campos dependientes (país → provincia) |
SUBMIT | Datos ya transformados, antes de asignarlos | Modificar los datos normalizados |
POST_SUBMIT | Después del envío | Lógica posterior; la validación se engancha aquí |
💡 Consejo: Los eventos de formulario son potentes pero añaden complejidad. Si solo necesitas pasar información al formulario, usa opciones personalizadas (
$resolver->setDefaults(['is_edit' => false])) y léelas enbuildForm()mediante$options['is_edit'].
12. Formularios sin clase
No todo formulario corresponde a una entidad. Para un buscador o un formulario de contacto puedes crearlo directamente en el controlador y trabajar con un array:
use Symfony\Component\Form\Extension\Core\Type\SearchType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
#[Route('/buscar', name: 'app_search')]
public function search(Request $request, ArticleRepository $articles): Response
{
$form = $this->createFormBuilder(null, [
'method' => 'GET', // los parámetros van en la URL
'csrf_protection' => false, // una búsqueda no modifica nada
])
->add('q', SearchType::class, [
'label' => false,
'attr' => ['placeholder' => 'Buscar artículos...'],
])
->add('buscar', SubmitType::class, ['attr' => ['class' => 'btn btn-outline-primary']])
->getForm();
$form->handleRequest($request);
$results = [];
if ($form->isSubmitted() && $form->isValid()) {
$data = $form->getData(); // ['q' => 'doctrine']
$results = $articles->search($data['q'] ?? '');
}
return $this->render('search/index.html.twig', [
'form' => $form,
'results' => $results,
]);
}
💡 Consejo: Para formularios sin entidad pero con varios campos (contacto, filtros avanzados), es más limpio crear un DTO (una clase simple con propiedades públicas) y usarlo como
data_class. Así tienes tipos y validación con atributos, como verás en el módulo 08.
13. Protección CSRF
📘 Concepto: Un ataque CSRF (Cross-Site Request Forgery) consiste en que una web maliciosa haga que el navegador de tu usuario, que tiene sesión iniciada en tu blog, envíe un formulario a tu aplicación sin que él lo sepa (por ejemplo, borrar un artículo).
Symfony lo evita añadiendo a cada formulario un campo oculto _token con un valor que el atacante no puede conocer. isValid() comprueba el token automáticamente: no tienes que hacer nada.
Puedes ajustar la protección por formulario:
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Article::class,
'csrf_protection' => true, // por defecto
'csrf_field_name' => '_token', // nombre del campo oculto
'csrf_token_id' => 'article_item', // identificador del token
]);
}
Botones de borrado sin FormType
Para acciones sencillas (un botón “Eliminar”) no necesitas un FormType, pero sí un token:
<form method="post" action="{{ path('app_admin_article_delete', {id: article.id}) }}"
onsubmit="return confirm('¿Seguro que quieres borrar este artículo?');">
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ article.id) }}">
<button class="btn btn-outline-danger btn-sm">Eliminar</button>
</form>
#[Route('/{id}/borrar', name: 'app_admin_article_delete', methods: ['POST'])]
public function delete(Article $article, Request $request, EntityManagerInterface $em): Response
{
if ($this->isCsrfTokenValid('delete'.$article->getId(), $request->getPayload()->getString('_token'))) {
$em->remove($article);
$em->flush();
$this->addFlash('success', 'Artículo eliminado.');
}
return $this->redirectToRoute('app_admin_article_index', status: Response::HTTP_SEE_OTHER);
}
💡 Consejo: Symfony también ofrece el atributo
#[IsCsrfTokenValid]para hacer esa comprobación de forma declarativa sobre el método del controlador. El resultado es el mismo; elige el estilo que te resulte más legible.
14. Ejemplo completo: artículos y comentarios
14.1 ArticleType definitivo
<?php
// src/Form/ArticleType.php
declare(strict_types=1);
namespace App\Form;
use App\Entity\Article;
use App\Entity\Category;
use App\Entity\Tag;
use App\Repository\CategoryRepository;
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\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
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' => 'Título',
'attr' => ['placeholder' => 'Un título claro y atractivo'],
])
->add('category', EntityType::class, [
'label' => 'Categoría',
'class' => Category::class,
'choice_label' => 'name',
'placeholder' => 'Selecciona una categoría',
'query_builder' => static fn (CategoryRepository $repo) =>
$repo->createQueryBuilder('c')->orderBy('c.name', 'ASC'),
])
->add('tags', EntityType::class, [
'label' => 'Etiquetas',
'class' => Tag::class,
'choice_label' => 'name',
'multiple' => true,
'expanded' => true,
'by_reference' => false,
'required' => false,
])
->add('content', TextareaType::class, [
'label' => 'Contenido',
'attr' => ['rows' => 14],
'help' => 'Puedes usar Markdown.',
])
->add('publishedAt', DateTimeType::class, [
'label' => 'Fecha de publicación',
'widget' => 'single_text',
'input' => 'datetime_immutable',
'required' => false,
'help' => 'Vacío = borrador.',
])
->add('cover', FileType::class, [
'label' => 'Imagen de portada',
'mapped' => false,
'required' => false,
'constraints' => [
new Assert\Image(maxSize: '2M'),
],
])
->add('notifySubscribers', CheckboxType::class, [
'label' => 'Avisar a los suscriptores al publicar',
'mapped' => false,
'required' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Article::class,
]);
}
}
14.2 Plantilla parcial compartida
{# templates/admin/article/_form.html.twig #}
{{ form_start(form) }}
<div class="row g-4">
<div class="col-lg-8">
{{ form_row(form.title) }}
{{ form_row(form.content) }}
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-body">
{{ form_row(form.category) }}
{{ form_row(form.publishedAt) }}
{{ form_row(form.cover) }}
{{ form_row(form.tags) }}
{{ form_row(form.notifySubscribers) }}
</div>
</div>
</div>
</div>
<div class="d-flex gap-2 mt-3">
<button type="submit" class="btn btn-primary">{{ button_label|default('Guardar') }}</button>
<a href="{{ path('app_admin_article_index') }}" class="btn btn-link">Cancelar</a>
</div>
{{ form_end(form) }}
{# templates/admin/article/new.html.twig #}
{% extends 'base.html.twig' %}
{% block title %}Nuevo artículo{% endblock %}
{% block body %}
<h1 class="mb-4">Nuevo artículo</h1>
{{ include('admin/article/_form.html.twig', {button_label: 'Crear artículo'}) }}
{% endblock %}
{# templates/admin/article/edit.html.twig #}
{% extends 'base.html.twig' %}
{% block title %}Editar «{{ article.title }}»{% endblock %}
{% block body %}
<h1 class="mb-4">Editar artículo</h1>
{{ include('admin/article/_form.html.twig', {button_label: 'Guardar cambios'}) }}
{% endblock %}
14.3 Formulario de comentario
<?php
// src/Form/CommentType.php
declare(strict_types=1);
namespace App\Form;
use App\Entity\Comment;
use Symfony\Component\Form\AbstractType;
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;
class CommentType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('authorName', TextType::class, ['label' => 'Tu nombre'])
->add('content', TextareaType::class, [
'label' => 'Comentario',
'attr' => ['rows' => 4],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => Comment::class]);
}
}
El formulario se muestra en la página del artículo y se procesa en la misma acción:
// src/Controller/ArticleController.php (fragmento)
use App\Entity\Comment;
use App\Form\CommentType;
#[Route('/articulo/{slug}', name: 'app_article_show', methods: ['GET', 'POST'])]
public function show(
string $slug,
Request $request,
ArticleRepository $articles,
EntityManagerInterface $em,
): Response {
$article = $articles->findOnePublishedBySlug($slug)
?? throw $this->createNotFoundException();
$comment = new Comment();
$form = $this->createForm(CommentType::class, $comment);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$article->addComment($comment); // asigna también $comment->article
$em->persist($comment);
$em->flush();
$this->addFlash('success', '¡Gracias por tu comentario!');
return $this->redirectToRoute('app_article_show', ['slug' => $slug, '_fragment' => 'comentarios'], Response::HTTP_SEE_OTHER);
}
return $this->render('article/show.html.twig', [
'article' => $article,
'comment_form' => $form,
]);
}
{# templates/article/show.html.twig (fragmento) #}
<section id="comentarios" class="mt-5">
<h2>Comentarios ({{ article.comments|length }})</h2>
{% for comment in article.comments %}
<div class="border-start border-3 ps-3 mb-3">
<strong>{{ comment.authorName }}</strong>
<small class="text-muted">{{ comment.createdAt|date('d/m/Y H:i') }}</small>
<p class="mb-0">{{ comment.content|nl2br }}</p>
</div>
{% else %}
<p class="text-muted">Sé el primero en comentar.</p>
{% endfor %}
<h3 class="h5 mt-4">Deja tu comentario</h3>
{{ form_start(comment_form) }}
{{ form_row(comment_form.authorName) }}
{{ form_row(comment_form.content) }}
<button class="btn btn-primary">Publicar comentario</button>
{{ form_end(comment_form) }}
</section>
✏️ Ejercicio: Envía el formulario de comentario vacío. ¿Se guarda un comentario vacío? Todavía no hemos añadido restricciones de validación: lo arreglarás en el módulo 08.
15. Errores comunes y buenas prácticas
| Problema | Causa | Solución |
|---|---|---|
| “The CSRF token is invalid” | Falta form_end() o la sesión ha caducado | Usa siempre form_end(); recarga la página |
| “Neither the property X nor one of the methods… exist” | El nombre del campo no coincide con la entidad | Renombra el campo o usa 'mapped' => false |
| “Expected argument of type string, null given” | Setter no admite null al enviar el campo vacío | Haz el setter nullable o usa empty_data |
| Las etiquetas ManyToMany no se guardan | Falta by_reference => false o editas el lado inverso | Añade 'by_reference' => false |
El fichero llega como null | Falta enctype o supera upload_max_filesize | Usa form_start(); revisa php.ini |
| El formulario nunca es válido y no se ven errores | Errores en campos que no pintas | Pinta form_errors(form) y revisa el panel Forms del Profiler |
💡 Consejo: Buenas prácticas con formularios:
- Un FormType por caso de uso; no intentes que un mismo formulario sirva para todo.
- Mantén el controlador delgado: la lógica (subir ficheros, generar slugs, enviar emails) va en servicios.
- Añade el botón de envío en la plantilla, no en el FormType: así reutilizas el formulario con distintos textos.
- Define las validaciones en la entidad o DTO (módulo 08), no en el HTML.
16. Ejercicios
Ejercicio 1: Formulario de categorías
Genera CategoryType con make:form para la entidad Category (campos name y slug). Crea las rutas /admin/categorias/nueva y /admin/categorias/{id}/editar reutilizando un parcial _form.html.twig. Tras guardar, muestra un mensaje flash y redirige al listado.
Ejercicio 2: Tema Bootstrap
Configura bootstrap_5_layout.html.twig en twig.yaml y comprueba el aspecto de tus formularios. Luego aplica bootstrap_5_horizontal_layout.html.twig solo al formulario de categorías usando {% form_theme %}.
Ejercicio 3: Selector de categoría y etiquetas
Añade a ArticleType el campo category (EntityType ordenado por nombre) y tags (EntityType múltiple con casillas). Edita un artículo, cambia sus etiquetas y comprueba en la base de datos (article_tag) que los cambios se guardan.
Ejercicio 4: Imagen de portada
Implementa la subida de la portada con FileType no mapeado y el servicio FileUploader. Al editar un artículo que ya tiene portada, muestra una miniatura sobre el campo. Si se sube una nueva, borra el fichero anterior del disco.
Ejercicio 5: Comentarios en el artículo
Implementa el CommentType y el procesamiento en la página de detalle del artículo, como en la sección 14.3. Haz que tras publicar el comentario la página haga scroll al bloque de comentarios (pista: _fragment).
Ejercicio 6: Formulario de contacto sin entidad
Crea /contacto con un formulario sin clase (createFormBuilder) con los campos nombre, email (EmailType), asunto (ChoiceType con tres opciones) y mensaje. Al enviarlo, muestra en pantalla los datos recibidos con getData(). (En el módulo 11 lo enviarás por email).
Ejercicio 7: Slug condicional con eventos
Usando FormEvents::PRE_SET_DATA, muestra el campo slug solo al editar artículos no publicados. Comprueba que al crear un artículo nuevo el campo no aparece y que en un artículo publicado tampoco.
Resumen
| Concepto | Sintaxis / herramienta |
|---|---|
| Generar formulario | php bin/console make:form ArticleType Article |
| Definir campos | $builder->add('title', TextType::class, ['label' => 'Título']) |
| Clase asociada | $resolver->setDefaults(['data_class' => Article::class]) |
| Crear en controlador | $form = $this->createForm(ArticleType::class, $article) |
| Procesar | $form->handleRequest($request) |
| Comprobar | $form->isSubmitted() && $form->isValid() |
| Leer datos | $form->getData() / $form->get('campo')->getData() |
| Renderizar | form(), form_start(), form_row(), form_widget(), form_errors(), form_end() |
| Tema Bootstrap | twig.form_themes: ['bootstrap_5_layout.html.twig'] |
| Elegir entidades | EntityType con class, choice_label, query_builder |
| Colecciones | 'multiple' => true, 'by_reference' => false |
| Campo no mapeado | 'mapped' => false + 'constraints' => [...] |
| Ficheros | FileType + UploadedFile::move() en un servicio |
| Eventos | $builder->addEventListener(FormEvents::PRE_SET_DATA, fn) |
| Sin clase | $this->createFormBuilder()->add(...)->getForm() |
| CSRF | Automático en formularios; csrf_token() + isCsrfTokenValid() a mano |