Skip to content
Learni
View all tutorials
Backend

Comment créer une API REST avec Symfony en 2026

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

terminal
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:create

Cette 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

src/Entity/User.php
<?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

terminal
symfony console make:migration
symfony console doctrine:migrations:migrate
symfony console doctrine:cache:clear-metadata
symfony console cache:clear

Cré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

config/packages/security.yaml
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

src/Controller/LoginController.php
<?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

tests/ApiTest.php
<?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

config/packages/api_platform.yaml
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: false

Active 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.toml pour 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.yml avec 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:diff avant push.
  • CORS mal config : Ajoutez nelmio/cors-bundle pour 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.