Symfony 7 : API Platform et bonnes pratiques
Guide complet pour créer des APIs REST professionnelles avec Symfony 7 et API Platform 4. State Providers, Processors, validation et sérialisation expliqués.

API Platform 4 transforme radicalement la création d'APIs REST et GraphQL avec Symfony 7. Cette nouvelle version apporte une philosophie repensée : séparation claire des responsabilités, State Providers et Processors simplifiés, et intégration native du Symfony Object Mapper. Construire une API professionnelle devient plus intuitif que jamais.
La version 4.2 introduit le JSON Streamer pour des gains de performance jusqu'à +32% RPS, un système de filtres repensé, et des Mutators pour personnaliser les opérations sans toucher au core. Le support Symfony 7 et 8 est natif.
Installation et configuration initiale
API Platform s'installe en quelques commandes avec Symfony Flex. La configuration par défaut couvre la majorité des cas d'usage tout en restant entièrement personnalisable.
# terminal
# Création d'un nouveau projet Symfony avec API Platform
composer create-project symfony/skeleton my-api
cd my-api
# Installation d'API Platform avec Doctrine ORM
composer require api
# Vérification de l'installation
php bin/console debug:router | grep apiSymfony Flex configure automatiquement les routes, la documentation OpenAPI et l'interface Swagger UI accessible à /api.
# config/packages/api_platform.yaml
api_platform:
title: 'Mon API'
version: '1.0.0'
# Formats de réponse supportés
formats:
jsonld: ['application/ld+json']
json: ['application/json']
# Documentation OpenAPI
swagger:
versions: [3]
# Pagination par défaut
defaults:
pagination_items_per_page: 30
pagination_maximum_items_per_page: 100Cette configuration définit les formats de sérialisation, la pagination globale et les métadonnées de la documentation API.
Création d'une ressource API simple
L'attribut #[ApiResource] expose une entité Doctrine comme ressource REST. API Platform génère automatiquement les endpoints CRUD, la documentation OpenAPI et les validations de base.
<?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 Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
#[ApiResource(
// Définition explicite des opérations disponibles
operations: [
new GetCollection(),
new Post(),
new Get(),
new Put(),
new Patch(),
new Delete(),
],
// Ordre par défaut pour les collections
order: ['publishedAt' => 'DESC'],
// Configuration de la pagination pour cette ressource
paginationItemsPerPage: 20
)]
class Book
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'Le titre est obligatoire')]
#[Assert\Length(min: 2, max: 255)]
private ?string $title = null;
#[ORM\Column(type: 'text')]
#[Assert\NotBlank]
private ?string $description = null;
#[ORM\Column(length: 13, unique: true)]
#[Assert\Isbn]
private ?string $isbn = null;
#[ORM\Column]
private ?\DateTimeImmutable $publishedAt = null;
// Getters et 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 getDescription(): ?string
{
return $this->description;
}
public function setDescription(string $description): static
{
$this->description = $description;
return $this;
}
public function getIsbn(): ?string
{
return $this->isbn;
}
public function setIsbn(string $isbn): static
{
$this->isbn = $isbn;
return $this;
}
public function getPublishedAt(): ?\DateTimeImmutable
{
return $this->publishedAt;
}
public function setPublishedAt(\DateTimeImmutable $publishedAt): static
{
$this->publishedAt = $publishedAt;
return $this;
}
}Cette entité génère six endpoints : GET /api/books, POST /api/books, GET /api/books/{id}, PUT /api/books/{id}, PATCH /api/books/{id} et DELETE /api/books/{id}.
API Platform supporte nativement les UUID v7 comme identifiants. Cette approche améliore la sécurité (identifiants non prédictibles) et les performances (tri naturel par date de création).
Groupes de sérialisation pour contrôler les données exposées
Les groupes de sérialisation permettent de définir précisément quelles propriétés sont exposées en lecture (normalization) et en écriture (denormalization). Cette séparation est essentielle pour la sécurité et la flexibilité de l'API.
<?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 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;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use App\State\UserPasswordHasher;
#[ORM\Entity]
#[ORM\Table(name: '`user`')]
#[UniqueEntity('email')]
#[ApiResource(
operations: [
new GetCollection(),
// Processor personnalisé pour hasher le mot de passe
new Post(processor: UserPasswordHasher::class,
validationContext: ['groups' => ['Default', 'user:create']]),
new Get(),
new Put(processor: UserPasswordHasher::class),
new Patch(processor: UserPasswordHasher::class),
new Delete(),
],
// Propriétés exposées en lecture
normalizationContext: ['groups' => ['user:read']],
// Propriétés acceptées en écriture
denormalizationContext: ['groups' => ['user:create', 'user:update']],
)]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['user:read'])]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
#[Assert\NotBlank]
#[Assert\Email]
#[Groups(['user:read', 'user:create', 'user:update'])]
private ?string $email = null;
#[ORM\Column]
private ?string $password = null;
// Jamais exposé en lecture, uniquement en écriture
#[Assert\NotBlank(groups: ['user:create'])]
#[Groups(['user:create', 'user:update'])]
private ?string $plainPassword = null;
#[ORM\Column(length: 100)]
#[Groups(['user:read', 'user:create', 'user:update'])]
private ?string $fullName = null;
#[ORM\Column(type: 'json')]
#[Groups(['user:read'])]
private array $roles = [];
#[ORM\Column]
#[Groups(['user:read'])]
private ?\DateTimeImmutable $createdAt = null;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
// Implémentation UserInterface
public function getUserIdentifier(): string
{
return (string) $this->email;
}
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
public function getPassword(): string
{
return $this->password;
}
public function eraseCredentials(): void
{
$this->plainPassword = null;
}
// Getters et 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 setPassword(string $password): static
{
$this->password = $password;
return $this;
}
public function getPlainPassword(): ?string
{
return $this->plainPassword;
}
public function setPlainPassword(?string $plainPassword): static
{
$this->plainPassword = $plainPassword;
return $this;
}
public function getFullName(): ?string
{
return $this->fullName;
}
public function setFullName(string $fullName): static
{
$this->fullName = $fullName;
return $this;
}
public function setRoles(array $roles): static
{
$this->roles = $roles;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
}Avec cette configuration, plainPassword n'est jamais exposé en réponse mais peut être envoyé lors de la création ou mise à jour.
Prêt à réussir tes entretiens Symfony ?
Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.
State Processors pour la logique métier
Les State Processors interceptent les opérations de persistance pour ajouter de la logique métier. API Platform 4 simplifie leur création grâce à l'injection de dépendances via attributs.
<?php
// src/State/UserPasswordHasher.php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\User;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Processor qui hash le mot de passe avant persistance
* @implements ProcessorInterface<User, User>
*/
final class UserPasswordHasher implements ProcessorInterface
{
public function __construct(
// Injection du processor Doctrine standard
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor,
private UserPasswordHasherInterface $passwordHasher,
) {
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): User
{
// Hash le mot de passe si fourni
if ($data->getPlainPassword()) {
$hashedPassword = $this->passwordHasher->hashPassword(
$data,
$data->getPlainPassword()
);
$data->setPassword($hashedPassword);
$data->eraseCredentials();
}
// Délègue la persistance au processor standard
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
}Ce pattern de composition permet d'ajouter n'importe quelle logique (envoi d'emails, événements, logs) tout en conservant le comportement standard de persistance.
Processor avec logique conditionnelle
Un processor peut adapter son comportement selon le type d'opération.
<?php
// src/State/BookProcessor.php
namespace App\State;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Book;
use App\Service\NotificationService;
use App\Service\SearchIndexer;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* @implements ProcessorInterface<Book, Book|void>
*/
final class BookProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private ProcessorInterface $removeProcessor,
private NotificationService $notifications,
private SearchIndexer $searchIndexer,
) {
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
// Suppression : utilise le remove processor
if ($operation instanceof DeleteOperationInterface) {
$this->searchIndexer->remove($data);
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
// Création : définit la date de publication
if ($operation instanceof Post) {
$data->setPublishedAt(new \DateTimeImmutable());
}
// Persistance standard
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
// Post-traitement : indexation et notification
$this->searchIndexer->index($result);
if ($operation instanceof Post) {
$this->notifications->notifyNewBook($result);
}
return $result;
}
}State Providers pour les sources de données personnalisées
Les State Providers permettent de récupérer des données depuis n'importe quelle source : API externe, cache, fichiers, ou logique métier complexe.
<?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;
/**
* Provider qui retourne les livres populaires avec cache
* @implements ProviderInterface<Book>
*/
final class PopularBooksProvider implements ProviderInterface
{
public function __construct(
private BookRepository $bookRepository,
private CacheInterface $cache,
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
// Cache de 5 minutes pour les livres populaires
return $this->cache->get('popular_books', function (ItemInterface $item) {
$item->expiresAfter(300);
return $this->bookRepository->findPopular(limit: 10);
});
}
}Ce provider s'utilise sur une opération dédiée.
<?php
// src/Entity/Book.php (extrait)
use App\State\PopularBooksProvider;
#[ApiResource(
operations: [
// ... autres opérations
new GetCollection(
uriTemplate: '/books/popular',
provider: PopularBooksProvider::class,
paginationEnabled: false,
),
],
)]
class Book
{
// ...
}Validation avancée avec groupes dynamiques
API Platform intègre nativement le composant Validator de Symfony. Les groupes de validation peuvent varier selon l'opération ou le contexte.
<?php
// src/Entity/Article.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\Validator\Constraints as Assert;
#[ORM\Entity]
#[ApiResource(
operations: [
new GetCollection(),
new Get(),
// Validation stricte à la création
new Post(validationContext: ['groups' => ['Default', 'article:create']]),
// Validation plus souple pour les mises à jour
new Put(validationContext: ['groups' => ['Default', 'article:update']]),
],
)]
class Article
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
#[Assert\Length(min: 10, max: 255)]
private ?string $title = null;
#[ORM\Column(type: 'text')]
#[Assert\NotBlank]
// Minimum 500 caractères à la création
#[Assert\Length(min: 500, groups: ['article:create'])]
// Minimum 100 caractères pour les mises à jour
#[Assert\Length(min: 100, groups: ['article:update'])]
private ?string $content = null;
#[ORM\Column(length: 50)]
#[Assert\NotBlank(groups: ['article:create'])]
#[Assert\Choice(choices: ['draft', 'published', 'archived'])]
private ?string $status = 'draft';
#[ORM\Column(nullable: true)]
// Obligatoire seulement si le statut est "published"
#[Assert\NotBlank(groups: ['article:publish'])]
private ?\DateTimeImmutable $publishedAt = null;
// Getters et setters...
}Validation dynamique avec un service
Pour des règles de validation complexes, un générateur de groupes personnalisé offre une flexibilité totale.
<?php
// src/Validator/ArticleGroupsGenerator.php
namespace App\Validator;
use ApiPlatform\Symfony\Validator\ValidationGroupsGeneratorInterface;
use App\Entity\Article;
use Symfony\Bundle\SecurityBundle\Security;
final class ArticleGroupsGenerator implements ValidationGroupsGeneratorInterface
{
public function __construct(
private Security $security,
) {
}
public function __invoke(object $object): array
{
assert($object instanceof Article);
$groups = ['Default'];
// Les admins ont moins de restrictions
if ($this->security->isGranted('ROLE_ADMIN')) {
$groups[] = 'admin';
return $groups;
}
// Validation supplémentaire si publication
if ($object->getStatus() === 'published') {
$groups[] = 'article:publish';
}
return $groups;
}
}Les validations complexes peuvent impacter les performances. Pour les imports en masse, envisagez de désactiver temporairement certaines validations ou d'utiliser des contraintes asynchrones.
Filtres pour des requêtes flexibles
API Platform 4.2 repense complètement le système de filtres avec une séparation claire des responsabilités. Les filtres permettent aux clients de l'API de rechercher et trier les données.
<?php
// src/Entity/Product.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ApiResource]
// Recherche textuelle sur nom et description
#[ApiFilter(SearchFilter::class, properties: [
'name' => 'partial', // LIKE %value%
'description' => 'partial',
'category.name' => 'exact', // Recherche sur relation
'sku' => 'exact', // Correspondance exacte
])]
// Filtrage par plage de valeurs
#[ApiFilter(RangeFilter::class, properties: ['price', 'stock'])]
// Filtrage par date
#[ApiFilter(DateFilter::class, properties: ['createdAt', 'updatedAt'])]
// Filtrage booléen
#[ApiFilter(BooleanFilter::class, properties: ['isActive', 'isFeatured'])]
// Tri personnalisable
#[ApiFilter(OrderFilter::class, properties: [
'name',
'price',
'createdAt',
], 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(type: 'text', nullable: true)]
private ?string $description = null;
#[ORM\Column(length: 50, unique: true)]
private ?string $sku = null;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
private ?string $price = null;
#[ORM\Column]
private ?int $stock = null;
#[ORM\Column]
private ?bool $isActive = true;
#[ORM\Column]
private ?bool $isFeatured = false;
#[ORM\ManyToOne(targetEntity: Category::class)]
private ?Category $category = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
// Getters et setters...
}Ces filtres génèrent automatiquement la documentation OpenAPI et permettent des requêtes comme :
GET /api/products?name=phone&price[gte]=100&price[lte]=500&isActive=true&sort[price]=ascRelations et sous-ressources
API Platform gère élégamment les relations entre entités avec des options de sérialisation et des sous-ressources.
<?php
// src/Entity/Author.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
#[ORM\Entity]
#[ApiResource(
normalizationContext: ['groups' => ['author:read']],
)]
class Author
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['author:read', 'book:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['author:read', 'book:read'])]
private ?string $name = null;
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['author:read'])]
private ?string $biography = null;
#[ORM\OneToMany(mappedBy: 'author', targetEntity: Book::class)]
#[Groups(['author:read'])]
private Collection $books;
public function __construct()
{
$this->books = new ArrayCollection();
}
// Getters et setters...
}<?php
// src/Entity/Book.php (avec relation)
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
#[ORM\Entity]
#[ApiResource(
normalizationContext: ['groups' => ['book:read']],
)]
// Sous-ressource : GET /api/authors/{authorId}/books
#[ApiResource(
uriTemplate: '/authors/{authorId}/books',
operations: [new GetCollection()],
uriVariables: [
'authorId' => new Link(
fromProperty: 'books',
fromClass: Author::class
),
],
normalizationContext: ['groups' => ['book:read']],
)]
class Book
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['book:read', 'author:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['book:read', 'author:read'])]
private ?string $title = null;
#[ORM\ManyToOne(targetEntity: Author::class, inversedBy: 'books')]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['book:read'])]
private ?Author $author = null;
// Getters et setters...
}Sécurité et contrôle d'accès
API Platform s'intègre parfaitement avec le système de sécurité Symfony. Les voters et expressions de sécurité contrôlent l'accès aux ressources.
<?php
// src/Entity/Order.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Delete;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ApiResource(
operations: [
// Liste : utilisateurs authentifiés voient leurs commandes
new GetCollection(
security: "is_granted('ROLE_USER')",
// Filtre automatique par utilisateur connecté
provider: UserOrdersProvider::class,
),
// Création : tout utilisateur authentifié
new Post(
security: "is_granted('ROLE_USER')",
processor: CreateOrderProcessor::class,
),
// Lecture : propriétaire ou admin
new Get(
security: "is_granted('ROLE_ADMIN') or object.getCustomer() == user",
securityMessage: "Accès refusé à cette commande.",
),
// Modification : admin uniquement
new Patch(
security: "is_granted('ROLE_ADMIN')",
),
// Suppression : admin uniquement
new Delete(
security: "is_granted('ROLE_ADMIN')",
),
],
)]
class Order
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $customer = null;
#[ORM\Column(length: 50)]
private ?string $status = 'pending';
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
private ?string $total = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
// Getters...
public function getCustomer(): ?User
{
return $this->customer;
}
}L'expression object.getCustomer() == user donne accès à l'entité courante et à l'utilisateur connecté pour des vérifications fines.
Tests automatisés de l'API
API Platform fournit des traits PHPUnit pour tester facilement les endpoints.
<?php
// tests/Api/BookTest.php
namespace App\Tests\Api;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\Book;
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;
public function testGetCollection(): void
{
// Création de données de test
BookFactory::createMany(30);
// Requête GET sur la collection
$response = static::createClient()->request('GET', '/api/books');
// Assertions
$this->assertResponseIsSuccessful();
$this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
$this->assertJsonContains([
'@context' => '/api/contexts/Book',
'@type' => 'Collection',
'totalItems' => 30,
]);
// Vérifie la pagination (20 items par page)
$this->assertCount(20, $response->toArray()['member']);
}
public function testCreateBook(): void
{
$user = UserFactory::createOne(['roles' => ['ROLE_ADMIN']]);
static::createClient()->request('POST', '/api/books', [
'auth_bearer' => $this->getToken($user),
'json' => [
'title' => 'Clean Code',
'description' => 'A Handbook of Agile Software Craftsmanship',
'isbn' => '9780132350884',
],
]);
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains([
'@type' => 'Book',
'title' => 'Clean Code',
]);
}
public function testCreateBookValidationFails(): void
{
$user = UserFactory::createOne(['roles' => ['ROLE_ADMIN']]);
static::createClient()->request('POST', '/api/books', [
'auth_bearer' => $this->getToken($user),
'json' => [
'title' => '', // Titre vide = erreur
'isbn' => 'invalid-isbn',
],
]);
$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'@type' => 'ConstraintViolationList',
'violations' => [
['propertyPath' => 'title', 'message' => 'Le titre est obligatoire'],
],
]);
}
private function getToken(object $user): string
{
// Implémentation selon votre système d'authentification
return 'test_token';
}
}Conclusion
API Platform 4 avec Symfony 7 représente l'état de l'art pour créer des APIs REST professionnelles en PHP. La séparation claire entre State Providers (lecture) et State Processors (écriture), combinée aux groupes de sérialisation et au système de validation, permet de construire des APIs robustes et maintenables.
Checklist pour une API de qualité
- ✅ Utiliser des groupes de sérialisation distincts pour lecture et écriture
- ✅ Implémenter des State Processors pour la logique métier (hash de mot de passe, notifications)
- ✅ Configurer des filtres pour les recherches et le tri
- ✅ Appliquer des validations par opération avec des groupes
- ✅ Sécuriser les endpoints avec des expressions de sécurité
- ✅ Écrire des tests fonctionnels pour chaque endpoint
- ✅ Documenter l'API via les métadonnées OpenAPI
Passe à la pratique !
Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.
La philosophie d'API Platform 4 encourage la composition plutôt que l'héritage, et la configuration plutôt que la convention. Le résultat : des APIs évolutives, testables et conformes aux standards REST/JSON-LD, prêtes pour la production.
Tags
Partager
Articles similaires

Doctrine ORM : Maîtriser les relations en Symfony
Guide complet des relations Doctrine ORM dans Symfony. OneToMany, ManyToMany, stratégies de chargement et optimisation des performances avec exemples pratiques.

Questions d'entretien Symfony : Top 25 en 2026
Les 25 questions d'entretien Symfony les plus posées. Architecture, Doctrine ORM, services, sécurité, formulaires et tests avec réponses détaillées et exemples de code.

Symfony Live Components et UX 3.0 : Applications Réactives Sans JavaScript en 2026
Guide complet des Symfony Live Components et UX 3.0. Découvrez comment créer des interfaces réactives puissantes uniquement avec PHP et Twig, sans framework JavaScript.