Módulo 12: APIs REST

Objetivos del módulo

  • Repasar los principios de diseño de una API REST: recursos, verbos HTTP y códigos de estado
  • Devolver JSON desde controladores con JsonResponse y $this->json()
  • Dominar el componente Serializer: normalizers, encoders, grupos, #[SerializedName], #[Ignore], contextos, referencias circulares y #[MaxDepth]
  • Recibir y validar datos de entrada con DTOs, #[MapRequestPayload] y #[MapQueryString]
  • Construir un CRUD API completo de artículos del blog con paginación y filtros
  • Devolver los errores en JSON con el formato estándar Problem Details
  • Proteger la API con tokens de acceso (Authorization: Bearer ...)
  • Configurar CORS con NelmioCorsBundle y documentar la API
  • Conocer API Platform y saber cuándo elegirlo frente a controladores propios
  • Probar la API con curl y con ficheros .http

1. Diseño REST: repaso rápido

📘 Concepto: Una API REST expone recursos (artículos, categorías, comentarios…) a través de URLs, y usa los verbos HTTP para indicar qué quieres hacer con ellos. El servidor responde con una representación del recurso (normalmente JSON) y un código de estado que resume el resultado.

Piensa en una API REST como en la ventanilla de una biblioteca: la URL es el estante (/api/articles/42), el verbo es lo que pides (“préstame”, “devuelve”, “cataloga uno nuevo”) y el código de estado es la respuesta del bibliotecario (“aquí lo tienes”, “no existe”, “no tienes carné”).

Recursos y URLs

URL Significado
/api/articles La colección de artículos
/api/articles/42 Un artículo concreto (item)
/api/articles/42/comments Los comentarios del artículo 42 (subrecurso)
/api/categories La colección de categorías

Buenas prácticas en las URLs:

  • Usa sustantivos en plural (/articles), nunca verbos (/getArticles, /createArticle).
  • El verbo HTTP ya expresa la acción.
  • Usa minúsculas y guiones (/api/blog-posts), no camelCase.
  • Los filtros, la ordenación y la paginación van en la query string: /api/articles?category=symfony&page=2.

Verbos HTTP

Verbo Uso ¿Idempotente? Respuesta típica
GET Leer una colección o un item Sí 200 OK
POST Crear un recurso nuevo en una colección No 201 Created + cabecera Location
PUT Reemplazar completamente un recurso Sí 200 OK
PATCH Modificar parcialmente un recurso No necesariamente 200 OK
DELETE Borrar un recurso Sí 204 No Content

📘 Concepto: Una operación es idempotente si repetirla varias veces produce el mismo estado que hacerla una sola vez. Borrar el artículo 42 dos veces deja el sistema igual que borrarlo una vez; crear un artículo dos veces con POST genera dos artículos.

Códigos de estado más usados

Código Nombre Cuándo usarlo
200 OK Lectura o modificación correcta
201 Created Recurso creado (con POST)
204 No Content Éxito sin cuerpo de respuesta (DELETE)
400 Bad Request JSON mal formado o petición incomprensible
401 Unauthorized Falta autenticación o el token no es válido
403 Forbidden Autenticado, pero sin permiso
404 Not Found El recurso no existe
405 Method Not Allowed Verbo no soportado en esa URL
409 Conflict Conflicto de estado (p. ej. slug duplicado)
415 Unsupported Media Type El cliente envía un formato que no aceptas
422 Unprocessable Content JSON válido pero datos que no pasan la validación
500 Internal Server Error Error no controlado en el servidor

⚠️ Importante: No devuelvas 200 OK con un cuerpo {"error": "..."}. Los clientes (y las herramientas de monitorización) confían en el código de estado: si algo falla, el código debe decirlo.


2. Devolver JSON desde un controlador

JsonResponse a mano

La forma más básica es crear un JsonResponse con un array:

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

namespace App\Controller\Api;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

final class PingController
{
    #[Route('/api/ping', name: 'api_ping', methods: ['GET'])]
    public function __invoke(): JsonResponse
    {
        return new JsonResponse([
            'status' => 'ok',
            'time' => (new \DateTimeImmutable())->format(\DATE_ATOM),
        ]);
    }
}

Salida esperada de GET /api/ping:

{"status":"ok","time":"2026-10-03T10:15:00+02:00"}

JsonResponse se encarga de codificar el array con json_encode() y de poner la cabecera Content-Type: application/json.

$this->json() en controladores que extienden AbstractController

Si tu controlador extiende AbstractController, tienes el atajo $this->json():

public function json(mixed $data, int $status = 200, array $headers = [], array $context = []): JsonResponse

La gran ventaja es que, si el componente Serializer está instalado, $this->json() lo usa automáticamente. Eso significa que puedes pasarle objetos (entidades, DTOs) y no solo arrays:

return $this->json($article, 200, [], ['groups' => ['article:read']]);

⚠️ Importante: Nunca hagas new JsonResponse($article) con una entidad: json_encode() solo ve las propiedades públicas y obtendrías {}. Para objetos usa siempre el Serializer (directamente o a través de $this->json()).


3. El componente Serializer

Instalación

composer require symfony/serializer-pack

El pack instala el Serializer y sus dependencias habituales (PropertyAccess, PropertyInfo, PHPDoc parser…).

Cómo funciona: normalizar + codificar

📘 Concepto: Serializar un objeto ocurre en dos pasos. Primero un normalizer convierte el objeto en un array de valores simples; después un encoder convierte ese array en texto (JSON, XML, CSV, YAML). Deserializar es el camino inverso: decode (texto → array) y denormalize (array → objeto).

flowchart LR
    O[Objeto Article] -- normalize --> A[array PHP]
    A -- encode --> J["JSON (texto)"]
    J -- decode --> A2[array PHP]
    A2 -- denormalize --> O2[Objeto Article / DTO]

Normalizers habituales que vienen registrados:

Normalizer Qué hace
ObjectNormalizer Objetos genéricos usando getters/setters y propiedades públicas
DateTimeNormalizer DateTimeInterface ↔ cadena (RFC 3339 por defecto)
BackedEnumNormalizer Enums con valor (string/int)
UidNormalizer Uuid y Ulid
ConstraintViolationListNormalizer Errores de validación (formato Problem Details)
ProblemNormalizer Excepciones (FlattenException) en formato Problem Details
ArrayDenormalizer Arrays de objetos (Article[])

Usar el Serializer como servicio

use Symfony\Component\Serializer\SerializerInterface;

// en un controlador o servicio, inyectado por autowiring
public function show(Article $article, SerializerInterface $serializer): JsonResponse
{
    // serialize() = normalize() + encode()
    $json = $serializer->serialize($article, 'json', ['groups' => ['article:read']]);

    // el cuarto argumento "true" indica que $json ya es JSON y no hay que codificarlo otra vez
    return new JsonResponse($json, 200, [], true);
}

💡 Consejo: SerializerInterface ofrece serialize() y deserialize(). Si solo necesitas el array (por ejemplo para añadir metadatos de paginación antes de codificar), inyecta NormalizerInterface y llama a normalize().

El problema de serializar entidades “tal cual”

Si serializas un Article sin más, el ObjectNormalizer intentará exportar todo: el autor (con su contraseña hasheada y roles), la categoría, los comentarios, cada comentario con su artículo… y así hasta el infinito. Necesitas controlar qué sale. Para eso están los grupos.

Grupos de serialización con #[Groups]

📘 Concepto: Un grupo es una etiqueta que pones en las propiedades. Al serializar indicas qué grupos quieres y solo se exportan las propiedades marcadas con alguno de ellos. Así una misma entidad puede tener una vista “resumen” para listados y otra “detalle” para la ficha.

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

namespace App\Entity;

use App\Repository\ArticleRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\Ignore;
use Symfony\Component\Serializer\Attribute\MaxDepth;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;

#[ORM\Entity(repositoryClass: ArticleRepository::class)]
class Article
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['article:list', 'article:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Groups(['article:list', 'article:read'])]
    private string $title = '';

    #[ORM\Column(length: 255, unique: true)]
    #[Groups(['article:list', 'article:read'])]
    private string $slug = '';

    #[ORM\Column(type: Types::TEXT)]
    #[Groups(['article:read'])]               // el contenido solo en el detalle
    private string $content = '';

    #[ORM\Column]
    #[Groups(['article:read'])]
    #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d H:i'])]
    private \DateTimeImmutable $createdAt;

    #[ORM\Column(nullable: true)]
    #[Groups(['article:list', 'article:read'])]
    #[SerializedName('published_at')]         // en el JSON se llamará "published_at"
    private ?\DateTimeImmutable $publishedAt = null;

    #[ORM\ManyToOne(inversedBy: 'articles')]
    #[ORM\JoinColumn(nullable: false)]
    #[Groups(['article:list', 'article:read'])]
    private ?User $author = null;

    #[ORM\ManyToOne(inversedBy: 'articles')]
    #[Groups(['article:list', 'article:read'])]
    private ?Category $category = null;

    /** @var Collection<int, Comment> */
    #[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'article', orphanRemoval: true)]
    #[Groups(['article:read'])]
    #[MaxDepth(1)]
    private Collection $comments;

    #[ORM\Column(nullable: true)]
    #[Ignore]                                  // nunca se serializa, en ningún grupo
    private ?string $internalNotes = null;

    public function __construct()
    {
        $this->createdAt = new \DateTimeImmutable();
        $this->comments = new ArrayCollection();
    }

    // ... getters y setters
}

En las entidades relacionadas marca solo lo que quieras exponer:

// src/Entity/User.php (fragmento)
#[ORM\Column(length: 180, unique: true)]
#[Groups(['article:read'])]          // el email solo en el detalle
private string $email = '';

#[ORM\Column(length: 100)]
#[Groups(['article:list', 'article:read'])]
private string $name = '';

#[ORM\Column]
private ?string $password = null;    // sin grupo: jamás saldrá en la API

// src/Entity/Category.php (fragmento)
#[Groups(['article:list', 'article:read', 'category:read'])]
private string $name = '';

Ahora puedes pedir dos vistas distintas del mismo objeto:

$this->json($articles, context: ['groups' => ['article:list']]);   // listado ligero
$this->json($article, context: ['groups' => ['article:read']]);    // detalle completo

Salida esperada del detalle:

{
  "id": 42,
  "title": "Novedades de Symfony 8.1",
  "slug": "novedades-de-symfony-8-1",
  "content": "Symfony 8.1 trae comandos basados en métodos...",
  "createdAt": "2026-09-20 18:30",
  "published_at": "2026-09-21T09:00:00+02:00",
  "author": { "email": "ana@blog.test", "name": "Ana" },
  "category": { "name": "Symfony" },
  "comments": [ { "id": 7, "content": "¡Muy útil!" } ]
}

💡 Consejo: Usa una convención de nombres para los grupos: recurso:operación (article:list, article:read, article:write). Te ahorrará confusiones cuando la API crezca.

Resumen de atributos del Serializer

Todos están en el espacio de nombres Symfony\Component\Serializer\Attribute:

Atributo Para qué sirve
#[Groups(['a', 'b'])] Incluye la propiedad cuando se serializa con alguno de esos grupos
#[SerializedName('nombre')] Cambia el nombre de la clave en el JSON
#[Ignore] Excluye la propiedad siempre
#[MaxDepth(1)] Limita la profundidad de anidamiento (requiere enable_max_depth)
#[Context([...])] Contexto específico para una propiedad (formato de fecha, etc.)
#[SerializedPath('[meta][autor]')] Mapea la propiedad a una ruta anidada del array

El contexto de serialización

📘 Concepto: El contexto es un array de opciones que acompaña a cada llamada de serialización y modifica su comportamiento: grupos, formato de fechas, si se omiten los null, etc.

use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;

$json = $serializer->serialize($article, 'json', [
    'groups' => ['article:read'],
    AbstractObjectNormalizer::SKIP_NULL_VALUES => true,       // omite claves con null
    DateTimeNormalizer::FORMAT_KEY => 'd/m/Y',                // formato de fecha global
    'json_encode_options' => \JSON_PRETTY_PRINT | \JSON_UNESCAPED_UNICODE,
]);

También puedes definir un contexto por defecto para toda la aplicación:

# config/packages/serializer.yaml
framework:
    serializer:
        default_context:
            enable_max_depth: true
            skip_null_values: true

Referencias circulares

Un Article tiene comments, y cada Comment tiene article. Si serializas sin grupos, el normalizer entra en un bucle: artículo → comentario → artículo → comentario… Symfony lo detecta y lanza una CircularReferenceException.

Tienes tres soluciones, de mejor a peor:

  1. Grupos (la buena): no pongas article:read en Comment::$article. Si la propiedad no se exporta, el ciclo desaparece.
  2. #[MaxDepth] con enable_max_depth: true: corta el anidamiento a un nivel.
  3. Manejador de referencias circulares: en vez de lanzar excepción, devuelve algo sencillo, como el id:
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;

$json = $serializer->serialize($article, 'json', [
    AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER => fn (object $object): mixed => $object->getId(),
]);

⚠️ Importante: Si te aparece A circular reference has been detected when serializing the object of class "App\Entity\Article", casi siempre significa que te has olvidado de pasar los grupos en el contexto, o que has puesto el mismo grupo en los dos lados de una relación bidireccional.

Deserializar

use App\Entity\Category;

$json = '{"name": "Doctrine", "description": "Todo sobre el ORM"}';

/** @var Category $category */
$category = $serializer->deserialize($json, Category::class, 'json');

// actualizar un objeto existente en vez de crear uno nuevo
$serializer->deserialize($json, Category::class, 'json', [
    AbstractNormalizer::OBJECT_TO_POPULATE => $existingCategory,
]);

💡 Consejo: Deserializar directamente en entidades es cómodo pero arriesgado: el cliente podría enviar campos que no debería tocar (author, id, createdAt…). Para la entrada de datos es mucho más seguro usar DTOs, como verás a continuación.


4. Entrada de datos con DTOs: #[MapRequestPayload] y #[MapQueryString]

En el módulo 08 viste #[MapRequestPayload] para validar datos. En una API es la herramienta central: Symfony deserializa el cuerpo JSON en un DTO (Data Transfer Object), lo valida y, si todo va bien, te lo entrega listo en el controlador.

📘 Concepto: Un DTO es una clase sencilla que solo describe la forma de los datos que esperas recibir. Hace de “aduana”: todo lo que no está declarado en el DTO no entra en tu aplicación.

El DTO de entrada para artículos

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

namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;

final class ArticleInput
{
    public function __construct(
        #[Assert\NotBlank]
        #[Assert\Length(min: 5, max: 255)]
        public readonly string $title = '',

        #[Assert\NotBlank]
        #[Assert\Length(min: 20)]
        public readonly string $content = '',

        #[Assert\Positive]
        public readonly ?int $categoryId = null,

        /** @var list<string> */
        #[Assert\All([new Assert\Length(max: 30)])]
        public readonly array $tags = [],

        public readonly bool $publish = false,
    ) {
    }
}

Recibir el DTO en el controlador

use App\Dto\ArticleInput;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;

#[Route('/api/articles', methods: ['POST'])]
public function create(#[MapRequestPayload] ArticleInput $input): JsonResponse
{
    // Si llegas aquí, $input ya está deserializado Y validado
    // ...
}

Lo que hace Symfony por ti:

Situación Respuesta automática
JSON mal formado 400 Bad Request
Content-Type no soportado 415 Unsupported Media Type
Tipos incompatibles (p. ej. "title": 123 con tipado estricto) 422 con detalle del campo
Restricciones de validación incumplidas 422 Unprocessable Content con la lista de violaciones

La respuesta de validación sigue el formato Problem Details:

{
  "type": "https://symfony.com/errors/validation",
  "title": "Validation Failed",
  "status": 422,
  "detail": "title: This value should not be blank.",
  "violations": [
    {
      "propertyPath": "title",
      "title": "This value should not be blank.",
      "template": "This value should not be blank.",
      "parameters": { "{{ value }}": "\"\"" },
      "type": "urn:uuid:c1051bb4-d103-4f74-8988-acbcafc7fdc3"
    }
  ]
}

Opciones útiles del atributo:

#[MapRequestPayload(
    acceptFormat: 'json',                          // solo acepta JSON
    validationGroups: ['Default', 'create'],       // grupos de validación
    serializationContext: ['groups' => ['article:write']],
    validationFailedStatusCode: Response::HTTP_UNPROCESSABLE_ENTITY, // 422 es el valor por defecto
)] ArticleInput $input

Para recibir un array de DTOs (por ejemplo, una importación masiva) indica el tipo de los elementos:

public function import(#[MapRequestPayload(type: ArticleInput::class)] array $articles): JsonResponse

Filtros y paginación con #[MapQueryString]

Igual que el cuerpo, puedes mapear toda la query string a un DTO:

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

namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;

final class ArticleListQuery
{
    public function __construct(
        #[Assert\Positive]
        public readonly int $page = 1,

        #[Assert\Range(min: 1, max: 50)]
        public readonly int $limit = 10,

        public readonly ?string $category = null,   // slug de la categoría

        #[Assert\Length(min: 3)]
        public readonly ?string $q = null,          // búsqueda en el título

        #[Assert\Choice(['publishedAt', '-publishedAt', 'title', '-title'])]
        public readonly string $sort = '-publishedAt',
    ) {
    }
}
use App\Dto\ArticleListQuery;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;

public function list(
    #[MapQueryString(validationFailedStatusCode: Response::HTTP_UNPROCESSABLE_ENTITY)]
    ArticleListQuery $query = new ArticleListQuery(),
): JsonResponse

⚠️ Importante: Si la petición llega sin query string y el argumento no tiene valor por defecto, Symfony no puede construir el DTO. Por eso le damos = new ArticleListQuery(). Además, el código por defecto cuando falla la validación de #[MapQueryString] es 404; en una API suele ser más claro usar 422, como en el ejemplo.

💡 Consejo: Para uno o dos parámetros sueltos sigue siendo cómodo #[MapQueryParameter] int $page = 1 (módulo 02). Cuando tienes varios filtros relacionados, un DTO con #[MapQueryString] es más limpio y validable.


5. CRUD API completo de artículos

Vamos a construir la API de artículos del blog con todas las operaciones. Primero, un método de paginación en el repositorio.

El repositorio con filtros y paginación

<?php
// src/Repository/ArticleRepository.php (fragmento)
declare(strict_types=1);

namespace App\Repository;

use App\Dto\ArticleListQuery;
use App\Entity\Article;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Tools\Pagination\Paginator;
use Doctrine\Persistence\ManagerRegistry;

/** @extends ServiceEntityRepository<Article> */
class ArticleRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Article::class);
    }

    /** @return Paginator<Article> */
    public function paginatePublished(ArticleListQuery $query): Paginator
    {
        $qb = $this->createQueryBuilder('a')
            ->addSelect('au', 'c')
            ->join('a.author', 'au')
            ->leftJoin('a.category', 'c')
            ->andWhere('a.publishedAt IS NOT NULL')
            ->andWhere('a.publishedAt <= :now')
            ->setParameter('now', new \DateTimeImmutable());

        if (null !== $query->category) {
            $qb->andWhere('c.slug = :category')->setParameter('category', $query->category);
        }

        if (null !== $query->q) {
            $qb->andWhere('LOWER(a.title) LIKE :q')
               ->setParameter('q', '%'.mb_strtolower($query->q).'%');
        }

        // "-publishedAt" => publishedAt DESC ; "title" => title ASC
        $direction = str_starts_with($query->sort, '-') ? 'DESC' : 'ASC';
        $field = ltrim($query->sort, '-');
        $qb->orderBy('a.'.$field, $direction);

        $qb->setFirstResult(($query->page - 1) * $query->limit)
           ->setMaxResults($query->limit);

        return new Paginator($qb);
    }
}

📘 Concepto: El Paginator de Doctrine ejecuta la consulta limitada y una consulta COUNT para saber el total. Con count($paginator) obtienes el número total de resultados (sin paginar) y al recorrerlo obtienes solo los de la página actual.

⚠️ Importante: Nunca concatenes en DQL el valor que llega del cliente para el campo de ordenación. Aquí es seguro porque #[Assert\Choice] en el DTO solo permite cuatro valores conocidos.

El controlador

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

namespace App\Controller\Api;

use App\Dto\ArticleInput;
use App\Dto\ArticleListQuery;
use App\Dto\ArticlePatchInput;
use App\Entity\Article;
use App\Entity\User;
use App\Repository\ArticleRepository;
use App\Repository\CategoryRepository;
use App\Service\Slugger;
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\MapQueryString;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route('/api/articles', name: 'api_article_', format: 'json')]
final class ArticleApiController extends AbstractController
{
    public function __construct(
        private readonly EntityManagerInterface $em,
        private readonly CategoryRepository $categories,
        private readonly Slugger $slugger,
    ) {
    }

    // GET /api/articles?page=1&limit=10&category=symfony&q=doctrine
    #[Route('', name: 'list', methods: ['GET'])]
    public function list(
        ArticleRepository $repository,
        #[MapQueryString(validationFailedStatusCode: Response::HTTP_UNPROCESSABLE_ENTITY)]
        ArticleListQuery $query = new ArticleListQuery(),
    ): JsonResponse {
        $paginator = $repository->paginatePublished($query);
        $total = count($paginator);

        return $this->json([
            'items' => iterator_to_array($paginator),
            'meta' => [
                'page' => $query->page,
                'limit' => $query->limit,
                'total' => $total,
                'pages' => (int) ceil($total / $query->limit),
            ],
        ], context: ['groups' => ['article:list']]);
    }

    // GET /api/articles/42
    #[Route('/{id<\d+>}', name: 'show', methods: ['GET'])]
    public function show(Article $article): JsonResponse
    {
        return $this->json($article, context: ['groups' => ['article:read']]);
    }

    // POST /api/articles
    #[Route('', name: 'create', methods: ['POST'])]
    #[IsGranted('ROLE_AUTHOR')]
    public function create(
        #[MapRequestPayload] ArticleInput $input,
        #[CurrentUser] User $user,
    ): JsonResponse {
        $article = new Article();
        $article->setAuthor($user);
        $this->applyInput($article, $input);

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

        $location = $this->generateUrl(
            'api_article_show',
            ['id' => $article->getId()],
            UrlGeneratorInterface::ABSOLUTE_URL,
        );

        return $this->json(
            $article,
            Response::HTTP_CREATED,
            ['Location' => $location],
            ['groups' => ['article:read']],
        );
    }

    // PUT /api/articles/42  (reemplazo completo)
    #[Route('/{id<\d+>}', name: 'replace', methods: ['PUT'])]
    #[IsGranted('ARTICLE_EDIT', subject: 'article')]
    public function replace(Article $article, #[MapRequestPayload] ArticleInput $input): JsonResponse
    {
        $this->applyInput($article, $input);
        $this->em->flush();

        return $this->json($article, context: ['groups' => ['article:read']]);
    }

    // PATCH /api/articles/42  (modificación parcial)
    #[Route('/{id<\d+>}', name: 'patch', methods: ['PATCH'])]
    #[IsGranted('ARTICLE_EDIT', subject: 'article')]
    public function patch(Article $article, #[MapRequestPayload] ArticlePatchInput $input): JsonResponse
    {
        if (null !== $input->title) {
            $article->setTitle($input->title);
            $article->setSlug($this->slugger->slugify($input->title));
        }
        if (null !== $input->content) {
            $article->setContent($input->content);
        }
        if (true === $input->publish && null === $article->getPublishedAt()) {
            $article->setPublishedAt(new \DateTimeImmutable());
        }

        $this->em->flush();

        return $this->json($article, context: ['groups' => ['article:read']]);
    }

    // DELETE /api/articles/42
    #[Route('/{id<\d+>}', name: 'delete', methods: ['DELETE'])]
    #[IsGranted('ARTICLE_DELETE', subject: 'article')]
    public function delete(Article $article): Response
    {
        $this->em->remove($article);
        $this->em->flush();

        return new Response(null, Response::HTTP_NO_CONTENT);
    }

    private function applyInput(Article $article, ArticleInput $input): void
    {
        $article->setTitle($input->title);
        $article->setSlug($this->slugger->slugify($input->title));
        $article->setContent($input->content);
        $article->setCategory(
            null !== $input->categoryId ? $this->categories->find($input->categoryId) : null
        );

        if ($input->publish && null === $article->getPublishedAt()) {
            $article->setPublishedAt(new \DateTimeImmutable());
        }
    }
}

Puntos clave del controlador:

  • format: 'json' en la ruta de clase fija el formato de la petición. Así, cuando algo falle (un 404 porque el artículo no existe, por ejemplo), Symfony generará el error en JSON y no en HTML.
  • Article $article en la firma: el entity value resolver de Doctrine busca el artículo por {id} y lanza un 404 si no existe.
  • #[IsGranted('ARTICLE_EDIT', subject: 'article')] reutiliza el voter ArticleVoter del módulo 10: solo el autor (o un admin) puede editar.
  • 201 Created + Location: el cliente sabe dónde está el recurso que acaba de crear.
  • 204 No Content: un Response vacío, sin JSON.

El DTO para PATCH

En PATCH todos los campos son opcionales: solo se cambia lo que llega.

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

namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;

final class ArticlePatchInput
{
    public function __construct(
        #[Assert\Length(min: 5, max: 255)]
        public readonly ?string $title = null,

        #[Assert\Length(min: 20)]
        public readonly ?string $content = null,

        public readonly ?bool $publish = null,
    ) {
    }
}

💡 Consejo: Las restricciones como Length ignoran el valor null, así que un campo ausente no da error, pero si llega se valida. Es exactamente lo que quieres en un PATCH.

PUT frente a PATCH

  PUT PATCH
Semántica “Este es el recurso completo nuevo” “Aplica estos cambios”
Campos ausentes Se consideran vacíos / valores por defecto Se dejan como estaban
DTO El mismo que en POST (ArticleInput) Uno con todo opcional (ArticlePatchInput)
Idempotente Sí No necesariamente

✏️ Ejercicio: Añade a la API la ruta GET /api/articles/{id}/comments que devuelva los comentarios aprobados de un artículo con el grupo comment:read (id, contenido, nombre del autor y fecha).


6. Errores en JSON: Problem Details

El formato estándar

📘 Concepto: Problem Details (RFC 9457, sucesora de la RFC 7807) es un estándar para describir errores de APIs HTTP en JSON. Usa el tipo de contenido application/problem+json y unas claves fijas: type, title, status, detail e instance, a las que puedes añadir campos propios.

Buena noticia: con el Serializer instalado, Symfony ya devuelve los errores en este formato cuando el formato de la petición es JSON (gracias al ProblemNormalizer). Un 404 en /api/articles/9999 da:

{
  "type": "https://tools.ietf.org/html/rfc2616#section-10",
  "title": "An error occurred",
  "status": 404,
  "detail": "Not Found"
}

En el entorno dev verás además la clase de la excepción y la traza; en prod no, para no filtrar información interna.

Un listener de kernel.exception propio

Si quieres controlar exactamente el formato (por ejemplo, mensajes en español y un type propio), escucha el evento kernel.exception solo para las rutas de la API:

<?php
// src/EventListener/ApiExceptionListener.php
declare(strict_types=1);

namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpKernel\KernelEvents;

#[AsEventListener(event: KernelEvents::EXCEPTION, priority: 10)]
final class ApiExceptionListener
{
    public function __construct(
        private readonly bool $debug = false,
    ) {
    }

    public function __invoke(ExceptionEvent $event): void
    {
        $request = $event->getRequest();

        // Solo actuamos en la API; el resto de la web sigue con sus páginas de error HTML
        if (!str_starts_with($request->getPathInfo(), '/api/')) {
            return;
        }

        $exception = $event->getThrowable();

        $status = $exception instanceof HttpExceptionInterface
            ? $exception->getStatusCode()
            : Response::HTTP_INTERNAL_SERVER_ERROR;

        $problem = [
            'type' => 'https://blog.example.com/errors/'.$status,
            'title' => Response::$statusTexts[$status] ?? 'Error',
            'status' => $status,
            // para errores 5xx no mostramos el mensaje interno (podría contener datos sensibles)
            'detail' => $status < 500 ? $exception->getMessage() : 'Se ha producido un error inesperado.',
            'instance' => $request->getPathInfo(),
        ];

        if ($this->debug) {
            $problem['exception'] = $exception::class;
        }

        $headers = ['Content-Type' => 'application/problem+json'];
        if ($exception instanceof HttpExceptionInterface) {
            $headers += $exception->getHeaders();  // conserva p. ej. WWW-Authenticate o Allow
        }

        $event->setResponse(new JsonResponse($problem, $status, $headers));
    }
}

Inyecta el modo debug en services.yaml:

# config/services.yaml
services:
    App\EventListener\ApiExceptionListener:
        arguments:
            $debug: '%kernel.debug%'

⚠️ Importante: Las respuestas de validación de #[MapRequestPayload] llegan a este listener como una HttpException 422 cuya excepción previa es una ValidationFailedException. Si quieres mantener la lista de violations, comprueba $exception->getPrevious() y normaliza su getViolations() con el NormalizerInterface. Una alternativa más sencilla es no tocar los 422 y dejar que Symfony los formatee.

Lanzar errores con significado

Desde los controladores, usa las excepciones HTTP de Symfony:

use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

if (null === $category) {
    throw new NotFoundHttpException(sprintf('La categoría %d no existe.', $input->categoryId));
}

if ($repository->findOneBy(['slug' => $slug])) {
    throw new ConflictHttpException('Ya existe un artículo con ese título.');
}
Excepción Código
BadRequestHttpException 400
UnauthorizedHttpException 401
AccessDeniedHttpException 403
NotFoundHttpException 404
MethodNotAllowedHttpException 405
ConflictHttpException 409
UnprocessableEntityHttpException 422
TooManyRequestsHttpException 429

7. Autenticación por token

La web del blog usa form login con sesión (módulo 10). Una API, en cambio, suele ser sin estado (stateless): cada petición lleva sus credenciales en la cabecera:

Authorization: Bearer 3f9a8c1e0b7d4a2f...

📘 Concepto: Un access token es como la pulsera de un festival: te la dan una vez (al hacer login o desde tu perfil) y la enseñas en cada puerta. El servidor no necesita recordar sesiones; solo comprobar que la pulsera es válida.

La entidad ApiToken

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

namespace App\Entity;

use App\Repository\ApiTokenRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: ApiTokenRepository::class)]
class ApiToken
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    public function __construct(
        #[ORM\ManyToOne]
        #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
        private User $owner,

        // guardamos el HASH del token, nunca el token en claro
        #[ORM\Column(length: 64, unique: true)]
        private string $tokenHash,

        #[ORM\Column]
        private \DateTimeImmutable $expiresAt,
    ) {
    }

    /** Crea un token nuevo y devuelve [entidad, token en claro] */
    public static function generate(User $owner, string $ttl = '+30 days'): array
    {
        $plain = bin2hex(random_bytes(32));

        return [new self($owner, hash('sha256', $plain), new \DateTimeImmutable($ttl)), $plain];
    }

    public function getOwner(): User { return $this->owner; }

    public function isValid(): bool
    {
        return $this->expiresAt > new \DateTimeImmutable();
    }
}

💡 Consejo: Guardar solo el hash del token funciona igual que con las contraseñas: si alguien roba una copia de la base de datos, no puede usar los tokens. El token en claro se muestra una sola vez al usuario, al crearlo.

El AccessTokenHandler

El handler recibe el token extraído de la cabecera y devuelve un UserBadge con el identificador del usuario (su email):

<?php
// src/Security/ApiTokenHandler.php
declare(strict_types=1);

namespace App\Security;

use App\Repository\ApiTokenRepository;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;

final class ApiTokenHandler implements AccessTokenHandlerInterface
{
    public function __construct(
        private readonly ApiTokenRepository $repository,
    ) {
    }

    public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
    {
        $token = $this->repository->findOneBy(['tokenHash' => hash('sha256', $accessToken)]);

        if (null === $token || !$token->isValid()) {
            throw new BadCredentialsException('Token inválido o caducado.');
        }

        // el user provider cargará el User a partir de este identificador
        return new UserBadge($token->getOwner()->getUserIdentifier());
    }
}

Configuración en security.yaml

Creamos un firewall específico para /api, antes del main:

# config/packages/security.yaml
security:
    providers:
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email

    firewalls:
        dev:
            pattern: ^/(_profiler|_wdt|assets|build)/
            security: false

        api:
            pattern: ^/api/
            stateless: true                       # sin sesión ni cookies
            provider: app_user_provider
            access_token:
                token_handler: App\Security\ApiTokenHandler
                # por defecto lee la cabecera "Authorization: Bearer <token>"

        main:
            lazy: true
            provider: app_user_provider
            form_login:
                login_path: app_login
                check_path: app_login
            logout:
                path: app_logout

    access_control:
        - { path: ^/api/, roles: PUBLIC_ACCESS, methods: [GET] }
        - { path: ^/api/, roles: ROLE_AUTHOR }

Con esto:

  • Los GET de la API son públicos (el listado y el detalle de artículos publicados).
  • Cualquier otra operación exige un usuario con ROLE_AUTHOR; sin token válido la respuesta es 401.
  • El voter sigue decidiendo si ese autor puede editar ese artículo (403 si no).

⚠️ Importante: Los firewalls se evalúan en orden y gana el primero cuyo pattern coincide. Si pones main (sin pattern) antes que api, el firewall de la API nunca se usará.

Emitir tokens

Los tokens se crean con ApiToken::generate($user), se persisten y se entrega el valor en claro una sola vez: desde un comando de consola (lo harás en el módulo 14), desde la página de perfil del usuario o desde un endpoint de login que reciba email y contraseña.

💡 Consejo: Si necesitas tokens firmados que no requieran consultar la base de datos (JWT), Symfony incluye un OidcTokenHandler y existe el popular LexikJWTAuthenticationBundle. Para la mayoría de APIs propias, tokens opacos guardados en BD como estos son más sencillos y fáciles de revocar.


8. CORS con NelmioCorsBundle

📘 Concepto: CORS (Cross-Origin Resource Sharing) es el mecanismo por el que un navegador permite que el JavaScript de https://app.ejemplo.com llame a una API en https://api.ejemplo.com. Antes de ciertas peticiones, el navegador envía una petición previa OPTIONS (preflight) y la API debe responder con cabeceras Access-Control-Allow-* adecuadas.

CORS solo afecta a navegadores: curl, Postman o un servidor PHP ignoran estas cabeceras. Por eso “en Postman funciona pero en mi app React no” suele ser un problema de CORS.

composer require nelmio/cors-bundle

La receta de Flex crea esta configuración:

# config/packages/nelmio_cors.yaml
nelmio_cors:
    defaults:
        origin_regex: true
        allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
        allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
        allow_headers: ['Content-Type', 'Authorization']
        expose_headers: ['Link', 'Location']
        max_age: 3600
    paths:
        '^/api/': null

Y en .env define qué orígenes se permiten (una expresión regular):

###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###

En producción, en .env.prod o en la variable de entorno real:

CORS_ALLOW_ORIGIN='^https://(www\.)?mi-blog\.com$'

⚠️ Importante: No uses allow_origin: ['*'] en una API con autenticación. Limita los orígenes a tus dominios reales.


9. Documentar la API

Una API sin documentación es una API que nadie sabe usar. NelmioApiDocBundle genera una especificación OpenAPI a partir de tus rutas, tipos PHP, DTOs y atributos, y la muestra con Swagger UI:

composer require nelmio/api-doc-bundle

La receta crea config/routes/nelmio_api_doc.yaml con la ruta /api/doc.json (la especificación en JSON) y deja comentada la ruta /api/doc de Swagger UI, que necesita Twig y Asset: descoméntala para tener la documentación visual. Después puedes enriquecerla con los atributos de OpenApi\Attributes:

use Nelmio\ApiDocBundle\Attribute\Model;
use OpenApi\Attributes as OA;

#[OA\Response(
    response: 200,
    description: 'Detalle de un artículo',
    content: new OA\JsonContent(ref: new Model(type: Article::class, groups: ['article:read']))
)]
#[OA\Tag(name: 'Artículos')]
public function show(Article $article): JsonResponse

💡 Consejo: Como #[MapRequestPayload] y #[MapQueryString] usan DTOs tipados, NelmioApiDocBundle puede deducir buena parte de la documentación de entrada sin que escribas nada más. Otra razón para usar DTOs.


10. Introducción a API Platform

Has construido a mano una API completa: rutas, DTOs, serialización, paginación, filtros, errores y documentación. API Platform es un framework construido sobre Symfony que hace todo eso de forma declarativa.

📘 Concepto: Con API Platform describes tus recursos con el atributo #[ApiResource] y el framework genera automáticamente los endpoints CRUD, la paginación, los filtros, la validación, los formatos (JSON-LD/Hydra, JSON:API, JSON, HAL…) y la documentación OpenAPI con Swagger UI.

Instalación

composer require api

El alias api de Symfony Flex instala API Platform y su receta (la versión actual es la 5.0, compatible con Symfony 7.4 y 8.x). Tras la instalación, abre https://127.0.0.1:8000/api y verás Swagger UI con la documentación interactiva.

Exponer un recurso

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

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity]
#[ApiResource(
    operations: [
        new GetCollection(),
        new Get(),
        new Post(security: "is_granted('ROLE_ADMIN')"),
        new Patch(security: "is_granted('ROLE_ADMIN')"),
        new Delete(security: "is_granted('ROLE_ADMIN')"),
    ],
    normalizationContext: ['groups' => ['category:read']],
    denormalizationContext: ['groups' => ['category:write']],
    paginationItemsPerPage: 20,
)]
class Category
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    #[Groups(['category:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 100)]
    #[Assert\NotBlank]
    #[Groups(['category:read', 'category:write'])]
    private string $name = '';

    #[ORM\Column(length: 100, unique: true)]
    #[Groups(['category:read'])]
    private string $slug = '';

    // ... getters y setters
}

Solo con esto tienes:

Operación Ruta Código
GetCollection GET /api/categories 200 (paginado, 20 por página)
Get GET /api/categories/{id} 200 / 404
Post POST /api/categories 201 / 422
Patch PATCH /api/categories/{id} 200 / 422
Delete DELETE /api/categories/{id} 204

Fíjate en que reutiliza lo que ya conoces: los grupos del Serializer, las restricciones del Validator y las expresiones de seguridad.

Filtros y paginación automáticos

La paginación viene activada por defecto (30 elementos por página, con ?page=2). Para los filtros, la forma recomendada en las versiones actuales es declararlos como parámetros de la operación:

use ApiPlatform\Doctrine\Orm\Filter\ExactFilter;
use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter;
use ApiPlatform\Doctrine\Orm\Filter\SortFilter;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\QueryParameter;

#[ApiResource(operations: [
    new GetCollection(
        parameters: [
            'title' => new QueryParameter(filter: new PartialSearchFilter()),   // ?title=symfony
            'slug' => new QueryParameter(filter: new ExactFilter()),            // ?slug=hola-mundo
            'order' => new QueryParameter(filter: new SortFilter(), property: 'publishedAt'), // ?order=desc
        ],
    ),
    // ...
])]
class Article

⚠️ Importante: En documentación y tutoriales antiguos verás #[ApiFilter(SearchFilter::class, properties: [...])]. Sigue existiendo, pero la propia documentación de API Platform desaconseja ese estilo y recomienda los QueryParameter con filtros específicos (ExactFilter, PartialSearchFilter, SortFilter…).

¿API Platform o controladores propios?

Criterio Controladores propios API Platform
Curva de aprendizaje Baja (es Symfony “normal”) Media: conceptos propios (providers, processors, JSON-LD)
Velocidad para CRUD estándar Lenta: mucho código repetitivo Muy rápida
Documentación OpenAPI Con NelmioApiDocBundle Incluida
Filtros, paginación, formatos Los programas tú Incluidos
Lógica muy específica por endpoint Total libertad Posible con state processors/providers, algo más indirecto
Pocos endpoints (una API “auxiliar”) Ideal Puede ser excesivo
API pública grande, muchos recursos Mucho mantenimiento Ideal

💡 Consejo: Una regla práctica: si tu API es básicamente “CRUD de entidades con filtros”, API Platform te ahorrará semanas. Si son pocos endpoints con lógica muy particular (un webhook, un endpoint de estadísticas…), los controladores con #[MapRequestPayload] que has visto en este módulo son más directos. Y puedes combinar ambos en la misma aplicación.


11. Probar la API: curl y ficheros .http

Con curl

# Listado con filtros
curl -s "https://127.0.0.1:8000/api/articles?page=1&limit=5&category=symfony"

# Detalle
curl -s https://127.0.0.1:8000/api/articles/42

# Crear (con token) y ver cabeceras de respuesta (-i)
curl -i -X POST https://127.0.0.1:8000/api/articles \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer 3f9a8c1e0b7d4a2f..." \
  -d '{"title": "Mi primer artículo por API", "content": "Contenido de prueba con más de veinte caracteres", "categoryId": 1}'

# Modificación parcial
curl -X PATCH https://127.0.0.1:8000/api/articles/43 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer 3f9a8c1e0b7d4a2f..." \
  -d '{"publish": true}'

# Borrar
curl -i -X DELETE https://127.0.0.1:8000/api/articles/43 \
  -H "Authorization: Bearer 3f9a8c1e0b7d4a2f..."

Salida esperada del POST con -i:

HTTP/2 201
content-type: application/json
location: https://127.0.0.1:8000/api/articles/43

{"id":43,"title":"Mi primer artículo por API","slug":"mi-primer-articulo-por-api",...}

En PowerShell usa curl.exe (en Windows PowerShell 5.1, curl es un alias de Invoke-WebRequest) o Invoke-RestMethod.

💡 Consejo: Añade | jq al final de un curl (si tienes jq instalado) para ver el JSON formateado y con colores.

Ficheros .http

Los IDE modernos (PhpStorm de serie, VS Code con la extensión REST Client) ejecutan peticiones escritas en ficheros .http. Puedes guardarlos en el repositorio como documentación viva:

### Variables
@host = https://127.0.0.1:8000
@token = 3f9a8c1e0b7d4a2f...

### Listado de artículos
GET {{host}}/api/articles?page=1&limit=5
Accept: application/json

### Crear artículo
POST {{host}}/api/articles
Content-Type: application/json
Authorization: Bearer {{token}}

{
  "title": "Artículo desde un fichero .http",
  "content": "Este contenido tiene más de veinte caracteres.",
  "categoryId": 1,
  "tags": ["symfony", "api"]
}

### Validación fallida (esperamos 422)
POST {{host}}/api/articles
Content-Type: application/json
Authorization: Bearer {{token}}

{ "title": "", "content": "corto" }

### Sin token (esperamos 401)
DELETE {{host}}/api/articles/1

⚠️ Importante: No subas al repositorio ficheros .http con tokens reales. Usa variables de entorno del IDE (por ejemplo http-client.private.env.json en PhpStorm) y añádelas a .gitignore.

En el módulo 13 automatizarás estas mismas comprobaciones con tests funcionales.


12. Ejercicios

Ejercicio 1: API de solo lectura de categorías

Crea CategoryApiController con GET /api/categories (todas, ordenadas por nombre) y GET /api/categories/{slug} (una categoría con el número de artículos publicados). Usa un grupo category:read y comprueba que un slug inexistente devuelve un 404 en JSON.

Ejercicio 2: Grupos y nombres personalizados

En Article, añade un método getExcerpt() que devuelva los 150 primeros caracteres del contenido y expónlo solo en article:list con el nombre excerpt. Haz que en el listado no aparezca content y que la fecha publishedAt salga con el formato d/m/Y.

Ejercicio 3: Comentarios por API

Implementa POST /api/articles/{id}/comments con un DTO CommentInput (contenido entre 3 y 1000 caracteres). Debe requerir usuario autenticado por token, crear el comentario en estado “pendiente” y responder 201 con Location. Si el artículo no está publicado, responde 409 Conflict.

Ejercicio 4: Paginación con cabeceras

Modifica el listado de artículos para que, además del bloque meta, devuelva la cabecera X-Total-Count y una cabecera Link con las URLs next y prev cuando proceda (formato <url>; rel="next"). Exponla en la configuración de CORS.

Ejercicio 5: Problem Details con violaciones

Amplía ApiExceptionListener para que, cuando la excepción previa sea una ValidationFailedException, añada al JSON una clave errors con el formato {"title": ["El título es obligatorio."]} (propiedad → lista de mensajes).

Ejercicio 6: Gestión de tokens

Añade DELETE /api/tokens/current, que revoque (borre) el token con el que se ha hecho la petición. Pista: en el handler, guarda el token en un atributo de la petición o búscalo de nuevo a partir de la cabecera.

Ejercicio 7: Categorías con API Platform

En una rama aparte, instala API Platform y expón Category con #[ApiResource], con lectura pública y escritura solo para ROLE_ADMIN. Añade un filtro PartialSearchFilter sobre name. Compara el número de líneas con tu controlador del ejercicio 1.

Ejercicio 8: Colección .http completa

Crea requests/articles.http con una petición por cada operación del CRUD y por cada caso de error (400 con JSON roto, 401, 403 editando un artículo ajeno, 404, 422). Ejecútalas y anota los códigos obtenidos.


Resumen

Concepto Sintaxis / herramienta
JSON a mano new JsonResponse(array, status, headers)
JSON con Serializer $this->json($obj, 200, [], ['groups' => [...]])
Serializar $serializer->serialize($obj, 'json', $context)
Deserializar $serializer->deserialize($json, Clase::class, 'json')
Grupos #[Groups(['article:read'])] + contexto groups
Renombrar / excluir #[SerializedName('x')], #[Ignore]
Profundidad #[MaxDepth(1)] + enable_max_depth: true
Referencias circulares Grupos o CIRCULAR_REFERENCE_HANDLER
Cuerpo JSON → DTO validado #[MapRequestPayload] Dto $dto (422 si falla)
Query string → DTO #[MapQueryString] Dto $q = new Dto()
Paginación Doctrine new Paginator($qb) + count($paginator)
Crear 201 Created + cabecera Location
Borrar new Response(null, 204)
Errores JSON Problem Details (application/problem+json), kernel.exception
Rutas en JSON #[Route(..., format: 'json')]
Autenticación API Firewall stateless + access_token + AccessTokenHandlerInterface
Cabecera de token Authorization: Bearer <token>
CORS nelmio/cors-bundle, CORS_ALLOW_ORIGIN
Documentación nelmio/api-doc-bundle (OpenAPI + Swagger UI)
API Platform composer require api, #[ApiResource], Swagger UI en /api
Pruebas manuales curl -i -X POST ... -H ... -d ..., ficheros .http