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.

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.
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.
# 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-bundleLa configuracion minima en config/packages/api_platform.yaml define el titulo, la version y los formatos soportados.
# 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: falseCreacion 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
// src/Entity/Book.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Delete;
use App\Repository\BookRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: BookRepository::class)]
#[ApiResource(
operations: [
new GetCollection(),
new Post(),
new Get(),
new Put(),
new Patch(),
new Delete(),
],
paginationItemsPerPage: 20,
)]
class Book
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
#[Assert\Length(min: 1, max: 255)]
private ?string $title = null;
#[ORM\Column(length: 13, unique: true)]
#[Assert\Isbn]
private ?string $isbn = null;
#[ORM\Column(type: 'text')]
#[Assert\NotBlank]
private ?string $description = null;
#[ORM\Column]
#[Assert\NotNull]
#[Assert\Positive]
private ?float $price = null;
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 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;
}
}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
// src/Entity/User.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
#[ApiResource(
operations: [
new GetCollection(
normalizationContext: ['groups' => ['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; }
}¿Listo para aprobar tus entrevistas de Symfony?
Practica con nuestros simuladores interactivos, flashcards y tests técnicos.
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
// src/State/UserPasswordHasherProcessor.php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\User;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* @implements ProcessorInterface<User, User>
*/
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
// src/State/BookProcessor.php
namespace App\State;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Book;
use App\Service\BookSlugGenerator;
use App\Service\NotificationService;
use Doctrine\ORM\EntityManagerInterface;
/**
* @implements ProcessorInterface<Book, Book|void>
*/
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
// src/State/PopularBooksProvider.php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Book;
use App\Repository\BookRepository;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
/**
* @implements ProviderInterface<Book>
*/
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
// Excerpt from src/Entity/Book.php
// Adding a custom operation with a specific provider
use App\State\BookProvider;
use App\State\BookProcessor;
use App\State\PopularBooksProvider;
#[ApiResource(
operations: [
new GetCollection(),
new GetCollection(
uriTemplate: '/books/popular',
provider: PopularBooksProvider::class,
),
new Get(
provider: BookProvider::class,
),
new Post(
processor: BookProcessor::class,
),
new Put(
processor: BookProcessor::class,
),
new Patch(
processor: BookProcessor::class,
),
new Delete(
processor: BookProcessor::class,
),
]
)]
class Book
{
// ...
}Validacion Avanzada con Grupos Dinamicos
API Platform integra el componente Validator de Symfony. Los grupos de validacion dinamicos permiten aplicar reglas diferentes segun la operacion, el rol del usuario o el estado del recurso.
<?php
// src/Entity/Article.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Validator\ArticleGroupsGenerator;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
#[ApiResource(
operations: [
new Post(
validationContext: ['groups' => 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
// src/Validator/ArticleGroupsGenerator.php
namespace App\Validator;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Validator\ValidationGroupsGeneratorInterface;
use App\Entity\Article;
use Symfony\Bundle\SecurityBundle\Security;
class ArticleGroupsGenerator implements ValidationGroupsGeneratorInterface
{
public function __construct(
private readonly Security $security,
) {}
public function __invoke(object $object, Operation $operation): array
{
$groups = ['Default'];
if (!$object instanceof Article) {
return $groups;
}
// Base group depending on operation
if ($object->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;
}
}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
// src/Entity/Product.php
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter;
use ApiPlatform\Doctrine\Orm\Filter\NumericFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ApiResource(
operations: [new GetCollection()]
)]
#[ApiFilter(SearchFilter::class, properties: [
'name' => '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=1Relaciones 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
// src/Entity/Author.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
#[ApiResource(
normalizationContext: ['groups' => ['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
// src/Entity/Book.php (updated with relationship)
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Delete;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
#[ApiResource(
normalizationContext: ['groups' => ['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
// src/Entity/Order.php
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 ApiPlatform\Metadata\Put;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
#[ApiResource(
normalizationContext: ['groups' => ['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
// tests/Api/BookTest.php
namespace App\Tests\Api;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Entity\Book;
use App\Entity\User;
use App\Factory\BookFactory;
use App\Factory\UserFactory;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
class BookTest extends ApiTestCase
{
use ResetDatabase;
use Factories;
private function getAuthenticatedClient(string $role = 'ROLE_USER'): Client
{
$user = UserFactory::createOne(['roles' => [$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
securityysecurityPostDenormalize - Escribir pruebas funcionales con
ApiTestCasey Foundry para cada operacion critica - Configurar cache HTTP con
cache_headerspara operaciones de solo lectura - Revisar la documentacion OpenAPI generada automaticamente en
/apiantes de cada release
¡Empieza a practicar!
Pon a prueba tu conocimiento con nuestros simuladores de entrevista y tests técnicos.
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.
Etiquetas
Compartir
