Módulo 08: Validación

Objetivos del módulo

  • Comprender por qué hay que validar en el servidor y qué aporta el componente Validator
  • Añadir restricciones con atributos #[Assert\...] a entidades y DTOs
  • Conocer las restricciones más habituales y sus opciones
  • Personalizar y traducir los mensajes de error
  • Validar formularios de forma automática y objetos de forma manual con ValidatorInterface
  • Usar grupos de validación y secuencias de grupos
  • Crear restricciones personalizadas y restricciones compuestas (Compound)
  • Validar peticiones de API con DTOs, #[MapRequestPayload] y #[MapQueryString]
  • Decidir cuándo validar entidades y cuándo DTOs

1. ¿Por qué validar?

En el módulo anterior viste que el formulario de comentarios acepta un comentario vacío. Peor aún: cualquiera puede saltarse el atributo HTML required con las herramientas del navegador, o enviar una petición directamente con curl.

📘 Concepto: Validar es comprobar que los datos cumplen las reglas de negocio antes de usarlos o guardarlos. La validación del navegador (HTML5, JavaScript) mejora la experiencia de usuario, pero la única validación fiable es la del servidor.

Piensa en el control de seguridad de un aeropuerto: puedes revisar tu maleta en casa (validación del cliente), pero el escáner del aeropuerto (validación del servidor) se pasa siempre, confíe o no en ti.

Validación Dónde Ventaja Limitación
HTML5 (required, type="email") Navegador Respuesta inmediata Se puede desactivar
JavaScript Navegador Reglas dinámicas Se puede desactivar
Validator de Symfony Servidor Imposible de saltar Requiere ida y vuelta
Restricciones de BD (NOT NULL, UNIQUE) Base de datos Última barrera Errores poco amigables

Instalación

Incluido en los proyectos --webapp. Si no lo tienes:

composer require symfony/validator

2. Restricciones con atributos

Las reglas se declaran como atributos sobre las propiedades. Por convención se importa el espacio de nombres con el alias Assert:

<?php
// src/Entity/Article.php
declare(strict_types=1);

namespace App\Entity;

use App\Repository\ArticleRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: ArticleRepository::class)]
#[UniqueEntity(fields: ['slug'], message: 'Ya existe un artículo con este slug.')]
class Article
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Assert\NotBlank(message: 'El título es obligatorio.')]
    #[Assert\Length(
        min: 5,
        max: 255,
        minMessage: 'El título debe tener al menos {{ limit }} caracteres.',
        maxMessage: 'El título no puede superar los {{ limit }} caracteres.',
    )]
    private ?string $title = null;

    #[ORM\Column(length: 255, unique: true)]
    #[Assert\Regex(
        pattern: '/^[a-z0-9]+(?:-[a-z0-9]+)*$/',
        message: 'El slug solo puede contener minúsculas, números y guiones.',
    )]
    private ?string $slug = null;

    #[ORM\Column(type: Types::TEXT)]
    #[Assert\NotBlank]
    #[Assert\Length(min: 50, minMessage: 'El contenido es demasiado corto ({{ limit }} caracteres mínimo).')]
    private ?string $content = null;

    #[ORM\Column]
    private ?\DateTimeImmutable $createdAt = null;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $publishedAt = null;

    #[ORM\ManyToOne(inversedBy: 'articles')]
    #[ORM\JoinColumn(nullable: false)]
    #[Assert\NotNull(message: 'Elige una categoría.')]
    private ?Category $category = null;

    // ...
}

📘 Concepto: Una restricción (constraint) describe una regla (“no puede estar vacío”, “entre 5 y 255 caracteres”). Un validador (constraint validator) es la clase que la comprueba. Tú solo escribes las restricciones; Symfony ejecuta sus validadores.

⚠️ Importante: Las opciones de las restricciones se pasan como argumentos con nombre (min: 5, message: '...'). La antigua sintaxis con un array de opciones (new Length(['min' => 5])) quedó obsoleta en Symfony 7.4 y ya no se admite en Symfony 8.

Restricción a nivel de clase

UniqueEntity se coloca sobre la clase porque necesita consultar la base de datos para saber si otro artículo usa ese slug. Viene del puente con Doctrine (Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity), no del espacio Assert.


3. Catálogo de restricciones

Restricción Ejemplo Comprueba
NotBlank #[Assert\NotBlank] No es null, '' ni []
NotNull #[Assert\NotNull] No es null (sí admite '')
Blank / IsNull #[Assert\IsNull] Lo contrario
Length #[Assert\Length(min: 3, max: 100)] Longitud de una cadena
Email #[Assert\Email(mode: 'html5')] Formato de correo
Url #[Assert\Url(requireTld: true)] URL válida
Regex #[Assert\Regex('/^\d{5}$/')] Expresión regular
Choice #[Assert\Choice(choices: ['draft', 'published'])] Valor dentro de una lista
Range #[Assert\Range(min: 1, max: 5)] Número o fecha en un rango
Positive / PositiveOrZero #[Assert\Positive] Mayor que 0 / mayor o igual que 0
GreaterThan / LessThanOrEqual #[Assert\GreaterThan('today')] Comparaciones (también con fechas)
Count #[Assert\Count(max: 5)] Número de elementos de una colección
Type #[Assert\Type('integer')] Tipo de dato
IsTrue #[Assert\IsTrue] Casillas como “acepto las condiciones”
File / Image #[Assert\Image(maxSize: '2M')] Ficheros subidos
Valid #[Assert\Valid] Valida también el objeto relacionado
All #[Assert\All(constraints: [new Assert\Email()])] Aplica restricciones a cada elemento
Callback #[Assert\Callback] en un método Lógica a medida en la propia clase
When #[Assert\When('this.isPublished()', constraints: [...])] Restricciones condicionales
PasswordStrength #[Assert\PasswordStrength] Contraseña suficientemente robusta
NotCompromisedPassword #[Assert\NotCompromisedPassword] Contraseña no filtrada en brechas conocidas
UniqueEntity #[UniqueEntity(fields: ['email'])] Valor único en la base de datos

⚠️ Importante: Casi todas las restricciones ignoran null y la cadena vacía. #[Assert\Email] sobre un campo vacío no da error. Si el campo es obligatorio, combina siempre con #[Assert\NotBlank].

Ejemplos con el resto del blog

// src/Entity/Comment.php (fragmento)
#[ORM\Column(length: 100)]
#[Assert\NotBlank(message: 'Dinos tu nombre.')]
#[Assert\Length(max: 100)]
private ?string $authorName = null;

#[ORM\Column(type: Types::TEXT)]
#[Assert\NotBlank(message: 'El comentario no puede estar vacío.')]
#[Assert\Length(min: 3, max: 2000)]
private ?string $content = null;
// src/Entity/Article.php (fragmento)
/**
 * @var Collection<int, Tag>
 */
#[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'articles')]
#[Assert\Count(max: 5, maxMessage: 'Un artículo puede tener como máximo {{ limit }} etiquetas.')]
private Collection $tags;
// src/Entity/UserProfile.php (fragmento)
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Url(requireTld: true, message: 'Introduce una URL válida (https://...).')]
private ?string $website = null;

Assert\Valid: validar en cascada

Por defecto, al validar un Article no se validan los objetos relacionados. Si quieres que los comentarios nuevos añadidos a un artículo también se validen:

#[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'article', cascade: ['persist'], orphanRemoval: true)]
#[Assert\Valid]
private Collection $comments;

4. Mensajes personalizados y traducción

4.1 Marcadores en los mensajes

Cada restricción tiene uno o varios mensajes con marcadores entre llaves dobles que Symfony sustituye:

Marcador Disponible en Valor
{{ value }} Casi todas Valor recibido
{{ limit }} Length, Count, Range… Límite configurado
{{ min }} / {{ max }} Range Mínimo y máximo
{{ compared_value }} GreaterThan, LessThan… Valor de comparación
{{ choices }} Choice Opciones válidas
#[Assert\Range(
    min: 1,
    max: 5,
    notInRangeMessage: 'La valoración debe estar entre {{ min }} y {{ max }} estrellas (has puesto {{ value }}).',
)]
private ?int $rating = null;

4.2 Mensajes en español

Symfony incluye traducciones al español de todos los mensajes por defecto. Basta con instalar el traductor y fijar el idioma:

composer require symfony/translation
# config/packages/translation.yaml
framework:
    default_locale: es
    translator:
        default_path: '%kernel.project_dir%/translations'
        fallbacks:
            - en

Ahora un #[Assert\NotBlank] sin mensaje muestra “Este valor no debería estar vacío.”.

4.3 Claves de traducción propias

Para mensajes propios multilingües, usa una clave como mensaje y tradúcela en el dominio validators:

#[Assert\NotBlank(message: 'article.title.not_blank')]
private ?string $title = null;
# translations/validators.es.yaml
article:
    title:
        not_blank: 'El título es obligatorio.'
        too_short: 'El título debe tener al menos {{ limit }} caracteres.'
# translations/validators.en.yaml
article:
    title:
        not_blank: 'The title is required.'
        too_short: 'The title must be at least {{ limit }} characters long.'

💡 Consejo: Aunque tu blog solo esté en español, usar claves de traducción centraliza todos los textos en un único fichero, lo que facilita revisarlos y cambiarlos sin tocar código.


5. Validación automática en formularios

Si tu formulario tiene data_class, no tienes que hacer nada: $form->isValid() ejecuta el validador sobre el objeto y asocia cada error a su campo.

$form = $this->createForm(CommentType::class, $comment);
$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) { // ← aquí se valida
    // ...
}
flowchart LR
    A[POST del formulario] --> B[handleRequest]
    B --> C[Datos asignados al objeto]
    C --> D{isValid}
    D -->|Restricciones OK| E[Guardar y redirigir]
    D -->|Hay violaciones| F[Errores en cada campo]
    F --> G[render con código 422]

Con el tema bootstrap_5_layout.html.twig, los campos con error reciben la clase is-invalid y el mensaje aparece en un invalid-feedback, sin escribir nada de HTML.

💡 Consejo: Los errores que no se pueden asociar a un campo concreto (por ejemplo, un UniqueEntity sobre un campo que no está en el formulario, o un Callback sin atPath()) se muestran con {{ form_errors(form) }}. Pinta siempre esa línea al principio del formulario.

Validación en campos no mapeados

Los campos con 'mapped' => false no tienen propiedad en la entidad, así que las restricciones van en la opción constraints del campo:

$builder->add('agreeTerms', CheckboxType::class, [
    'mapped' => false,
    'constraints' => [new Assert\IsTrue(message: 'Debes aceptar las normas.')],
]);

6. Validar manualmente con ValidatorInterface

Fuera de los formularios (un comando, un importador de CSV, un servicio), inyecta ValidatorInterface:

<?php
// src/Service/ArticleImporter.php
declare(strict_types=1);

namespace App\Service;

use App\Entity\Article;
use App\Entity\Category;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;

final readonly class ArticleImporter
{
    public function __construct(
        private ValidatorInterface $validator,
        private EntityManagerInterface $em,
    ) {
    }

    /**
     * @param array{title: string, slug: string, content: string} $row
     * @return list<string> Lista de errores (vacía si todo fue bien)
     */
    public function import(array $row, Category $category): array
    {
        $article = new Article();
        $article->setTitle($row['title']);
        $article->setSlug($row['slug']);
        $article->setContent($row['content']);
        $article->setCategory($category);

        $violations = $this->validator->validate($article);

        if (count($violations) > 0) {
            $errors = [];
            foreach ($violations as $violation) {
                $errors[] = sprintf('%s: %s', $violation->getPropertyPath(), $violation->getMessage());
            }

            return $errors;
        }

        $this->em->persist($article);
        $this->em->flush();

        return [];
    }
}

Salida de ejemplo si el título es demasiado corto y el slug tiene mayúsculas:

title: El título debe tener al menos 5 caracteres.
slug: El slug solo puede contener minúsculas, números y guiones.
Método de ConstraintViolationInterface Devuelve
getPropertyPath() Ruta del campo (title, comments[0].content)
getMessage() Mensaje ya traducido
getInvalidValue() Valor que no pasó la validación
getConstraint() La restricción que falló

Validar un valor suelto

También puedes validar un valor sin objeto, pasando las restricciones:

use Symfony\Component\Validator\Constraints as Assert;

$violations = $this->validator->validate($email, [
    new Assert\NotBlank(),
    new Assert\Email(),
]);

if (count($violations) > 0) {
    echo $violations[0]->getMessage(); // Este valor no es una dirección de email válida.
}

7. Restricción Callback

Cuando una regla implica varios campos de la misma clase, #[Assert\Callback] te deja escribirla como un método:

// src/Entity/Article.php (fragmento)
use Symfony\Component\Validator\Context\ExecutionContextInterface;

#[Assert\Callback]
public function validatePublication(ExecutionContextInterface $context, mixed $payload): void
{
    // La fecha de publicación no puede ser anterior a la de creación
    if ($this->publishedAt !== null && $this->createdAt !== null && $this->publishedAt < $this->createdAt) {
        $context->buildViolation('La publicación no puede ser anterior a la creación del artículo.')
            ->atPath('publishedAt')   // asocia el error al campo
            ->addViolation();
    }

    // El título no debe repetirse tal cual al inicio del contenido
    if ($this->title !== null && $this->content !== null && str_starts_with($this->content, $this->title)) {
        $context->buildViolation('No repitas el título al comienzo del contenido.')
            ->atPath('content')
            ->addViolation();
    }
}

💡 Consejo: Callback es perfecto para reglas ligadas a una sola clase. Si la regla se repite en varias clases o necesita servicios (por ejemplo, consultar la base de datos), crea una restricción personalizada (sección 9).


8. Grupos de validación y secuencias

8.1 El problema

Un artículo borrador puede tener el contenido a medias, pero para publicarlo el contenido debe tener al menos 300 caracteres y una categoría. Las mismas propiedades, reglas distintas según la situación.

📘 Concepto: Un grupo de validación es una etiqueta que agrupa restricciones. Al validar indicas qué grupos aplicar. Las restricciones sin grupo pertenecen al grupo Default (y también a un grupo con el nombre de la clase, por ejemplo Article).

#[ORM\Column(type: Types::TEXT)]
#[Assert\NotBlank]                                       // grupo Default
#[Assert\Length(min: 300, groups: ['publish'])]          // solo al publicar
private ?string $content = null;

#[ORM\Column(nullable: true)]
#[Assert\NotNull(message: 'Indica la fecha de publicación.', groups: ['publish'])]
private ?\DateTimeImmutable $publishedAt = null;

8.2 Elegir grupos

En un formulario:

// src/Form/ArticlePublishType.php
public function configureOptions(OptionsResolver $resolver): void
{
    $resolver->setDefaults([
        'data_class' => Article::class,
        'validation_groups' => ['Default', 'publish'],
    ]);
}

O decidirlo según los datos con una función:

use Symfony\Component\Form\FormInterface;

$resolver->setDefaults([
    'data_class' => Article::class,
    'validation_groups' => static function (FormInterface $form): array {
        /** @var Article $article */
        $article = $form->getData();

        return $article->getPublishedAt() !== null ? ['Default', 'publish'] : ['Default'];
    },
]);

Manualmente:

$violations = $validator->validate($article, groups: ['Default', 'publish']);

8.3 Secuencias de grupos

Con una secuencia, los grupos se validan en orden y se detiene en el primero que falle. Es útil para no lanzar una comprobación costosa si las básicas ya fallan:

#[Assert\GroupSequence(['Article', 'Strict'])]
#[UniqueEntity(fields: ['slug'], groups: ['Strict'])] // consulta la BD solo si lo demás es válido
class Article
{
    // ...
}

Primero se valida el grupo Article (equivale a Default para esta clase). Solo si pasa, se valida Strict, que lanza la consulta de unicidad.

⚠️ Importante: Dentro de una GroupSequence sobre la clase no puedes usar Default como elemento (provocaría un bucle); usa el nombre de la clase. Si necesitas decidir la secuencia en tiempo de ejecución existe #[Assert\GroupSequenceProvider].


9. Restricciones personalizadas

Vamos a crear una restricción NoForbiddenWords para rechazar comentarios con palabras ofensivas. La lista de palabras vendrá de un parámetro, lo que demuestra que el validador es un servicio con dependencias inyectadas.

php bin/console make:validator NoForbiddenWords

9.1 La restricción

<?php
// src/Validator/NoForbiddenWords.php
declare(strict_types=1);

namespace App\Validator;

use Symfony\Component\Validator\Constraint;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD)]
final class NoForbiddenWords extends Constraint
{
    public string $message = 'Tu texto contiene una palabra no permitida: "{{ word }}".';

    public function __construct(?string $message = null, ?array $groups = null, mixed $payload = null)
    {
        parent::__construct(null, $groups, $payload);

        $this->message = $message ?? $this->message;
    }
}

9.2 El validador

<?php
// src/Validator/NoForbiddenWordsValidator.php
declare(strict_types=1);

namespace App\Validator;

use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;

final class NoForbiddenWordsValidator extends ConstraintValidator
{
    /**
     * @param list<string> $forbiddenWords
     */
    public function __construct(
        #[Autowire('%app.forbidden_words%')]
        private readonly array $forbiddenWords,
    ) {
    }

    public function validate(mixed $value, Constraint $constraint): void
    {
        if (!$constraint instanceof NoForbiddenWords) {
            throw new UnexpectedTypeException($constraint, NoForbiddenWords::class);
        }

        // Deja null y '' a NotBlank
        if ($value === null || $value === '') {
            return;
        }

        if (!is_string($value)) {
            throw new UnexpectedValueException($value, 'string');
        }

        $normalized = mb_strtolower($value);

        foreach ($this->forbiddenWords as $word) {
            if (preg_match('/\b'.preg_quote($word, '/').'\b/u', $normalized)) {
                $this->context->buildViolation($constraint->message)
                    ->setParameter('{{ word }}', $word)
                    ->addViolation();

                return;
            }
        }
    }
}
# config/services.yaml
parameters:
    app.forbidden_words: ['spam', 'casino', 'tonto']

9.3 Usarla

// src/Entity/Comment.php (fragmento)
use App\Validator\NoForbiddenWords;

#[ORM\Column(type: Types::TEXT)]
#[Assert\NotBlank]
#[Assert\Length(min: 3, max: 2000)]
#[NoForbiddenWords]
private ?string $content = null;

📘 Concepto: Symfony asocia NoForbiddenWords con NoForbiddenWordsValidator por convención de nombres (sufijo Validator), usando el método validatedBy() heredado. Gracias a la autoconfiguración, el validador se registra como servicio y recibe sus dependencias por autowiring.

9.4 Restricciones de clase

Si la regla necesita el objeto completo (por ejemplo, “un artículo de la categoría Noticias debe tener fecha de publicación”), la restricción se aplica a la clase. En la restricción añades:

#[\Attribute(\Attribute::TARGET_CLASS)]
final class NewsMustBeDated extends Constraint
{
    public string $message = 'Las noticias deben tener fecha de publicación.';

    public function getTargets(): string
    {
        return self::CLASS_CONSTRAINT;
    }
}

Y en el validador $value será el objeto Article completo; usa ->atPath('publishedAt') para asociar el error a un campo.


10. Restricciones compuestas (Compound)

Cuando repites siempre el mismo conjunto de reglas, agrúpalas en una restricción compuesta. Ejemplo: los requisitos de contraseña que usaremos en el registro (módulo 10).

<?php
// src/Validator/PasswordRequirements.php
declare(strict_types=1);

namespace App\Validator;

use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Constraints\Compound;

#[\Attribute]
final class PasswordRequirements extends Compound
{
    protected function getConstraints(array $options): array
    {
        return [
            new Assert\NotBlank(message: 'Introduce una contraseña.'),
            new Assert\Type('string'),
            new Assert\Length(min: 12, max: 4096, minMessage: 'Mínimo {{ limit }} caracteres.'),
            new Assert\PasswordStrength(),
            new Assert\NotCompromisedPassword(),
        ];
    }
}
// Uso en un DTO o en un campo no mapeado
#[PasswordRequirements]
public string $plainPassword = '';

💡 Consejo: Otra compuesta útil para el blog sería SlugRequirements (NotBlank + Length(max: 255) + Regex), aplicada tanto a Article::$slug como a Category::$slug.


11. DTOs con #[MapRequestPayload] y #[MapQueryString]

En una API no hay formularios: llega un JSON. Symfony puede deserializar ese JSON en un objeto (un DTO), validarlo y, si falla, responder automáticamente con un 422.

📘 Concepto: Un DTO (Data Transfer Object) es una clase simple cuyo único propósito es transportar datos de entrada o salida. No tiene lógica de negocio ni está ligada a Doctrine.

Necesitas el Serializer (se estudia a fondo en el módulo 12):

composer require symfony/serializer-pack

11.1 El DTO

<?php
// src/Dto/CreateArticleRequest.php
declare(strict_types=1);

namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;

final readonly class CreateArticleRequest
{
    /**
     * @param list<string> $tags
     */
    public function __construct(
        #[Assert\NotBlank]
        #[Assert\Length(min: 5, max: 255)]
        public string $title,

        #[Assert\NotBlank]
        #[Assert\Length(min: 50)]
        public string $content,

        #[Assert\NotBlank]
        #[Assert\Positive]
        public int $categoryId,

        #[Assert\Count(max: 5)]
        #[Assert\All(constraints: [new Assert\NotBlank(), new Assert\Length(max: 50)])]
        public array $tags = [],

        public bool $publishNow = false,
    ) {
    }
}

11.2 El controlador

<?php
// src/Controller/Api/ArticleApiController.php
declare(strict_types=1);

namespace App\Controller\Api;

use App\Dto\CreateArticleRequest;
use App\Entity\Article;
use App\Repository\CategoryRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\String\Slugger\SluggerInterface;

#[Route('/api/articles')]
final class ArticleApiController extends AbstractController
{
    #[Route('', name: 'api_article_create', methods: ['POST'])]
    public function create(
        #[MapRequestPayload] CreateArticleRequest $dto,
        CategoryRepository $categories,
        EntityManagerInterface $em,
        SluggerInterface $slugger,
    ): JsonResponse {
        // Si llegamos aquí, $dto ya es válido
        $category = $categories->find($dto->categoryId)
            ?? throw $this->createNotFoundException('Categoría no encontrada');

        $article = new Article();
        $article->setTitle($dto->title);
        $article->setSlug($slugger->slug($dto->title)->lower()->toString());
        $article->setContent($dto->content);
        $article->setCategory($category);
        if ($dto->publishNow) {
            $article->setPublishedAt(new \DateTimeImmutable());
        }

        $em->persist($article);
        $em->flush();

        return $this->json(
            ['id' => $article->getId(), 'slug' => $article->getSlug()],
            Response::HTTP_CREATED,
        );
    }
}

11.3 Probarlo

curl -X POST https://127.0.0.1:8000/api/articles \
  -H "Content-Type: application/json" \
  -d '{"title": "Hola", "content": "Corto", "categoryId": 0}'

Respuesta (resumida) con código 422 Unprocessable Content:

{
    "type": "https://symfony.com/errors/validation",
    "title": "Validation Failed",
    "status": 422,
    "detail": "title: Este valor es demasiado corto. Debería tener 5 caracteres o más.\ncontent: ...",
    "violations": [
        {
            "propertyPath": "title",
            "title": "Este valor es demasiado corto. Debería tener 5 caracteres o más.",
            "parameters": { "{{ value }}": "\"Hola\"", "{{ limit }}": "5" }
        },
        {
            "propertyPath": "categoryId",
            "title": "Este valor debería ser positivo."
        }
    ]
}
Situación Código de respuesta
JSON válido y datos válidos Lo que devuelva tu controlador (201)
Datos que no cumplen las restricciones 422 con la lista de violaciones
JSON mal formado o tipos imposibles de convertir 400 (o 422, según el error de deserialización)
Formato no soportado 415

💡 Consejo: Puedes ajustar el comportamiento con argumentos del atributo: #[MapRequestPayload(validationGroups: ['create'])], acceptFormat: 'json' o validationFailedStatusCode: 400 si tu API sigue otra convención.

11.4 #[MapQueryString]: filtros en la URL

Para listados con filtros (/api/articles?category=symfony&page=2), #[MapQueryString] mapea la query string completa a un DTO:

<?php
// src/Dto/ArticleFilter.php
declare(strict_types=1);

namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;

final readonly class ArticleFilter
{
    public function __construct(
        #[Assert\Length(max: 100)]
        public ?string $q = null,

        #[Assert\Regex('/^[a-z0-9-]+$/')]
        public ?string $category = null,

        #[Assert\Positive]
        public int $page = 1,

        #[Assert\Range(min: 1, max: 50)]
        public int $perPage = 10,
    ) {
    }
}
use App\Dto\ArticleFilter;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;

#[Route('', name: 'api_article_list', methods: ['GET'])]
public function list(#[MapQueryString] ?ArticleFilter $filter, ArticleRepository $articles): JsonResponse
{
    $filter ??= new ArticleFilter(); // sin parámetros en la URL

    // ... usar $filter->q, $filter->category, $filter->page
    return $this->json(['page' => $filter->page, 'perPage' => $filter->perPage]);
}

📘 Concepto: Recuerda la diferencia: #[MapQueryParameter] (módulo 02) mapea un parámetro a un argumento escalar; #[MapQueryString] mapea todos los parámetros a un objeto validado.


12. ¿Validar entidades o DTOs?

Las dos opciones son válidas y conviven en proyectos reales.

Aspecto Validar la entidad Validar un DTO
Código necesario Mínimo Una clase más y un mapeo DTO → entidad
Formularios CRUD sencillos Ideal Excesivo
APIs Expone la estructura de la BD Recomendado: contrato estable
Datos que no existen en la entidad (plainPassword, agreeTerms) Campos no mapeados o propiedades “extra” Naturales en el DTO
Entidades en estado inválido Pueden existir temporalmente en memoria La entidad solo se crea con datos válidos
Reglas distintas por caso de uso Grupos de validación Un DTO por caso de uso

💡 Consejo: Regla práctica para este curso:

  • Formularios de administración que reflejan la entidad (crear/editar artículo, categoría): valida la entidad.
  • Entradas de API, formularios que no se parecen a la entidad (registro, cambio de contraseña, búsqueda): usa DTOs.
  • Mantén siempre también las restricciones de base de datos (unique, nullable: false) como última barrera.

13. Errores comunes

Error Causa Solución
#[Assert\Email] acepta un campo vacío Las restricciones ignoran null/'' Añade #[Assert\NotBlank]
No aparecen mensajes en español Falta el traductor o default_locale composer require symfony/translation y default_locale: es
UniqueEntity no hace nada Se puso en la propiedad o falta fields Va sobre la clase: #[UniqueEntity(fields: ['slug'])]
Las restricciones de un grupo no se aplican No se indicó el grupo al validar validation_groups en el formulario o groups: en validate()
Error “Unexpected argument type” en el validador El valor no es del tipo esperado Lanza UnexpectedValueException y combina con Type
La API devuelve 500 en lugar de 422 Excepción en el constructor del DTO o tipos incompatibles Usa tipos simples y valores por defecto en el DTO
Un objeto relacionado no se valida Falta #[Assert\Valid] Añádelo a la propiedad de la relación

14. Ejercicios

Ejercicio 1: Validar el blog

Añade restricciones a Article, Category, Comment y Tag: campos obligatorios, longitudes máximas coherentes con las columnas, Regex para los slugs y UniqueEntity para Category::$slug y Tag::$name. Comprueba en el formulario de comentarios que ya no se puede enviar vacío.

Ejercicio 2: Mensajes en español con claves

Configura default_locale: es. Sustituye los mensajes de Article por claves de traducción (article.title.not_blank…) y crea translations/validators.es.yaml con sus textos.

Ejercicio 3: Importador validado

Crea el servicio ArticleImporter de la sección 6 y una ruta temporal que importe tres artículos de un array PHP, dos de ellos con errores. Muestra en pantalla los mensajes de error obtenidos de ConstraintViolationList.

Ejercicio 4: Borrador frente a publicado

Define un grupo publish que exija contenido de al menos 300 caracteres y fecha de publicación. Haz que el formulario de artículo aplique ['Default', 'publish'] solo cuando el usuario rellena publishedAt, usando una función en validation_groups.

Ejercicio 5: Palabras prohibidas

Implementa NoForbiddenWords con su validador y la lista en un parámetro. Aplícala a Comment::$content y a Article::$title. Prueba con mayúsculas (“SPAM”) para comprobar que se detecta igualmente.

Ejercicio 6: API de creación de artículos

Crea el DTO CreateArticleRequest y el endpoint POST /api/articles con #[MapRequestPayload]. Usa curl (o la extensión REST de tu editor) para enviar un JSON válido (201) y otro inválido (422) y analiza la estructura de la respuesta de error.

Ejercicio 7: Filtros validados

Crea GET /api/articles con #[MapQueryString] ?ArticleFilter $filter que devuelva los artículos publicados filtrados por texto y categoría y paginados (reutiliza los métodos del módulo 06). Comprueba que ?perPage=500 devuelve un 422.


Resumen

Concepto Sintaxis / herramienta
Importar restricciones use Symfony\Component\Validator\Constraints as Assert;
Obligatorio #[Assert\NotBlank] / #[Assert\NotNull]
Longitud #[Assert\Length(min: 5, max: 255)]
Formatos #[Assert\Email], #[Assert\Url], #[Assert\Regex('/.../')]
Números y opciones #[Assert\Range], #[Assert\Positive], #[Assert\Choice(choices: [...])]
Unicidad en BD #[UniqueEntity(fields: ['slug'])] sobre la clase
Objetos relacionados #[Assert\Valid]
Lógica en la clase #[Assert\Callback] + ExecutionContextInterface
Mensajes message: '... {{ limit }} ...' y translations/validators.es.yaml
Validar manualmente $validator->validate($objeto) → ConstraintViolationList
Grupos groups: ['publish'] + validation_groups en el formulario
Secuencias #[Assert\GroupSequence(['Article', 'Strict'])]
Restricción propia Constraint + ConstraintValidator (make:validator)
Compuesta extends Compound + getConstraints()
Cuerpo JSON validado #[MapRequestPayload] Dto $dto → 422 si falla
Query string validada #[MapQueryString] ?Dto $filtro