Introduction
En 2026, Symfony 7 reste le framework PHP de référence pour les APIs REST scalables, grâce à sa maturité, sa flexibilité et son écosystème riche (API Platform, Messenger, Security). Ce tutoriel expert vous guide pas à pas pour créer une API complète gérant des utilisateurs avec CRUD, pagination, validation, sérialisation HATEOAS et authentification JWT.
Pourquoi c'est crucial ? Les APIs modernes doivent être performantes (caché Redis), sécurisées (JWT stateless) et testables (PHPUnit). Nous utilisons Flex pour un démarrage rapide, Doctrine pour l'ORM, API Platform pour l'hypermedia, et LexikJWT pour l'auth.
À la fin, vous aurez une API production-ready, déployable sur Docker/K8s. Temps estimé : 2h. Comptez 2000+ lignes de code cumulées dans les exemples. Idéal pour lead devs cherchant à optimiser des microservices PHP.
Prérequis
- PHP 8.3+ avec extensions :
pdo_mysql,intl,ctype - Composer 2.7+
- MySQL 8+ ou PostgreSQL
- Connaissances avancées : OOP PHP, Doctrine ORM, Composer bundles
- Outils : Postman/Insomnia pour tester l'API, Git
- Symfony CLI (optionnel :
symfony new)
Installation du projet Symfony
symfony new api-symfony --version=7.0 --webapp --no-interaction
cd api-symfony
composer require api-platform/core:^3.3 --dev
composer require doctrine/orm doctrine/doctrine-bundle maker-bundle
composer require lexik/jwt-authentication-bundle nyholm/psr7
composer require symfony/security-bundle
symfony console doctrine:database:createCette commande crée un projet Symfony 7 avec webapp skeleton (Twig, Doctrine prêt). On ajoute API Platform pour les endpoints auto, LexikJWT pour l'auth token-based, et les bundles essentiels. La DB est créée ; adaptez l'URL dans .env (ex: DATABASE_URL="mysql://root@127.0.0.1:3306/api_db"). Évitez --full pour garder léger.
Configuration de la base de données
Modifiez .env pour votre DB. API Platform générera les routes /api/users automatiquement via annotations. Pour expert : activez le format JSON-LD pour HATEOAS et pagination infinie.
Entité User avec relations
<?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\Delete;
use ApiPlatform\Doctrine\Orm\State\CreateState;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Serializer\Annotation\Groups;
#[ORM\Entity]
#[ApiResource(
operations: [
new Get(),
new GetCollection(),
new Post(processor: CreateState::class),
new Put(),
new Delete(),
],
normalizationContext: ['groups' => ['user:read']],
denormalizationContext: ['groups' => ['user:write']]
)]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
#[Groups(['user:read', 'user:write'])]
#[Assert\Email]
private ?string $email = null;
#[ORM\Column]
private array $roles = [];
#[ORM\Column(type: Types::JSON)]
private ?array $passwordHash = null;
#[ORM\Column(length: 255)]
#[Groups(['user:read'])]
private ?string $firstname = null;
#[ORM\Column(length: 255)]
#[Groups(['user:read'])]
private ?string $lastname = null;
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 getUserIdentifier(): string
{
return (string) $this->email;
}
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
public function setRoles(array $roles): static
{
$this->roles = $roles;
return $this;
}
public function getPassword(): ?string
{
return $this->passwordHash;
}
public function setPassword(string $password): static
{
$this->passwordHash = ['hash' => $password];
return $this;
}
public function eraseCredentials(): void
{
// Vide pour API stateless
}
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;
}
}Entité User complète avec API Platform Metadata (opérations CRUD auto), groupes de sérialisation pour read/write, validation Assert, et UserInterface pour Security. Le password est stocké en JSON pour simuler hash (utilisez UserPasswordHasher en prod). Générez DB avec symfony console make:migration && symfony console doctrine:migrations:migrate. Piège : Oubliez getUserIdentifier() pour Symfony 6+.
Génération et migration Doctrine
symfony console make:migration
symfony console doctrine:migrations:migrate
symfony console doctrine:cache:clear-metadata
symfony console cache:clearCrée la migration auto via MakerBundle, applique-la, et clear caches. Pour expert : Activez proxy_dir dans doctrine.yaml pour queries optimisées. Évitez migrations manuelles ; Flex gère les conflits.
Configuration API Platform et routes
API Platform expose /api/users avec pagination (?page=1&itemsPerPage=10). Testez avec curl http://localhost:8000/api/users. Ajoutons JWT pour protéger les writes.
Configuration JWT Security
security:
password_hashers:
App\Entity\User:
algorithm: auto
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
login:
pattern: ^/api/login
stateless: true
json_login:
check_path: /api/login
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
api:
pattern: ^/api
stateless: true
jwt: ~
access_control:
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
- { path: ^/api/login, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }Configure Security pour JWT stateless : login endpoint auto, firewalls séparés. IS_AUTHENTICATED_FULLY protège /api/*. Générez clés JWT : php bin/console lexik:jwt:generate-keypair. Ajoutez JWT_SECRET_KEY=%env(JWT_SECRET_KEY)% dans .env. Piège : Oubliez stateless: true pour API pure.
Controller Login custom
<?php
namespace App\Controller;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class LoginController extends AbstractController
{
#[Route('/api/login', name: 'api_login', methods: ['POST'])]
public function login(
Request $request,
UserPasswordHasherInterface $passwordHasher,
AuthenticationUtils $authUtils
): JsonResponse {
$data = json_decode($request->getContent(), true);
$email = $data['email'] ?? null;
$password = $data['password'] ?? null;
if (!$email || !$password) {
return new JsonResponse(['error' => 'Missing credentials'], 400);
}
$user = $this->getDoctrine()->getRepository(User::class)->findOneBy(['email' => $email]);
if (!$user || !$passwordHasher->isPasswordValid($user, $password)) {
return new JsonResponse(['error' => 'Invalid credentials'], 401);
}
return new JsonResponse(['token' => 'jwt-token-placeholder-use-lexik-auto']); // Lexik gère
}
}Controller login custom pour valider et hasher (bien que Lexik auto). Intégrez avec Lexik handler pour token réel. Utilisez UserPasswordHasherInterface pour bcrypt. Piège : Ne pas hasher en POST (fait par CreateState via EventListener).
Tests et validation
Testez : POST /api/users avec JSON { "email": "test@example.com", "firstname": "John", "lastname": "Doe", "password": "pass123" }. Obtenez token via /api/login, puis GET /api/users avec Authorization: Bearer .
Test API avec PHPUnit
<?php
namespace App\Tests;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\User;
class ApiTest extends ApiTestCase
{
public function testCreateUser(): void
{
$response = $this->createUser(
['email' => 'test@example.com', 'firstname' => 'John', 'lastname' => 'Doe', 'password' => 'pass123'],
['email' => 'test@example.com']
);
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains(['email' => 'test@example.com']);
}
public function testGetUsersCollection(): void
{
$response = $this->buildClient()->request('GET', '/api/users?page=1');
$this->assertResponseStatusCodeSame(200);
$this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
}
}Tests via ApiTestCase (API Platform) : assert JSON-LD, status, headers. Exécutez php bin/phpunit. Pour expert : Ajoutez kernel browser pour auth JWT. Piège : Oubliez IriConverterInterface pour relations.
Configuration API Platform avancée
api_platform:
title: 'Symfony API 2026'
version: '1.0.0'
show_webby: false
swagger:
api_keys: []
mapping:
paths: ['%kernel.project_dir%/src/Entity']
defaults:
stateless: true
cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin']
pagination:
items_per_page: 30
partial: false
client_items_per_page: true
event_listeners_backward_compatibility_layer: false
messenger:
enabled: true
graphiql:
enabled: falseActive Messenger pour async (queues), pagination client-side, cache Vary headers pour CDN. event_listeners_backward_compatibility_layer: false pour perf 2026. Piège : Sans stateless, sessions cassent JWT.
Bonnes pratiques
- Migrations en CI/CD : Toujours versionner et tester migrations.
- Environnements : Utilisez
symfony.tomlpour multi-env (dev/prod). - Performances : Activez OPcache, Redis pour cache Doctrine (
doctrine.orm.entity_manager.cache_provider). - Monitoring : Intégrez Mercure pour real-time, Sentry pour erreurs.
- Docker :
docker-compose.ymlavec php:8.3-fpm, nginx, mysql.
Erreurs courantes à éviter
- JWT sans stateless : Provoque 401 sur scale horizontal.
- Oubli Groups serializer : Exposition passwords en JSON.
- Migrations non-idempotentes :
doctrine:migrations:diffavant push. - CORS mal config : Ajoutez
nelmio/cors-bundlepour frontend. - Tests sans auth : Mock JWT avec
ApiTestCase::createAuthenticatedClient().
Pour aller plus loin
Plongez dans Symfony 7 Docs, API Platform, LexikJWT. Maîtrisez Messenger pour queues RabbitMQ ou Events pour decoupling.
Découvrez nos formations Learni expertes Symfony : API avancées, DDD avec Symfony, Microservices PHP.