# Symfony 7: API Platform y Mejores Practicas > Guia completa de API Platform 4 con Symfony 7. Recursos, grupos de serializacion, state processors, filtros, seguridad y pruebas automatizadas para APIs REST profesionales. - Published: 2026-01-12 - Updated: 2026-04-07 - Author: SharpSkill - Tags: symfony, api platform, php, rest api, api development - Reading time: 14 min --- API Platform 4, integrado con Symfony 7, representa la solucion mas completa del ecosistema PHP para construir APIs REST y GraphQL de nivel profesional. La combinacion elimina el codigo repetitivo habitual: con unos pocos atributos PHP, se genera automaticamente documentacion OpenAPI, soporte JSON-LD, paginacion, filtros y operaciones CRUD completas. Esta guia recorre las funcionalidades clave y las practicas recomendadas para proyectos en produccion. > **Novedades en API Platform 4.2** > > API Platform 4.2 introduce soporte nativo para UUID v7, mejoras en el sistema de State Processors encadenados, nuevos filtros de rango para fechas y numeros, y compatibilidad completa con PHP 8.4. La generacion automatica de esquemas JSON-LD ahora incluye tipos de union y tipos de interseccion de PHP 8. ## Instalacion y Configuracion Inicial La instalacion de API Platform en un proyecto Symfony 7 se realiza mediante Composer. El paquete instala todas las dependencias necesarias, incluidas las integraciones con Doctrine ORM y el sistema de serializacion de Symfony. ```bash # Install API Platform composer require api-platform/core # Install Doctrine ORM composer require symfony/orm-pack # Install the validator composer require symfony/validator # Optional: install the maker bundle for code generation composer require --dev symfony/maker-bundle ``` La configuracion minima en `config/packages/api_platform.yaml` define el titulo, la version y los formatos soportados. ```yaml # config/packages/api_platform.yaml api_platform: title: 'My API' version: '1.0.0' formats: jsonld: mime_types: ['application/ld+json'] json: mime_types: ['application/json'] jsonapi: mime_types: ['application/vnd.api+json'] docs_formats: jsonld: mime_types: ['application/ld+json'] jsonopenapi: mime_types: ['application/vnd.openapi+json'] html: mime_types: ['text/html'] defaults: stateless: true cache_headers: vary: ['Content-Type', 'Authorization', 'Origin'] event_listeners_backward_compatibility_layer: false keep_legacy_inflector: false ``` ## Creacion de un Recurso API Simple Un recurso API se define mediante el atributo `#[ApiResource]` sobre una entidad Doctrine. API Platform genera automaticamente las operaciones CRUD, la documentacion OpenAPI y los endpoints REST correspondientes. ```php id; } public function getTitle(): ?string { return $this->title; } public function setTitle(string $title): static { $this->title = $title; return $this; } public function getIsbn(): ?string { return $this->isbn; } public function setIsbn(string $isbn): static { $this->isbn = $isbn; return $this; } public function getDescription(): ?string { return $this->description; } public function setDescription(string $description): static { $this->description = $description; return $this; } public function getPrice(): ?float { return $this->price; } public function setPrice(float $price): static { $this->price = $price; return $this; } } ``` > **UUID v7 Recomendado** > > En produccion, se recomienda reemplazar los IDs auto-incrementales con UUID v7. Los UUID v7 son ordenables cronologicamente, lo que mejora el rendimiento de los indices en bases de datos. API Platform los soporta nativamente: basta con usar `#[ORM\Column(type: 'uuid')]` y el trait `HasUuid` de Symfony UID. ## Grupos de Serializacion para Controlar Datos Expuestos Los grupos de serializacion permiten exponer diferentes campos segun la operacion o el rol del usuario. Esta tecnica evita la sobreexposicion de datos sensibles y permite respuestas optimizadas para cada caso de uso. ```php ['user:list']] ), new Get( normalizationContext: ['groups' => ['user:read']] ), new Post( normalizationContext: ['groups' => ['user:read']], denormalizationContext: ['groups' => ['user:create']] ), new Put( normalizationContext: ['groups' => ['user:read']], denormalizationContext: ['groups' => ['user:update']] ), ] )] class User implements UserInterface, PasswordAuthenticatedUserInterface { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['user:list', 'user:read'])] private ?int $id = null; #[ORM\Column(length: 180, unique: true)] #[Assert\Email] #[Assert\NotBlank] #[Groups(['user:list', 'user:read', 'user:create'])] private ?string $email = null; #[ORM\Column(length: 255)] #[Assert\NotBlank] #[Groups(['user:list', 'user:read', 'user:create', 'user:update'])] private ?string $firstName = null; #[ORM\Column(length: 255)] #[Assert\NotBlank] #[Groups(['user:list', 'user:read', 'user:create', 'user:update'])] private ?string $lastName = null; // Password is write-only: never serialized in responses #[ORM\Column] #[Groups(['user:create', 'user:update'])] private ?string $password = null; #[ORM\Column] #[Groups(['user:read'])] private array $roles = []; // UserInterface & PasswordAuthenticatedUserInterface implementation... public function getUserIdentifier(): string { return (string) $this->email; } public function getPassword(): string { return $this->password; } public function getRoles(): array { return array_unique([...$this->roles, 'ROLE_USER']); } public function eraseCredentials(): void {} // Getters and setters... public function getId(): ?int { return $this->id; } public function getEmail(): ?string { return $this->email; } public function setEmail(string $email): static { $this->email = $email; return $this; } public function getFirstName(): ?string { return $this->firstName; } public function setFirstName(string $firstName): static { $this->firstName = $firstName; return $this; } public function getLastName(): ?string { return $this->lastName; } public function setLastName(string $lastName): static { $this->lastName = $lastName; return $this; } public function setPassword(string $password): static { $this->password = $password; return $this; } public function setRoles(array $roles): static { $this->roles = $roles; return $this; } } ``` ## State Processors para Logica de Negocio Los State Processors reemplazan los DataPersisters de versiones anteriores de API Platform. Son servicios que se ejecutan al crear, actualizar o eliminar un recurso, permitiendo encapsular la logica de negocio antes o despues de la persistencia. ```php */ class UserPasswordHasherProcessor implements ProcessorInterface { public function __construct( private readonly ProcessorInterface $persistProcessor, private readonly UserPasswordHasherInterface $passwordHasher, ) {} public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): User { if (!$data instanceof User || !$data->getPassword()) { return $this->persistProcessor->process($data, $operation, $uriVariables, $context); } $hashedPassword = $this->passwordHasher->hashPassword($data, $data->getPassword()); $data->setPassword($hashedPassword); return $this->persistProcessor->process($data, $operation, $uriVariables, $context); } } ``` El registro del processor se realiza asociandolo a la operacion correspondiente mediante el atributo `processor`. ```php */ class BookProcessor implements ProcessorInterface { public function __construct( private readonly ProcessorInterface $persistProcessor, private readonly ProcessorInterface $removeProcessor, private readonly BookSlugGenerator $slugGenerator, private readonly NotificationService $notificationService, ) {} public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Book|null { if ($operation instanceof Delete) { return $this->removeProcessor->process($data, $operation, $uriVariables, $context); } // Generate slug automatically from title if ($data instanceof Book && !$data->getSlug()) { $data->setSlug($this->slugGenerator->generate($data->getTitle())); } $result = $this->persistProcessor->process($data, $operation, $uriVariables, $context); // Send notification after creation if (isset($context['previous_data']) === false) { $this->notificationService->notifyNewBook($result); } return $result; } } ``` ## State Providers para Fuentes de Datos Personalizadas Los State Providers permiten obtener datos de fuentes que no son Doctrine: APIs externas, sistemas de cache, calculos en tiempo real o combinaciones de multiples fuentes. Se implementan a traves de la interfaz `ProviderInterface`. ```php */ class PopularBooksProvider implements ProviderInterface { public function __construct( private readonly BookRepository $bookRepository, private readonly CacheInterface $cache, ) {} public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { return $this->cache->get('popular_books', function (ItemInterface $item): array { $item->expiresAfter(3600); // Cache for 1 hour return $this->bookRepository->findPopular( limit: 10, minRating: 4.0 ); }); } } ``` La asociacion del provider con la operacion se declara directamente en el atributo `#[ApiResource]`. ```php ArticleGroupsGenerator::class] ), new Put( validationContext: ['groups' => ArticleGroupsGenerator::class] ), ] )] class Article { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 255)] #[Assert\NotBlank(groups: ['article:create', 'article:update'])] #[Assert\Length(min: 10, max: 255, groups: ['article:create', 'article:update'])] private ?string $title = null; #[ORM\Column(type: 'text')] #[Assert\NotBlank(groups: ['article:create'])] #[Assert\Length(min: 100, groups: ['article:create', 'article:update'])] private ?string $content = null; #[ORM\Column(length: 50)] #[Assert\Choice( choices: ['draft', 'review', 'published'], groups: ['article:create', 'article:update'] )] private string $status = 'draft'; #[ORM\Column(nullable: true)] #[Assert\NotNull( message: 'Publication date is required for published articles.', groups: ['article:publish'] )] private ?\DateTimeImmutable $publishedAt = null; // Getters and setters... public function getId(): ?int { return $this->id; } public function getTitle(): ?string { return $this->title; } public function setTitle(string $title): static { $this->title = $title; return $this; } public function getContent(): ?string { return $this->content; } public function setContent(string $content): static { $this->content = $content; return $this; } public function getStatus(): string { return $this->status; } public function setStatus(string $status): static { $this->status = $status; return $this; } public function getPublishedAt(): ?\DateTimeImmutable { return $this->publishedAt; } public function setPublishedAt(?\DateTimeImmutable $publishedAt): static { $this->publishedAt = $publishedAt; return $this; } } ``` ```php getId() === null) { $groups[] = 'article:create'; } else { $groups[] = 'article:update'; } // Additional group when publishing if ($object->getStatus() === 'published') { $groups[] = 'article:publish'; } // Additional validation for editors if ($this->security->isGranted('ROLE_EDITOR')) { $groups[] = 'article:editor'; } return $groups; } } ``` > **Rendimiento de Validacion** > > Los grupos de validacion dinamicos se evaluan en cada peticion. Si el generador realiza consultas a la base de datos, es fundamental implementar caching apropiado. Un generador mal optimizado puede convertirse en un cuello de botella silencioso bajo carga alta. ## Filtros para Consultas Flexibles API Platform incluye filtros predefinidos para las operaciones de coleccion: busqueda por texto, por rango numerico o de fechas, por valores exactos, y ordenamiento. Se declaran directamente sobre la entidad con el atributo `#[ApiFilter]`. ```php 'partial', // LIKE %value% 'category' => 'exact', // Exact match 'brand.name' => 'ipartial', // Case-insensitive partial on relation ])] #[ApiFilter(BooleanFilter::class, properties: ['isAvailable', 'isFeatured'])] #[ApiFilter(RangeFilter::class, properties: ['price', 'stock'])] #[ApiFilter(DateFilter::class, properties: ['createdAt', 'updatedAt'])] #[ApiFilter(NumericFilter::class, properties: ['rating'])] #[ApiFilter(ExistsFilter::class, properties: ['deletedAt'])] #[ApiFilter(OrderFilter::class, properties: [ 'name', 'price', 'createdAt', 'rating', ], arguments: ['orderParameterName' => 'sort'])] class Product { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 255)] private ?string $name = null; #[ORM\Column(length: 100)] private ?string $category = null; #[ORM\Column] private ?float $price = null; #[ORM\Column] private ?int $stock = null; #[ORM\Column] private bool $isAvailable = true; #[ORM\Column] private bool $isFeatured = false; #[ORM\Column(nullable: true)] private ?float $rating = null; #[ORM\Column] private \DateTimeImmutable $createdAt; // Getters and setters... } ``` Los filtros se aplican via parametros de query en la URL, con combinaciones arbitrarias. ``` GET /api/products?name=laptop&isAvailable=true&price[gte]=500&price[lte]=2000&sort[rating]=desc&page=1 ``` ## Relaciones y Subrecursos API Platform maneja las relaciones entre entidades de forma natural. Una relacion puede exponerse como un IRI (referencia), como un objeto embebido, o como un subrecurso con su propia ruta. ```php ['author:read']], denormalizationContext: ['groups' => ['author:write']], operations: [ new GetCollection(), new Get(), new Post(), ] )] class Author { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['author:read', 'book:read'])] private ?int $id = null; #[ORM\Column(length: 255)] #[Assert\NotBlank] #[Groups(['author:read', 'author:write', 'book:read'])] private ?string $name = null; #[ORM\Column(type: 'text', nullable: true)] #[Groups(['author:read', 'author:write'])] private ?string $biography = null; #[ORM\OneToMany(mappedBy: 'author', targetEntity: Book::class, cascade: ['persist'])] #[Groups(['author:read'])] private Collection $books; public function __construct() { $this->books = new ArrayCollection(); } // Getters and setters... public function getId(): ?int { return $this->id; } public function getName(): ?string { return $this->name; } public function setName(string $name): static { $this->name = $name; return $this; } public function getBiography(): ?string { return $this->biography; } public function setBiography(?string $biography): static { $this->biography = $biography; return $this; } public function getBooks(): Collection { return $this->books; } public function addBook(Book $book): static { if (!$this->books->contains($book)) { $this->books->add($book); $book->setAuthor($this); } return $this; } public function removeBook(Book $book): static { if ($this->books->removeElement($book)) { if ($book->getAuthor() === $this) { $book->setAuthor(null); } } return $this; } } ``` ```php ['book:read']], denormalizationContext: ['groups' => ['book:write']], operations: [ new GetCollection(), new GetCollection( uriTemplate: '/authors/{authorId}/books', uriVariables: [ 'authorId' => new Link(fromClass: Author::class, toProperty: 'author'), ], ), new Get(), new Post(), new Put(), new Patch(), new Delete(), ] )] class Book { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['book:read', 'author:read'])] private ?int $id = null; #[ORM\Column(length: 255)] #[Assert\NotBlank] #[Groups(['book:read', 'book:write', 'author:read'])] private ?string $title = null; #[ORM\Column] #[Assert\NotNull] #[Assert\Positive] #[Groups(['book:read', 'book:write'])] private ?float $price = null; // Relation exposed as IRI by default #[ORM\ManyToOne(inversedBy: 'books')] #[ORM\JoinColumn(nullable: false)] #[Assert\NotNull] #[Groups(['book:read', 'book:write'])] private ?Author $author = null; // Getters and setters... public function getId(): ?int { return $this->id; } public function getTitle(): ?string { return $this->title; } public function setTitle(string $title): static { $this->title = $title; return $this; } public function getPrice(): ?float { return $this->price; } public function setPrice(float $price): static { $this->price = $price; return $this; } public function getAuthor(): ?Author { return $this->author; } public function setAuthor(?Author $author): static { $this->author = $author; return $this; } } ``` ## Seguridad y Control de Acceso API Platform se integra con el sistema de seguridad de Symfony. Las expresiones de seguridad en los atributos `security` y `securityPostDenormalize` controlan el acceso a nivel de operacion y de objeto. ```php ['order:read']], denormalizationContext: ['groups' => ['order:write']], operations: [ // Admins see all orders, users only see their own new GetCollection( security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_USER')", ), new Get( // Users can only view their own orders security: "is_granted('ROLE_ADMIN') or object.getOwner() == user", securityMessage: 'Access denied: you can only view your own orders.' ), new Post( security: "is_granted('ROLE_USER')", // Post-denormalization: verify owner is the current user securityPostDenormalize: "object.getOwner() == user", securityPostDenormalizeMessage: 'You can only create orders for yourself.' ), new Patch( // Only admins can update orders security: "is_granted('ROLE_ADMIN') or (object.getOwner() == user and object.getStatus() == 'pending')", securityMessage: 'Access denied: the order cannot be modified.' ), new Delete( security: "is_granted('ROLE_ADMIN')", securityMessage: 'Access denied: only administrators can delete orders.' ), ] )] class Order { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['order:read'])] private ?int $id = null; #[ORM\ManyToOne] #[ORM\JoinColumn(nullable: false)] #[Groups(['order:read', 'order:write'])] private ?User $owner = null; #[ORM\Column(length: 50)] #[Groups(['order:read'])] private string $status = 'pending'; #[ORM\Column] #[Assert\NotNull] #[Assert\Positive] #[Groups(['order:read', 'order:write'])] private ?float $total = null; #[ORM\Column] #[Groups(['order:read'])] private \DateTimeImmutable $createdAt; public function __construct() { $this->createdAt = new \DateTimeImmutable(); } // Getters and setters... public function getId(): ?int { return $this->id; } public function getOwner(): ?User { return $this->owner; } public function setOwner(?User $owner): static { $this->owner = $owner; return $this; } public function getStatus(): string { return $this->status; } public function setStatus(string $status): static { $this->status = $status; return $this; } public function getTotal(): ?float { return $this->total; } public function setTotal(float $total): static { $this->total = $total; return $this; } public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } } ``` ## Pruebas Automatizadas de API API Platform provee una clase base `ApiTestCase` que simplifica las pruebas funcionales. El cliente de pruebas maneja automaticamente la autenticacion, los headers y la serializacion JSON-LD. ```php [$role]]); return static::createClient()->withOptions([ 'headers' => [ 'Accept' => 'application/ld+json', 'Content-Type' => 'application/ld+json', ], ])->loginUser($user->object()); } public function testGetCollectionOfBooks(): void { BookFactory::createMany(30); $client = $this->getAuthenticatedClient(); $response = $client->request('GET', '/api/books'); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); $this->assertJsonContains([ '@context' => '/api/contexts/Book', '@type' => 'hydra:Collection', 'hydra:totalItems' => 30, ]); // Default pagination: 20 items per page $this->assertCount(20, $response->toArray()['hydra:member']); } public function testCreateBook(): void { $client = $this->getAuthenticatedClient('ROLE_ADMIN'); $response = $client->request('POST', '/api/books', ['json' => [ 'title' => 'Clean Architecture', 'isbn' => '9780134494166', 'description' => 'A craftsman guide to software structure and design.', 'price' => 49.99, ]]); $this->assertResponseStatusCodeSame(201); $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); $this->assertJsonContains([ '@type' => 'Book', 'title' => 'Clean Architecture', 'isbn' => '9780134494166', 'price' => 49.99, ]); $this->assertMatchesResourceItemJsonSchema(Book::class); } public function testCreateBookValidationError(): void { $client = $this->getAuthenticatedClient('ROLE_ADMIN'); $client->request('POST', '/api/books', ['json' => [ 'title' => '', // Title is required 'isbn' => 'invalid-isbn', 'price' => -10, // Price must be positive ]]); $this->assertResponseStatusCodeSame(422); $this->assertJsonContains([ '@type' => 'ConstraintViolationList', 'hydra:title' => 'An error occurred', ]); } public function testUpdateBook(): void { $client = $this->getAuthenticatedClient('ROLE_ADMIN'); $book = BookFactory::createOne(['price' => 29.99]); $client->request('PATCH', '/api/books/' . $book->getId(), [ 'json' => ['price' => 39.99], 'headers' => ['Content-Type' => 'application/merge-patch+json'], ]); $this->assertResponseIsSuccessful(); $this->assertJsonContains(['price' => 39.99]); } public function testDeleteBookRequiresAdmin(): void { $book = BookFactory::createOne(); $client = $this->getAuthenticatedClient('ROLE_USER'); // Normal user $client->request('DELETE', '/api/books/' . $book->getId()); $this->assertResponseStatusCodeSame(403); } public function testGetPopularBooks(): void { BookFactory::createMany(5, ['rating' => 4.5]); BookFactory::createMany(10, ['rating' => 2.0]); $client = $this->getAuthenticatedClient(); $response = $client->request('GET', '/api/books/popular'); $this->assertResponseIsSuccessful(); $this->assertCount(5, $response->toArray()['hydra:member']); } } ``` ## Conclusion API Platform 4 con Symfony 7 define el estandar de facto para construir APIs REST profesionales en el ecosistema PHP. La combinacion de generacion automatica de documentacion, integracion profunda con Doctrine, sistema extensible de processors y providers, y herramientas de prueba de primer nivel permite desarrollar APIs robustas con una fraccion del codigo que requeririan otros enfoques. ### Lista de Verificacion para APIs con API Platform - Configurar API Platform con los formatos y la version correcta en `api_platform.yaml` - Usar UUID v7 en lugar de IDs auto-incrementales para mayor seguridad y escalabilidad - Definir grupos de serializacion separados para lectura y escritura - Implementar State Processors para toda la logica de negocio (hasheo de passwords, generacion de slugs, notificaciones) - Usar State Providers cuando los datos provienen de fuentes no-Doctrine - Aplicar filtros con `#[ApiFilter]` en lugar de endpoints personalizados para busqueda y ordenamiento - Declarar seguridad a nivel de operacion con expresiones `security` y `securityPostDenormalize` - Escribir pruebas funcionales con `ApiTestCase` y Foundry para cada operacion critica - Configurar cache HTTP con `cache_headers` para operaciones de solo lectura - Revisar la documentacion OpenAPI generada automaticamente en `/api` antes de cada release Dominar API Platform implica comprender su modelo de extension: processors, providers y grupos de serializacion son los tres pilares que determinan la calidad de una API construida con este framework. Cuando se aplican correctamente, el resultado es una API autodocumentada, segura y testeable que escala sin esfuerzo adicional. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/es/blog/symfony/symfony-7-api-platform-best-practices