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:form y comprender buildForm() y configureOptions()
  • 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:

  1. Generar el HTML con los valores actuales (al editar).
  2. Recoger los datos enviados y convertirlos al tipo correcto (texto → DateTimeImmutable, id → entidad Category…).
  3. Validarlos y mostrar los errores junto a cada campo.
  4. Protegerte frente a ataques CSRF.
  5. 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 ArticleType es el padre y cada campo (title, content) es a su vez un formulario hijo. Por eso un campo puede ser otro FormType completo (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 a addTag() y removeTag() 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: required solo añade el atributo HTML5 required, 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 Article genera 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 de form_end(), el formulario fallará con “The CSRF token is invalid”.

💡 Consejo: El atributo novalidate desactiva 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áticamente enctype="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 con Assert\File/Assert\Image y 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 en buildForm() 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