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
JsonResponsey$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
curly 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
POSTgenera 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 OKcon 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:
SerializerInterfaceofreceserialize()ydeserialize(). Si solo necesitas el array (por ejemplo para añadir metadatos de paginación antes de codificar), inyectaNormalizerInterfacey llama anormalize().
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:
- Grupos (la buena): no pongas
article:readenComment::$article. Si la propiedad no se exporta, el ciclo desaparece. #[MaxDepth]conenable_max_depth: true: corta el anidamiento a un nivel.- 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
Paginatorde Doctrine ejecuta la consulta limitada y una consultaCOUNTpara saber el total. Concount($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 $articleen 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 voterArticleVoterdel 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: unResponsevací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
Lengthignoran el valornull, así que un campo ausente no da error, pero si llega se valida. Es exactamente lo que quieres en unPATCH.
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}/commentsque devuelva los comentarios aprobados de un artículo con el grupocomment: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+jsony unas claves fijas:type,title,status,detaileinstance, 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 unaHttpException422 cuya excepción previa es unaValidationFailedException. Si quieres mantener la lista deviolations, comprueba$exception->getPrevious()y normaliza sugetViolations()con elNormalizerInterface. 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
GETde 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
patterncoincide. Si ponesmain(sinpattern) antes queapi, 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
OidcTokenHandlery 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.comllame a una API enhttps://api.ejemplo.com. Antes de ciertas peticiones, el navegador envía una petición previaOPTIONS(preflight) y la API debe responder con cabecerasAccess-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 losQueryParametercon 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
| jqal final de uncurl(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
.httpcon tokens reales. Usa variables de entorno del IDE (por ejemplohttp-client.private.env.jsonen 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 |