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.

Questions d'entretien Symfony et PHP - Guide complet

Les entretiens Symfony évaluent la maîtrise du framework PHP professionnel de référence, la compréhension de l'architecture orientée composants, l'ORM Doctrine, et la capacité à construire des applications robustes et scalables. Ce guide couvre les 25 questions les plus posées, des fondamentaux Symfony jusqu'aux patterns avancés de production.

Conseil pour l'entretien

Les recruteurs apprécient les candidats qui comprennent la philosophie Symfony : découplage via les services, configuration explicite, et respect des standards PSR. Expliquer les choix architecturaux du framework fait la différence.

Fondamentaux Symfony

Question 1 : Expliquez le cycle de vie d'une requête dans Symfony

Le cycle de vie d'une requête Symfony traverse le Kernel HTTP et utilise un système d'événements pour permettre l'extension à chaque étape. Cette compréhension est essentielle pour le debugging et la personnalisation du comportement de l'application.

public/index.phpphp
// Point d'entrée de toutes les requêtes HTTP
use App\Kernel;

require_once dirname(__DIR__).'/vendor/autoload_runtime.php';

// Symfony Runtime gère le bootstrap
return function (array $context) {
    return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};
src/Kernel.phpphp
// Le Kernel orchestre le traitement de la requête
namespace App;

use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;

class Kernel extends BaseKernel
{
    use MicroKernelTrait;

    // Le Kernel charge les bundles et configure le container
    // Événements clés du cycle de vie :
    // 1. kernel.request - Avant le routing
    // 2. kernel.controller - Après résolution du contrôleur
    // 3. kernel.view - Si le contrôleur ne retourne pas une Response
    // 4. kernel.response - Avant l'envoi de la réponse
    // 5. kernel.terminate - Après l'envoi (tâches asynchrones)
}

Le cycle complet : index.php → Runtime → Kernel → HttpKernel → EventDispatcher (kernel.request) → Router → Controller → EventDispatcher (kernel.response) → Response. Chaque étape peut être interceptée via les Event Subscribers.

Question 2 : Qu'est-ce que le Service Container et l'injection de dépendances dans Symfony ?

Le Service Container (ou DIC - Dependency Injection Container) est le cœur de Symfony. Il gère l'instanciation, la configuration et l'injection de tous les services de l'application.

src/Service/PaymentService.phpphp
// Service avec dépendances injectées automatiquement
namespace App\Service;

use App\Repository\OrderRepository;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

class PaymentService
{
    public function __construct(
        private readonly HttpClientInterface $stripeClient, // Client HTTP configuré
        private readonly OrderRepository $orderRepository,   // Repository Doctrine
        private readonly LoggerInterface $logger,            // Logger PSR-3
        private readonly string $stripeApiKey,               // Paramètre injecté
    ) {}

    public function processPayment(int $orderId, float $amount): bool
    {
        $order = $this->orderRepository->find($orderId);

        try {
            $response = $this->stripeClient->request('POST', '/charges', [
                'body' => [
                    'amount' => $amount * 100,
                    'currency' => 'eur',
                    'source' => $order->getPaymentToken(),
                ],
            ]);

            $order->markAsPaid($response->toArray()['id']);
            $this->orderRepository->save($order, true);

            return true;
        } catch (\Exception $e) {
            $this->logger->error('Payment failed', [
                'order' => $orderId,
                'error' => $e->getMessage(),
            ]);
            return false;
        }
    }
}
yaml
# config/services.yaml
# Configuration des services
services:
    _defaults:
        autowire: true      # Injection automatique par type-hint
        autoconfigure: true # Configuration automatique des tags

    App\:
        resource: '../src/'
        exclude:
            - '../src/DependencyInjection/'
            - '../src/Entity/'
            - '../src/Kernel.php'

    # Configuration explicite avec paramètres
    App\Service\PaymentService:
        arguments:
            $stripeClient: '@stripe.client'
            $stripeApiKey: '%env(STRIPE_API_KEY)%'

L'autowiring résout automatiquement les dépendances par type-hint. Les paramètres scalaires nécessitent une configuration explicite.

Question 3 : Quelle est la différence entre un Bundle et un composant Symfony ?

Les Bundles sont des packages réutilisables qui intègrent des fonctionnalités dans une application Symfony. Les Composants sont des bibliothèques PHP autonomes utilisables sans Symfony.

src/MyBundle/MyBundle.phpphp
// Structure d'un Bundle personnalisé
namespace App\MyBundle;

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;

class MyBundle extends AbstractBundle
{
    // Chargement de la configuration du bundle
    public function loadExtension(
        array $config,
        ContainerConfigurator $container,
        ContainerBuilder $builder
    ): void {
        // Chargement des services du bundle
        $container->import('../config/services.yaml');

        // Configuration conditionnelle
        if ($config['feature_enabled']) {
            $container->services()
                ->set('my_bundle.feature_service', FeatureService::class)
                ->autowire();
        }
    }

    // Configuration par défaut du bundle
    public function configure(DefinitionConfigurator $definition): void
    {
        $definition->rootNode()
            ->children()
                ->booleanNode('feature_enabled')->defaultTrue()->end()
                ->scalarNode('api_key')->isRequired()->end()
            ->end();
    }
}
php
// Utilisation d'un Composant sans Symfony
// Les composants sont des bibliothèques PHP autonomes
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

// Utilisable dans n'importe quel projet PHP
$request = Request::createFromGlobals();
$response = new Response('Hello World', 200);
$response->send();

Les Bundles encapsulent configuration, services et ressources. Les Composants sont des outils bas niveau réutilisables partout.

Question 4 : Comment fonctionnent les Event Subscribers dans Symfony ?

Les Event Subscribers permettent de réagir aux événements du framework ou de l'application, découplant ainsi la logique métier du code principal.

src/EventSubscriber/ApiExceptionSubscriber.phpphp
namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpKernel\KernelEvents;

class ApiExceptionSubscriber implements EventSubscriberInterface
{
    // Déclaration des événements écoutés et leur priorité
    public static function getSubscribedEvents(): array
    {
        return [
            // Priorité haute (exécuté avant les autres)
            KernelEvents::EXCEPTION => ['onKernelException', 100],
            KernelEvents::RESPONSE => ['onKernelResponse', 0],
        ];
    }

    public function onKernelException(ExceptionEvent $event): void
    {
        $exception = $event->getThrowable();
        $request = $event->getRequest();

        // Ne traiter que les requêtes API
        if (!str_starts_with($request->getPathInfo(), '/api')) {
            return;
        }

        $statusCode = $exception instanceof HttpExceptionInterface
            ? $exception->getStatusCode()
            : 500;

        $response = new JsonResponse([
            'error' => true,
            'message' => $exception->getMessage(),
            'code' => $statusCode,
        ], $statusCode);

        // Remplace la réponse par notre JSON
        $event->setResponse($response);
    }

    public function onKernelResponse(\Symfony\Component\HttpKernel\Event\ResponseEvent $event): void
    {
        // Ajout d'en-têtes personnalisés
        $event->getResponse()->headers->set('X-Api-Version', '1.0');
    }
}

Les Event Subscribers sont auto-découverts grâce à l'autoconfigure. La priorité détermine l'ordre d'exécution (plus haute = exécuté en premier).

Doctrine ORM

Question 5 : Expliquez les relations Doctrine et leurs différences

Doctrine propose plusieurs types de relations pour modéliser les associations entre entités. Chaque type a ses implications sur les requêtes et la performance.

src/Entity/User.phpphp
namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: UserRepository::class)]
class User
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    // Relation OneToOne : un utilisateur a un profil
    #[ORM\OneToOne(mappedBy: 'user', cascade: ['persist', 'remove'])]
    private ?Profile $profile = null;

    // Relation OneToMany : un utilisateur a plusieurs articles
    #[ORM\OneToMany(mappedBy: 'author', targetEntity: Article::class, orphanRemoval: true)]
    private Collection $articles;

    // Relation ManyToMany : plusieurs utilisateurs ont plusieurs rôles
    #[ORM\ManyToMany(targetEntity: Role::class, inversedBy: 'users')]
    #[ORM\JoinTable(name: 'user_roles')]
    private Collection $roles;

    public function __construct()
    {
        // Initialisation obligatoire des collections
        $this->articles = new ArrayCollection();
        $this->roles = new ArrayCollection();
    }

    public function addArticle(Article $article): static
    {
        if (!$this->articles->contains($article)) {
            $this->articles->add($article);
            $article->setAuthor($this); // Synchronisation bidirectionnelle
        }
        return $this;
    }

    public function removeArticle(Article $article): static
    {
        if ($this->articles->removeElement($article)) {
            if ($article->getAuthor() === $this) {
                $article->setAuthor(null);
            }
        }
        return $this;
    }
}
src/Entity/Article.phpphp
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
class Article
{
    // Relation ManyToOne : plusieurs articles appartiennent à un auteur
    #[ORM\ManyToOne(inversedBy: 'articles')]
    #[ORM\JoinColumn(nullable: false)]
    private ?User $author = null;

    // Relation ManyToMany avec attributs supplémentaires via entité pivot
    #[ORM\OneToMany(mappedBy: 'article', targetEntity: ArticleTag::class)]
    private Collection $articleTags;
}

Les relations bidirectionnelles nécessitent une synchronisation manuelle. Le côté "owning" (avec JoinColumn/JoinTable) contrôle la persistance.

Question 6 : Qu'est-ce que le problème N+1 et comment le résoudre avec Doctrine ?

Le problème N+1 survient quand une requête principale génère N requêtes supplémentaires pour charger les relations. C'est la cause la plus fréquente de lenteur dans les applications Symfony.

src/Repository/ArticleRepository.phpphp
namespace App\Repository;

use App\Entity\Article;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

class ArticleRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Article::class);
    }

    // ❌ PROBLÈME : N+1 requêtes si on accède aux auteurs
    public function findAllBad(): array
    {
        return $this->findAll();
        // + 1 requête par article pour charger l'auteur
    }

    // ✅ SOLUTION 1 : JOIN avec fetch eager
    public function findAllWithAuthor(): array
    {
        return $this->createQueryBuilder('a')
            ->addSelect('u')              // SELECT également l'auteur
            ->leftJoin('a.author', 'u')   // JOIN sur la relation
            ->orderBy('a.publishedAt', 'DESC')
            ->getQuery()
            ->getResult();
    }

    // ✅ SOLUTION 2 : JOIN multiple pour plusieurs relations
    public function findAllWithDetails(): array
    {
        return $this->createQueryBuilder('a')
            ->addSelect('u', 'c', 't')    // SELECT toutes les relations
            ->leftJoin('a.author', 'u')
            ->leftJoin('a.comments', 'c')
            ->leftJoin('a.tags', 't')
            ->where('a.status = :status')
            ->setParameter('status', 'published')
            ->orderBy('a.publishedAt', 'DESC')
            ->getQuery()
            ->getResult();
    }

    // ✅ SOLUTION 3 : Batch loading pour les grandes listes
    public function findAllOptimized(): array
    {
        $query = $this->createQueryBuilder('a')
            ->getQuery();

        // Charge les relations par lots de 100
        $query->setFetchMode(Article::class, 'author', ClassMetadata::FETCH_BATCH);

        return $query->getResult();
    }
}

Utiliser le Symfony Profiler avec le panneau Doctrine pour détecter les problèmes N+1. Le nombre de requêtes apparaît dans la Web Debug Toolbar.

Question 7 : Comment créer des Query Extensions et des filtres Doctrine ?

Les Query Extensions et Doctrine Filters permettent d'appliquer automatiquement des conditions à toutes les requêtes, idéal pour le multi-tenancy ou le soft delete.

src/Doctrine/Extension/CurrentUserExtension.phpphp
// Extension API Platform pour filtrer automatiquement par utilisateur
namespace App\Doctrine\Extension;

use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Article;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;

final class CurrentUserExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
    public function __construct(
        private readonly Security $security,
    ) {}

    public function applyToCollection(
        QueryBuilder $queryBuilder,
        QueryNameGeneratorInterface $queryNameGenerator,
        string $resourceClass,
        ?Operation $operation = null,
        array $context = []
    ): void {
        $this->addWhere($queryBuilder, $resourceClass);
    }

    public function applyToItem(
        QueryBuilder $queryBuilder,
        QueryNameGeneratorInterface $queryNameGenerator,
        string $resourceClass,
        array $identifiers,
        ?Operation $operation = null,
        array $context = []
    ): void {
        $this->addWhere($queryBuilder, $resourceClass);
    }

    private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void
    {
        // Ne s'applique qu'aux Articles
        if ($resourceClass !== Article::class) {
            return;
        }

        // Admin voit tout
        if ($this->security->isGranted('ROLE_ADMIN')) {
            return;
        }

        $user = $this->security->getUser();
        $rootAlias = $queryBuilder->getRootAliases()[0];

        // Filtre automatique par auteur
        $queryBuilder
            ->andWhere(sprintf('%s.author = :current_user', $rootAlias))
            ->setParameter('current_user', $user);
    }
}
src/Doctrine/Filter/SoftDeleteFilter.phpphp
// Filtre global Doctrine pour exclure les éléments supprimés
namespace App\Doctrine\Filter;

use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;

class SoftDeleteFilter extends SQLFilter
{
    public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string
    {
        // Vérifie si l'entité a un champ deletedAt
        if (!$targetEntity->hasField('deletedAt')) {
            return '';
        }

        return sprintf('%s.deleted_at IS NULL', $targetTableAlias);
    }
}
yaml
# config/packages/doctrine.yaml
doctrine:
    orm:
        filters:
            soft_delete:
                class: App\Doctrine\Filter\SoftDeleteFilter
                enabled: true

Les filtres s'appliquent au niveau SQL, les extensions au niveau QueryBuilder. Désactiver temporairement avec $em->getFilters()->disable('soft_delete').

Prêt à réussir tes entretiens Symfony ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Sécurité Symfony

Question 8 : Comment fonctionne le système de sécurité Symfony ?

Le composant Security de Symfony gère l'authentification (qui est l'utilisateur) et l'autorisation (que peut-il faire) via une architecture extensible.

yaml
# config/packages/security.yaml
security:
    password_hashers:
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'

    providers:
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email

    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false

        api:
            pattern: ^/api
            stateless: true
            jwt: ~  # Lexik JWT Bundle

        main:
            lazy: true
            provider: app_user_provider
            form_login:
                login_path: app_login
                check_path: app_login
                enable_csrf: true
            logout:
                path: app_logout
            remember_me:
                secret: '%kernel.secret%'
                lifetime: 604800

    access_control:
        - { path: ^/api/login, roles: PUBLIC_ACCESS }
        - { path: ^/api, roles: ROLE_USER }
        - { path: ^/admin, roles: ROLE_ADMIN }
src/Security/CustomAuthenticator.phpphp
// Authenticator personnalisé pour logique spécifique
namespace App\Security;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;

class ApiKeyAuthenticator extends AbstractAuthenticator
{
    public function supports(Request $request): ?bool
    {
        // Cet authenticator ne gère que les requêtes avec X-API-KEY
        return $request->headers->has('X-API-KEY');
    }

    public function authenticate(Request $request): Passport
    {
        $apiKey = $request->headers->get('X-API-KEY');

        if (null === $apiKey) {
            throw new AuthenticationException('No API key provided');
        }

        // UserBadge charge l'utilisateur par l'identifiant
        return new SelfValidatingPassport(
            new UserBadge($apiKey, function (string $apiKey) {
                // Logique de chargement de l'utilisateur par API key
                return $this->userRepository->findByApiKey($apiKey);
            })
        );
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        // null = continuer la requête normalement
        return null;
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        return new JsonResponse([
            'error' => $exception->getMessage(),
        ], Response::HTTP_UNAUTHORIZED);
    }
}

L'architecture Security repose sur les Firewalls (configuration), Authenticators (authentification), et Voters (autorisation).

Question 9 : Comment implémenter les Voters pour l'autorisation fine ?

Les Voters permettent une logique d'autorisation complexe et réutilisable, séparant les règles métier du code des contrôleurs.

src/Security/Voter/ArticleVoter.phpphp
namespace App\Security\Voter;

use App\Entity\Article;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

class ArticleVoter extends Voter
{
    public const VIEW = 'ARTICLE_VIEW';
    public const EDIT = 'ARTICLE_EDIT';
    public const DELETE = 'ARTICLE_DELETE';

    protected function supports(string $attribute, mixed $subject): bool
    {
        // Ce voter ne traite que les Articles et ces attributs
        return in_array($attribute, [self::VIEW, self::EDIT, self::DELETE])
            && $subject instanceof Article;
    }

    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
    {
        $user = $token->getUser();
        $article = $subject;

        // Les articles publiés sont visibles par tous
        if ($attribute === self::VIEW && $article->isPublished()) {
            return true;
        }

        // Les autres actions nécessitent un utilisateur authentifié
        if (!$user instanceof User) {
            return false;
        }

        return match ($attribute) {
            self::VIEW => $this->canView($article, $user),
            self::EDIT => $this->canEdit($article, $user),
            self::DELETE => $this->canDelete($article, $user),
            default => false,
        };
    }

    private function canView(Article $article, User $user): bool
    {
        // Brouillons visibles uniquement par l'auteur ou les admins
        return $article->getAuthor() === $user
            || in_array('ROLE_ADMIN', $user->getRoles());
    }

    private function canEdit(Article $article, User $user): bool
    {
        // Seul l'auteur peut modifier
        return $article->getAuthor() === $user;
    }

    private function canDelete(Article $article, User $user): bool
    {
        // Auteur ou admin peut supprimer
        return $article->getAuthor() === $user
            || in_array('ROLE_ADMIN', $user->getRoles());
    }
}
src/Controller/ArticleController.phpphp
// Utilisation du Voter dans un contrôleur
use Symfony\Component\Security\Http\Attribute\IsGranted;

class ArticleController extends AbstractController
{
    #[Route('/articles/{id}/edit', name: 'article_edit')]
    #[IsGranted(ArticleVoter::EDIT, subject: 'article')]
    public function edit(Article $article): Response
    {
        // L'autorisation est vérifiée automatiquement
        // 403 si le voter refuse l'accès
    }

    // Alternative programmatique
    #[Route('/articles/{id}', name: 'article_show')]
    public function show(Article $article): Response
    {
        $this->denyAccessUnlessGranted(ArticleVoter::VIEW, $article);

        // Ou avec condition
        if (!$this->isGranted(ArticleVoter::EDIT, $article)) {
            // Masquer le bouton d'édition
        }
    }
}
twig
{# Dans Twig #}
{% if is_granted('ARTICLE_EDIT', article) %}
    <a href="{{ path('article_edit', {id: article.id}) }}">Modifier</a>
{% endif %}

Les Voters sont auto-découverts et consultés automatiquement lors des appels à isGranted(). La stratégie par défaut accorde l'accès si au moins un Voter vote positivement.

Question 10 : Comment sécuriser une API avec JWT dans Symfony ?

L'authentification JWT (JSON Web Token) est la solution standard pour les APIs stateless. Symfony utilise généralement le bundle LexikJWTAuthenticationBundle.

yaml
# config/packages/lexik_jwt_authentication.yaml
lexik_jwt_authentication:
    secret_key: '%env(resolve:JWT_SECRET_KEY)%'
    public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
    pass_phrase: '%env(JWT_PASSPHRASE)%'
    token_ttl: 3600  # 1 heure
src/Controller/Api/AuthController.phpphp
namespace App\Controller\Api;

use App\Entity\User;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/api')]
class AuthController extends AbstractController
{
    #[Route('/login', name: 'api_login', methods: ['POST'])]
    public function login(
        Request $request,
        UserPasswordHasherInterface $passwordHasher,
        JWTTokenManagerInterface $jwtManager,
    ): JsonResponse {
        $data = json_decode($request->getContent(), true);

        $user = $this->userRepository->findOneBy(['email' => $data['email']]);

        if (!$user || !$passwordHasher->isPasswordValid($user, $data['password'])) {
            return new JsonResponse(['error' => 'Invalid credentials'], 401);
        }

        // Génération du token JWT
        $token = $jwtManager->create($user);

        return new JsonResponse([
            'token' => $token,
            'user' => [
                'id' => $user->getId(),
                'email' => $user->getEmail(),
                'roles' => $user->getRoles(),
            ],
        ]);
    }

    #[Route('/refresh-token', name: 'api_refresh_token', methods: ['POST'])]
    public function refreshToken(): JsonResponse
    {
        // Géré automatiquement par le bundle si configuré
        // Retourne un nouveau token à partir du refresh token
    }
}
src/EventListener/JWTCreatedListener.phpphp
// Personnalisation du payload JWT
namespace App\EventListener;

use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent;

class JWTCreatedListener
{
    public function onJWTCreated(JWTCreatedEvent $event): void
    {
        $user = $event->getUser();
        $payload = $event->getData();

        // Ajout de données personnalisées au token
        $payload['user_id'] = $user->getId();
        $payload['email'] = $user->getEmail();
        $payload['permissions'] = $user->getPermissions();

        $event->setData($payload);
    }
}

Le token JWT est envoyé dans l'en-tête Authorization: Bearer <token>. Le bundle vérifie automatiquement la signature et l'expiration.

Formulaires Symfony

Question 11 : Comment créer des formulaires avancés avec validation ?

Le composant Form de Symfony génère des formulaires HTML, gère la soumission et valide les données avec des contraintes.

src/Form/ArticleType.phpphp
namespace App\Form;

use App\Entity\Article;
use App\Entity\Category;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;

class ArticleType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('title', TextType::class, [
                'label' => 'Titre de l\'article',
                'attr' => ['placeholder' => 'Entrez le titre...'],
                'constraints' => [
                    new Assert\NotBlank(message: 'Le titre est obligatoire'),
                    new Assert\Length(
                        min: 10,
                        max: 255,
                        minMessage: 'Le titre doit faire au moins {{ limit }} caractères',
                    ),
                ],
            ])
            ->add('content', TextareaType::class, [
                'label' => 'Contenu',
                'constraints' => [
                    new Assert\NotBlank(),
                    new Assert\Length(min: 100),
                ],
            ])
            ->add('category', EntityType::class, [
                'class' => Category::class,
                'choice_label' => 'name',
                'placeholder' => 'Choisir une catégorie',
                'query_builder' => function ($repo) {
                    return $repo->createQueryBuilder('c')
                        ->where('c.active = true')
                        ->orderBy('c.name', 'ASC');
                },
            ])
            ->add('coverImage', FileType::class, [
                'label' => 'Image de couverture',
                'mapped' => false,  // Non lié à l'entité
                'required' => false,
                'constraints' => [
                    new Assert\Image(
                        maxSize: '5M',
                        mimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
                        mimeTypesMessage: 'Format d\'image non supporté',
                    ),
                ],
            ])
            ->add('publishedAt', DateTimeType::class, [
                'widget' => 'single_text',
                'required' => false,
                'label' => 'Date de publication',
            ]);

        // Event listener pour modifier le formulaire dynamiquement
        $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
            $article = $event->getData();
            $form = $event->getForm();

            // Ajouter un champ seulement pour les modifications
            if ($article && $article->getId()) {
                $form->add('slug', TextType::class, [
                    'disabled' => true,
                    'help' => 'Le slug ne peut pas être modifié',
                ]);
            }
        });
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Article::class,
            'validation_groups' => ['Default', 'article_creation'],
        ]);
    }
}
src/Controller/ArticleController.phpphp
#[Route('/articles/new', name: 'article_new')]
public function new(Request $request, SluggerInterface $slugger): Response
{
    $article = new Article();
    $form = $this->createForm(ArticleType::class, $article);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        // Gestion de l'upload de fichier
        $coverImage = $form->get('coverImage')->getData();
        if ($coverImage) {
            $filename = $slugger->slug($article->getTitle()).'-'.uniqid().'.'.$coverImage->guessExtension();
            $coverImage->move($this->getParameter('covers_directory'), $filename);
            $article->setCoverImageFilename($filename);
        }

        $this->entityManager->persist($article);
        $this->entityManager->flush();

        $this->addFlash('success', 'Article créé avec succès !');
        return $this->redirectToRoute('article_show', ['id' => $article->getId()]);
    }

    return $this->render('article/new.html.twig', [
        'form' => $form,
    ]);
}

Les FormEvents (PRE_SET_DATA, POST_SUBMIT, etc.) permettent de modifier dynamiquement les champs selon le contexte.

Question 12 : Comment implémenter la validation personnalisée avec des contraintes ?

Symfony permet de créer des contraintes de validation personnalisées pour les règles métier complexes.

src/Validator/UniqueEmail.phpphp
// Contrainte personnalisée
namespace App\Validator;

use Symfony\Component\Validator\Constraint;

#[\Attribute(\Attribute::TARGET_PROPERTY)]
class UniqueEmail extends Constraint
{
    public string $message = 'Cette adresse email "{{ value }}" est déjà utilisée.';
    public ?int $excludeId = null;  // Pour exclure l'utilisateur actuel en modification
}
src/Validator/UniqueEmailValidator.phpphp
// Validateur associé à la contrainte
namespace App\Validator;

use App\Repository\UserRepository;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

class UniqueEmailValidator extends ConstraintValidator
{
    public function __construct(
        private readonly UserRepository $userRepository,
    ) {}

    public function validate(mixed $value, Constraint $constraint): void
    {
        if (!$constraint instanceof UniqueEmail) {
            throw new UnexpectedTypeException($constraint, UniqueEmail::class);
        }

        if (null === $value || '' === $value) {
            return; // Laisser NotBlank gérer les valeurs vides
        }

        $existingUser = $this->userRepository->findOneBy(['email' => $value]);

        // Vérifie si un utilisateur existe avec cet email
        // et que ce n'est pas l'utilisateur actuel (pour les modifications)
        if ($existingUser && $existingUser->getId() !== $constraint->excludeId) {
            $this->context->buildViolation($constraint->message)
                ->setParameter('{{ value }}', $value)
                ->addViolation();
        }
    }
}
src/Entity/User.phpphp
// Utilisation de la contrainte sur l'entité
use App\Validator as AppAssert;
use Symfony\Component\Validator\Constraints as Assert;

class User
{
    #[Assert\NotBlank]
    #[Assert\Email]
    #[AppAssert\UniqueEmail]
    private ?string $email = null;

    #[Assert\NotBlank]
    #[Assert\Length(min: 8)]
    #[Assert\Regex(
        pattern: '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/',
        message: 'Le mot de passe doit contenir une majuscule, une minuscule et un chiffre'
    )]
    private ?string $plainPassword = null;
}
php
// Contrainte au niveau de la classe pour validation multi-champs
// src/Validator/PasswordMatch.php
#[\Attribute(\Attribute::TARGET_CLASS)]
class PasswordMatch extends Constraint
{
    public string $message = 'Les mots de passe ne correspondent pas.';

    public function getTargets(): string
    {
        return self::CLASS_CONSTRAINT;
    }
}

Les contraintes personnalisées sont auto-découvertes. Le suffixe Validator est obligatoire pour le validateur.

Messenger et Communication Asynchrone

Question 13 : Comment implémenter le traitement asynchrone avec Messenger ?

Symfony Messenger permet de dispatcher des messages vers des files d'attente pour un traitement asynchrone, améliorant la réactivité de l'application.

src/Message/SendWelcomeEmail.phpphp
// Le message (DTO contenant les données)
namespace App\Message;

final class SendWelcomeEmail
{
    public function __construct(
        public readonly int $userId,
        public readonly string $locale = 'fr',
    ) {}
}
src/MessageHandler/SendWelcomeEmailHandler.phpphp
// Le handler qui traite le message
namespace App\MessageHandler;

use App\Message\SendWelcomeEmail;
use App\Repository\UserRepository;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class SendWelcomeEmailHandler
{
    public function __construct(
        private readonly UserRepository $userRepository,
        private readonly MailerInterface $mailer,
    ) {}

    public function __invoke(SendWelcomeEmail $message): void
    {
        $user = $this->userRepository->find($message->userId);

        if (!$user) {
            return; // Utilisateur supprimé entre-temps
        }

        $email = (new TemplatedEmail())
            ->to($user->getEmail())
            ->subject('Bienvenue sur notre plateforme !')
            ->htmlTemplate('emails/welcome.html.twig')
            ->context([
                'user' => $user,
                'locale' => $message->locale,
            ]);

        $this->mailer->send($email);
    }
}
yaml
# config/packages/messenger.yaml
framework:
    messenger:
        failure_transport: failed

        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy:
                    max_retries: 3
                    delay: 1000
                    multiplier: 2
            failed:
                dsn: 'doctrine://default?queue_name=failed'

        routing:
            # Route les messages vers le transport async
            App\Message\SendWelcomeEmail: async
            App\Message\ProcessImage: async
            App\Message\GenerateReport: async
src/Controller/RegistrationController.phpphp
// Dispatch du message
use Symfony\Component\Messenger\MessageBusInterface;

class RegistrationController extends AbstractController
{
    #[Route('/register', name: 'app_register', methods: ['POST'])]
    public function register(
        Request $request,
        MessageBusInterface $bus,
    ): Response {
        // ... création de l'utilisateur

        // Dispatch asynchrone - l'email sera envoyé en arrière-plan
        $bus->dispatch(new SendWelcomeEmail(
            userId: $user->getId(),
            locale: $request->getLocale(),
        ));

        // Réponse immédiate à l'utilisateur
        return $this->redirectToRoute('app_login');
    }
}

Lancer le worker avec php bin/console messenger:consume async -vv. En production, utiliser Supervisor pour maintenir le worker actif.

Question 14 : Comment gérer les erreurs et le retry avec Messenger ?

Messenger offre des mécanismes robustes pour gérer les échecs : retry automatique, dead letter queue, et gestion manuelle des messages en erreur.

src/MessageHandler/ProcessPaymentHandler.phpphp
namespace App\MessageHandler;

use App\Exception\PaymentFailedException;
use App\Exception\PaymentRetryableException;
use App\Message\ProcessPayment;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\RecoverableMessageHandlingException;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;

#[AsMessageHandler]
final class ProcessPaymentHandler
{
    public function __invoke(ProcessPayment $message): void
    {
        try {
            $this->paymentGateway->process($message->orderId);
        } catch (PaymentRetryableException $e) {
            // Erreur temporaire (timeout, rate limit) → retry
            throw new RecoverableMessageHandlingException(
                $e->getMessage(),
                previous: $e
            );
        } catch (PaymentFailedException $e) {
            // Erreur permanente (carte invalide) → pas de retry
            throw new UnrecoverableMessageHandlingException(
                $e->getMessage(),
                previous: $e
            );
        }
    }
}
src/Message/ProcessPayment.phpphp
// Configuration du retry au niveau du message
namespace App\Message;

use Symfony\Component\Messenger\Stamp\DelayStamp;

final class ProcessPayment
{
    public function __construct(
        public readonly int $orderId,
        public readonly int $attempt = 1,
    ) {}

    // Délai de retry personnalisé selon le nombre de tentatives
    public function getRetryDelay(): int
    {
        return match ($this->attempt) {
            1 => 5000,   // 5 secondes
            2 => 30000,  // 30 secondes
            3 => 300000, // 5 minutes
            default => 600000,
        };
    }
}
bash
# Commandes de gestion des messages en erreur
php bin/console messenger:failed:show          # Liste les messages en erreur
php bin/console messenger:failed:retry         # Retente tous les messages
php bin/console messenger:failed:retry 123     # Retente un message spécifique
php bin/console messenger:failed:remove 123    # Supprime un message

La stratégie de retry et le transport "failed" garantissent qu'aucun message n'est perdu. Les messages peuvent être analysés et retentés manuellement.

Prêt à réussir tes entretiens Symfony ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Testing dans Symfony

Question 15 : Comment structurer les tests dans Symfony ?

Symfony fournit PHPUnit avec des helpers dédiés pour tester les différentes couches de l'application : tests unitaires, fonctionnels et d'intégration.

tests/Unit/Service/PriceCalculatorTest.phpphp
// Test unitaire : teste une classe isolée
namespace App\Tests\Unit\Service;

use App\Service\PriceCalculator;
use PHPUnit\Framework\TestCase;

class PriceCalculatorTest extends TestCase
{
    private PriceCalculator $calculator;

    protected function setUp(): void
    {
        $this->calculator = new PriceCalculator();
    }

    public function testCalculateTotalWithoutDiscount(): void
    {
        $total = $this->calculator->calculateTotal(100.00, 0);

        $this->assertEquals(100.00, $total);
    }

    public function testCalculateTotalWithPercentageDiscount(): void
    {
        $total = $this->calculator->calculateTotal(100.00, 20);

        $this->assertEquals(80.00, $total);
    }

    /**
     * @dataProvider discountProvider
     */
    public function testCalculateTotalWithVariousDiscounts(
        float $price,
        int $discount,
        float $expected
    ): void {
        $total = $this->calculator->calculateTotal($price, $discount);

        $this->assertEquals($expected, $total);
    }

    public static function discountProvider(): array
    {
        return [
            'no discount' => [100.00, 0, 100.00],
            '10% discount' => [100.00, 10, 90.00],
            '50% discount' => [200.00, 50, 100.00],
            'max discount' => [100.00, 100, 0.00],
        ];
    }
}
tests/Functional/Controller/ArticleControllerTest.phpphp
// Test fonctionnel : teste les contrôleurs via HTTP
namespace App\Tests\Functional\Controller;

use App\Entity\Article;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class ArticleControllerTest extends WebTestCase
{
    private $client;
    private EntityManagerInterface $entityManager;

    protected function setUp(): void
    {
        $this->client = static::createClient();
        $this->entityManager = static::getContainer()->get(EntityManagerInterface::class);
    }

    public function testListArticlesReturnsOk(): void
    {
        $this->client->request('GET', '/articles');

        $this->assertResponseIsSuccessful();
        $this->assertSelectorExists('h1');
    }

    public function testCreateArticleRequiresAuthentication(): void
    {
        $this->client->request('GET', '/articles/new');

        $this->assertResponseRedirects('/login');
    }

    public function testAuthenticatedUserCanCreateArticle(): void
    {
        // Authentification
        $user = $this->createUser();
        $this->client->loginUser($user);

        // Accès au formulaire
        $crawler = $this->client->request('GET', '/articles/new');
        $this->assertResponseIsSuccessful();

        // Soumission du formulaire
        $form = $crawler->selectButton('Créer')->form([
            'article[title]' => 'Test Article Title',
            'article[content]' => 'This is the content of my test article with enough characters.',
        ]);
        $this->client->submit($form);

        // Vérification
        $this->assertResponseRedirects();
        $this->client->followRedirect();
        $this->assertSelectorTextContains('h1', 'Test Article Title');

        // Vérification en base
        $article = $this->entityManager->getRepository(Article::class)
            ->findOneBy(['title' => 'Test Article Title']);
        $this->assertNotNull($article);
    }

    private function createUser(): User
    {
        $user = new User();
        $user->setEmail('test@example.com');
        $user->setPassword('$2y$13$hashedpassword');

        $this->entityManager->persist($user);
        $this->entityManager->flush();

        return $user;
    }

    protected function tearDown(): void
    {
        // Nettoyage de la base de test
        $this->entityManager->getConnection()->executeStatement('DELETE FROM article');
        $this->entityManager->getConnection()->executeStatement('DELETE FROM user');

        parent::tearDown();
    }
}

Séparer les tests Unit (pas de kernel), Functional (avec kernel), et Integration (services réels).

Question 16 : Comment utiliser les Fixtures et le DatabaseResetter ?

Les Fixtures peuplent la base de données avec des données de test réalistes. Le composant DoctrineTestBundle facilite le reset entre les tests.

src/DataFixtures/ArticleFixtures.phpphp
namespace App\DataFixtures;

use App\Entity\Article;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;

class ArticleFixtures extends Fixture implements DependentFixtureInterface
{
    public function load(ObjectManager $manager): void
    {
        for ($i = 1; $i <= 20; $i++) {
            $article = new Article();
            $article->setTitle("Article de test numéro $i");
            $article->setSlug("article-test-$i");
            $article->setContent("Contenu détaillé de l'article numéro $i...");
            $article->setStatus($i <= 15 ? 'published' : 'draft');
            $article->setPublishedAt($i <= 15 ? new \DateTimeImmutable("-$i days") : null);

            // Référence à un utilisateur créé par UserFixtures
            $article->setAuthor($this->getReference('user-'.($i % 3), User::class));

            $manager->persist($article);

            // Création de références pour d'autres fixtures
            $this->addReference("article-$i", $article);
        }

        $manager->flush();
    }

    public function getDependencies(): array
    {
        // UserFixtures doit être chargé avant ArticleFixtures
        return [
            UserFixtures::class,
        ];
    }
}
src/DataFixtures/UserFixtures.phpphp
namespace App\DataFixtures;

use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

class UserFixtures extends Fixture implements FixtureGroupInterface
{
    public function __construct(
        private readonly UserPasswordHasherInterface $passwordHasher,
    ) {}

    public function load(ObjectManager $manager): void
    {
        $users = [
            ['email' => 'admin@example.com', 'roles' => ['ROLE_ADMIN'], 'ref' => 'user-0'],
            ['email' => 'author@example.com', 'roles' => ['ROLE_AUTHOR'], 'ref' => 'user-1'],
            ['email' => 'user@example.com', 'roles' => ['ROLE_USER'], 'ref' => 'user-2'],
        ];

        foreach ($users as $userData) {
            $user = new User();
            $user->setEmail($userData['email']);
            $user->setRoles($userData['roles']);
            $user->setPassword($this->passwordHasher->hashPassword($user, 'password123'));

            $manager->persist($user);
            $this->addReference($userData['ref'], $user);
        }

        $manager->flush();
    }

    public static function getGroups(): array
    {
        return ['test', 'dev'];
    }
}
tests/Functional/ArticleControllerTest.phpphp
// Utilisation avec DAMADoctrineTestBundle pour reset automatique
namespace App\Tests\Functional;

use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class ArticleControllerTest extends WebTestCase
{
    use \DAMA\DoctrineTestBundle\Doctrine\DBAL\StaticDriver;

    public function testPublishedArticlesCount(): void
    {
        $client = static::createClient();

        $em = static::getContainer()->get(EntityManagerInterface::class);
        $count = $em->getRepository(Article::class)
            ->count(['status' => 'published']);

        $this->assertEquals(15, $count); // Selon les fixtures
    }
}
bash
# Chargement des fixtures
php bin/console doctrine:fixtures:load
php bin/console doctrine:fixtures:load --group=test
php bin/console doctrine:fixtures:load --env=test

DAMADoctrineTestBundle encapsule chaque test dans une transaction qui est rollback, évitant le rechargement des fixtures entre chaque test.

Architecture et Patterns Avancés

Question 17 : Comment implémenter le CQRS avec Symfony ?

CQRS (Command Query Responsibility Segregation) sépare les opérations de lecture des opérations d'écriture, permettant une optimisation indépendante.

src/Message/Command/CreateArticleCommand.phpphp
// Command : représente une intention de modification
namespace App\Message\Command;

final class CreateArticleCommand
{
    public function __construct(
        public readonly string $title,
        public readonly string $content,
        public readonly int $authorId,
        public readonly array $tagIds = [],
    ) {}
}
src/MessageHandler/Command/CreateArticleCommandHandler.phpphp
// CommandHandler : exécute la modification
namespace App\MessageHandler\Command;

use App\Entity\Article;
use App\Message\Command\CreateArticleCommand;
use App\Repository\TagRepository;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\String\Slugger\SluggerInterface;

#[AsMessageHandler]
final class CreateArticleCommandHandler
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly UserRepository $userRepository,
        private readonly TagRepository $tagRepository,
        private readonly SluggerInterface $slugger,
    ) {}

    public function __invoke(CreateArticleCommand $command): Article
    {
        $author = $this->userRepository->find($command->authorId)
            ?? throw new \InvalidArgumentException('Author not found');

        $article = new Article();
        $article->setTitle($command->title);
        $article->setSlug($this->slugger->slug($command->title)->lower());
        $article->setContent($command->content);
        $article->setAuthor($author);

        foreach ($command->tagIds as $tagId) {
            $tag = $this->tagRepository->find($tagId);
            if ($tag) {
                $article->addTag($tag);
            }
        }

        $this->entityManager->persist($article);
        $this->entityManager->flush();

        return $article;
    }
}
src/Message/Query/GetArticleBySlugQuery.phpphp
// Query : représente une demande de lecture
namespace App\Message\Query;

final class GetArticleBySlugQuery
{
    public function __construct(
        public readonly string $slug,
        public readonly bool $withComments = false,
    ) {}
}
src/MessageHandler/Query/GetArticleBySlugQueryHandler.phpphp
// QueryHandler : récupère les données (peut utiliser un read model optimisé)
namespace App\MessageHandler\Query;

use App\DTO\ArticleDTO;
use App\Message\Query\GetArticleBySlugQuery;
use App\Repository\ArticleRepository;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class GetArticleBySlugQueryHandler
{
    public function __construct(
        private readonly ArticleRepository $repository,
    ) {}

    public function __invoke(GetArticleBySlugQuery $query): ?ArticleDTO
    {
        $qb = $this->repository->createQueryBuilder('a')
            ->select('a', 'u')
            ->leftJoin('a.author', 'u')
            ->where('a.slug = :slug')
            ->setParameter('slug', $query->slug);

        if ($query->withComments) {
            $qb->addSelect('c')
               ->leftJoin('a.comments', 'c');
        }

        $article = $qb->getQuery()->getOneOrNullResult();

        return $article ? ArticleDTO::fromEntity($article) : null;
    }
}
src/Controller/ArticleController.phpphp
// Utilisation des Command et Query Bus
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\HandledStamp;

class ArticleController extends AbstractController
{
    public function __construct(
        private readonly MessageBusInterface $commandBus,
        private readonly MessageBusInterface $queryBus,
    ) {}

    #[Route('/articles', methods: ['POST'])]
    public function create(Request $request): Response
    {
        $data = json_decode($request->getContent(), true);

        $envelope = $this->commandBus->dispatch(new CreateArticleCommand(
            title: $data['title'],
            content: $data['content'],
            authorId: $this->getUser()->getId(),
        ));

        $article = $envelope->last(HandledStamp::class)->getResult();

        return $this->json($article, 201);
    }

    #[Route('/articles/{slug}', methods: ['GET'])]
    public function show(string $slug): Response
    {
        $envelope = $this->queryBus->dispatch(new GetArticleBySlugQuery(
            slug: $slug,
            withComments: true,
        ));

        $article = $envelope->last(HandledStamp::class)->getResult();

        return $this->json($article);
    }
}

CQRS permet d'optimiser séparément les lectures (caching, projections) et les écritures (validation, events).

Question 18 : Comment implémenter le Repository Pattern correctement dans Symfony ?

Le Repository Pattern dans Symfony est déjà présent via Doctrine, mais peut être enrichi avec des interfaces et des méthodes métier.

src/Repository/Contract/ArticleRepositoryInterface.phpphp
// Interface pour découplage et testabilité
namespace App\Repository\Contract;

use App\Entity\Article;
use Doctrine\Common\Collections\Collection;

interface ArticleRepositoryInterface
{
    public function find(int $id): ?Article;
    public function findBySlug(string $slug): ?Article;
    public function findPublished(int $limit = 20, int $offset = 0): array;
    public function findByAuthor(int $authorId): array;
    public function save(Article $article, bool $flush = false): void;
    public function remove(Article $article, bool $flush = false): void;
}
src/Repository/ArticleRepository.phpphp
// Implémentation Doctrine du repository
namespace App\Repository;

use App\Entity\Article;
use App\Repository\Contract\ArticleRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

class ArticleRepository extends ServiceEntityRepository implements ArticleRepositoryInterface
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Article::class);
    }

    public function findBySlug(string $slug): ?Article
    {
        return $this->createQueryBuilder('a')
            ->addSelect('u', 't')
            ->leftJoin('a.author', 'u')
            ->leftJoin('a.tags', 't')
            ->where('a.slug = :slug')
            ->setParameter('slug', $slug)
            ->getQuery()
            ->getOneOrNullResult();
    }

    public function findPublished(int $limit = 20, int $offset = 0): array
    {
        return $this->createQueryBuilder('a')
            ->addSelect('u')
            ->leftJoin('a.author', 'u')
            ->where('a.status = :status')
            ->setParameter('status', 'published')
            ->orderBy('a.publishedAt', 'DESC')
            ->setMaxResults($limit)
            ->setFirstResult($offset)
            ->getQuery()
            ->getResult();
    }

    public function findByAuthor(int $authorId): array
    {
        return $this->createQueryBuilder('a')
            ->where('a.author = :authorId')
            ->setParameter('authorId', $authorId)
            ->orderBy('a.createdAt', 'DESC')
            ->getQuery()
            ->getResult();
    }

    public function save(Article $article, bool $flush = false): void
    {
        $this->getEntityManager()->persist($article);

        if ($flush) {
            $this->getEntityManager()->flush();
        }
    }

    public function remove(Article $article, bool $flush = false): void
    {
        $this->getEntityManager()->remove($article);

        if ($flush) {
            $this->getEntityManager()->flush();
        }
    }

    // Méthodes de requête complexes
    public function findPopularByCategory(int $categoryId, int $limit = 5): array
    {
        return $this->createQueryBuilder('a')
            ->addSelect('u')
            ->leftJoin('a.author', 'u')
            ->where('a.category = :categoryId')
            ->andWhere('a.status = :status')
            ->setParameter('categoryId', $categoryId)
            ->setParameter('status', 'published')
            ->orderBy('a.viewCount', 'DESC')
            ->setMaxResults($limit)
            ->getQuery()
            ->getResult();
    }
}
yaml
# config/services.yaml
# Binding de l'interface à l'implémentation
services:
    App\Repository\Contract\ArticleRepositoryInterface:
        alias: App\Repository\ArticleRepository

L'interface permet de créer des implémentations de test (InMemoryArticleRepository) ou de changer de source de données sans modifier le code métier.

Question 19 : Comment gérer la configuration et les environnements dans Symfony ?

Symfony utilise un système de configuration flexible avec support des variables d'environnement, des secrets, et des fichiers YAML par environnement.

yaml
# config/packages/framework.yaml
# Configuration par défaut
framework:
    secret: '%env(APP_SECRET)%'
    http_method_override: false
    handle_all_throwables: true

    session:
        handler_id: null
        cookie_secure: auto
        cookie_samesite: lax
        storage_factory_id: session.storage.factory.native

    php_errors:
        log: true
yaml
# config/packages/prod/framework.yaml
# Surcharge pour la production
framework:
    session:
        handler_id: '%env(REDIS_URL)%'
        cookie_secure: true

when@prod:
    framework:
        router:
            strict_requirements: null
config/secrets/prod/prod.decrypt.private.phpphp
// Gestion des secrets sensibles (chiffrés)
// php bin/console secrets:set DATABASE_URL --env=prod
// php bin/console secrets:list --reveal --env=prod
src/DependencyInjection/Configuration.phpphp
// Configuration personnalisée pour un bundle
namespace App\DependencyInjection;

use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;

class Configuration implements ConfigurationInterface
{
    public function getConfigTreeBuilder(): TreeBuilder
    {
        $treeBuilder = new TreeBuilder('my_app');

        $treeBuilder->getRootNode()
            ->children()
                ->scalarNode('api_key')
                    ->isRequired()
                    ->cannotBeEmpty()
                ->end()
                ->integerNode('cache_ttl')
                    ->defaultValue(3600)
                    ->min(0)
                ->end()
                ->arrayNode('features')
                    ->addDefaultsIfNotSet()
                    ->children()
                        ->booleanNode('dark_mode')->defaultTrue()->end()
                        ->booleanNode('beta_features')->defaultFalse()->end()
                    ->end()
                ->end()
            ->end();

        return $treeBuilder;
    }
}
src/Service/ConfigurableService.phpphp
// Injection de configuration
namespace App\Service;

use Symfony\Component\DependencyInjection\Attribute\Autowire;

class ConfigurableService
{
    public function __construct(
        #[Autowire('%env(API_KEY)%')]
        private readonly string $apiKey,

        #[Autowire('%kernel.project_dir%')]
        private readonly string $projectDir,

        #[Autowire('%my_app.cache_ttl%')]
        private readonly int $cacheTtl,
    ) {}
}

Les secrets Symfony sont chiffrés et versionnés. Utiliser %env(...)% pour les variables runtime, les paramètres pour les valeurs statiques.

Performance et Production

Question 20 : Comment optimiser les performances d'une application Symfony ?

L'optimisation couvre plusieurs niveaux : opcache, configuration, cache applicatif, et requêtes Doctrine.

config/packages/prod/doctrine.yamlphp
# Configuration Doctrine optimisée pour la production
doctrine:
    orm:
        auto_generate_proxy_classes: false
        metadata_cache_driver:
            type: pool
            pool: doctrine.system_cache_pool
        query_cache_driver:
            type: pool
            pool: doctrine.system_cache_pool
        result_cache_driver:
            type: pool
            pool: doctrine.result_cache_pool
yaml
# config/packages/cache.yaml
# Configuration du cache avec Redis
framework:
    cache:
        app: cache.adapter.redis
        system: cache.adapter.system

        pools:
            doctrine.result_cache_pool:
                adapter: cache.adapter.redis
                default_lifetime: 3600
            doctrine.system_cache_pool:
                adapter: cache.adapter.system

services:
    Redis:
        class: Redis
        calls:
            - connect:
                - '%env(REDIS_HOST)%'
                - '%env(int:REDIS_PORT)%'

    Symfony\Component\Cache\Adapter\RedisAdapter:
        arguments:
            - '@Redis'
src/Repository/ArticleRepository.phpphp
// Utilisation du cache de résultat Doctrine
public function findPopularCached(): array
{
    return $this->createQueryBuilder('a')
        ->addSelect('u')
        ->leftJoin('a.author', 'u')
        ->where('a.status = :status')
        ->setParameter('status', 'published')
        ->orderBy('a.viewCount', 'DESC')
        ->setMaxResults(10)
        ->getQuery()
        ->enableResultCache(3600, 'popular_articles')  // Cache 1h
        ->getResult();
}
bash
# Commandes d'optimisation production
php bin/console cache:clear --env=prod
php bin/console cache:warmup --env=prod
php bin/console doctrine:cache:clear-metadata --env=prod
php bin/console doctrine:cache:clear-query --env=prod

# Génération des proxies Doctrine
php bin/console doctrine:proxy:create-proxy-classes

# Compilation de l'autoloader optimisé
composer install --no-dev --optimize-autoloader --classmap-authoritative

OPcache doit être activé en production avec des paramètres optimaux. Le warmup génère le cache du container et du router.

Question 21 : Comment configurer le logging et le monitoring dans Symfony ?

Un logging structuré et un monitoring approprié sont essentiels pour diagnostiquer les problèmes en production.

yaml
# config/packages/prod/monolog.yaml
monolog:
    handlers:
        main:
            type: fingers_crossed
            action_level: error
            handler: nested
            excluded_http_codes: [404, 405]
            buffer_size: 50

        nested:
            type: stream
            path: '%kernel.logs_dir%/%kernel.environment%.log'
            level: debug
            formatter: monolog.formatter.json

        console:
            type: console
            process_psr_3_messages: false
            channels: ['!event', '!doctrine']

        slack:
            type: slack
            token: '%env(SLACK_TOKEN)%'
            channel: '#alerts'
            level: critical
            bot_name: 'SymfonyBot'
src/Service/PaymentService.phpphp
// Logging structuré avec contexte
namespace App\Service;

use Psr\Log\LoggerInterface;

class PaymentService
{
    public function __construct(
        private readonly LoggerInterface $logger,
    ) {}

    public function processPayment(Order $order): bool
    {
        $this->logger->info('Payment processing started', [
            'order_id' => $order->getId(),
            'amount' => $order->getTotal(),
            'currency' => $order->getCurrency(),
            'user_id' => $order->getUser()->getId(),
        ]);

        try {
            $result = $this->gateway->charge($order);

            $this->logger->info('Payment successful', [
                'order_id' => $order->getId(),
                'transaction_id' => $result->getTransactionId(),
            ]);

            return true;
        } catch (\Exception $e) {
            $this->logger->error('Payment failed', [
                'order_id' => $order->getId(),
                'error' => $e->getMessage(),
                'exception' => $e,
            ]);

            return false;
        }
    }
}
src/EventSubscriber/RequestLoggerSubscriber.phpphp
// Logging des requêtes HTTP
namespace App\EventSubscriber;

use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\KernelEvents;

class RequestLoggerSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private readonly LoggerInterface $logger,
    ) {}

    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::TERMINATE => 'onKernelTerminate',
        ];
    }

    public function onKernelTerminate(TerminateEvent $event): void
    {
        $request = $event->getRequest();
        $response = $event->getResponse();

        $this->logger->info('Request completed', [
            'method' => $request->getMethod(),
            'uri' => $request->getRequestUri(),
            'status' => $response->getStatusCode(),
            'duration_ms' => round((microtime(true) - $request->server->get('REQUEST_TIME_FLOAT')) * 1000),
            'memory_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 2),
        ]);
    }
}

Le format JSON facilite l'ingestion par les outils de monitoring (ELK, Datadog). Les channels permettent de filtrer par type de log.

Question 22 : Comment déployer une application Symfony en production ?

Un déploiement Symfony robuste combine préparation du build, migrations sécurisées, et basculement atomique.

bash
#!/bin/bash
# deploy.sh - Script de déploiement
set -e

RELEASE_DIR="/var/www/releases/$(date +%Y%m%d%H%M%S)"
SHARED_DIR="/var/www/shared"
CURRENT_LINK="/var/www/current"

echo "Creating release directory..."
mkdir -p $RELEASE_DIR

echo "Cloning repository..."
git clone --depth 1 --branch main git@github.com:org/repo.git $RELEASE_DIR

echo "Installing dependencies..."
cd $RELEASE_DIR
composer install --no-dev --optimize-autoloader --classmap-authoritative

echo "Linking shared files..."
ln -sf $SHARED_DIR/.env.local $RELEASE_DIR/.env.local
ln -sf $SHARED_DIR/var/log $RELEASE_DIR/var/log
ln -sf $SHARED_DIR/public/uploads $RELEASE_DIR/public/uploads

echo "Running migrations..."
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration

echo "Warming up cache..."
php bin/console cache:clear --env=prod --no-debug
php bin/console cache:warmup --env=prod --no-debug

echo "Setting permissions..."
chown -R www-data:www-data $RELEASE_DIR

echo "Switching to new release..."
ln -sfn $RELEASE_DIR $CURRENT_LINK

echo "Restarting PHP-FPM..."
sudo systemctl reload php8.3-fpm

echo "Restarting Messenger workers..."
php bin/console messenger:stop-workers

echo "Cleaning old releases..."
ls -dt /var/www/releases/*/ | tail -n +6 | xargs rm -rf

echo "Deployment complete!"
yaml
# .github/workflows/deploy.yml
# Déploiement CI/CD avec GitHub Actions
name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: intl, pdo_pgsql, redis

      - name: Install dependencies
        run: composer install --no-dev --optimize-autoloader

      - name: Run tests
        run: php bin/phpunit

      - name: Deploy to production
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.PROD_USER }}
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            cd /var/www/app
            ./deploy.sh

Le déploiement atomique via symlinks permet un rollback instantané. Les workers Messenger doivent être redémarrés pour charger le nouveau code.

API Platform et REST

Question 23 : Comment construire une API REST avec API Platform ?

API Platform est la solution standard pour créer des APIs REST et GraphQL avec Symfony, offrant auto-documentation et standards HTTP.

src/Entity/Article.phpphp
// Configuration d'une ressource API Platform
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\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity(repositoryClass: ArticleRepository::class)]
#[ApiResource(
    operations: [
        new GetCollection(
            normalizationContext: ['groups' => ['article:list']],
        ),
        new Get(
            normalizationContext: ['groups' => ['article:read']],
        ),
        new Post(
            security: "is_granted('ROLE_AUTHOR')",
            denormalizationContext: ['groups' => ['article:write']],
        ),
        new Put(
            security: "is_granted('ARTICLE_EDIT', object)",
        ),
        new Patch(
            security: "is_granted('ARTICLE_EDIT', object)",
        ),
        new Delete(
            security: "is_granted('ARTICLE_DELETE', object)",
        ),
    ],
    order: ['publishedAt' => 'DESC'],
    paginationItemsPerPage: 20,
)]
class Article
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[Groups(['article:list', 'article:read'])]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Assert\NotBlank]
    #[Assert\Length(min: 10, max: 255)]
    #[Groups(['article:list', 'article:read', 'article:write'])]
    private ?string $title = null;

    #[ORM\Column(type: 'text')]
    #[Assert\NotBlank]
    #[Groups(['article:read', 'article:write'])]
    private ?string $content = null;

    #[ORM\ManyToOne(inversedBy: 'articles')]
    #[ORM\JoinColumn(nullable: false)]
    #[Groups(['article:list', 'article:read'])]
    private ?User $author = null;

    #[ORM\Column(nullable: true)]
    #[Groups(['article:list', 'article:read'])]
    private ?\DateTimeImmutable $publishedAt = null;
}
src/State/ArticleProcessor.phpphp
// Processor personnalisé pour la logique métier
namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Article;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\String\Slugger\SluggerInterface;

final class ArticleProcessor implements ProcessorInterface
{
    public function __construct(
        private readonly ProcessorInterface $persistProcessor,
        private readonly Security $security,
        private readonly SluggerInterface $slugger,
    ) {}

    public function process(
        mixed $data,
        Operation $operation,
        array $uriVariables = [],
        array $context = []
    ): Article {
        if ($data instanceof Article && !$data->getId()) {
            // Nouvel article : définir l'auteur et le slug
            $data->setAuthor($this->security->getUser());
            $data->setSlug($this->slugger->slug($data->getTitle())->lower());
        }

        return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
    }
}
yaml
# config/packages/api_platform.yaml
api_platform:
    title: 'Mon API'
    version: '1.0.0'
    formats:
        jsonld: ['application/ld+json']
        json: ['application/json']
    docs_formats:
        jsonld: ['application/ld+json']
        jsonopenapi: ['application/vnd.openapi+json']
        html: ['text/html']

    defaults:
        pagination_items_per_page: 20
        pagination_client_items_per_page: true
        pagination_maximum_items_per_page: 100

    swagger:
        versions: [3]

API Platform génère automatiquement la documentation OpenAPI et fournit les filtres, la pagination, et la validation.

Question 24 : Comment personnaliser les opérations API Platform ?

API Platform permet de créer des opérations personnalisées avec des controllers ou des State Providers/Processors.

src/Entity/Article.phpphp
// Opérations personnalisées
#[ApiResource(
    operations: [
        // Opération standard
        new GetCollection(),
        new Get(),

        // Opération personnalisée avec controller
        new Post(
            uriTemplate: '/articles/{id}/publish',
            controller: PublishArticleController::class,
            openapi: new Model\Operation(
                summary: 'Publie un article',
                description: 'Change le statut de l\'article en "published"',
            ),
            security: "is_granted('ARTICLE_EDIT', object)",
        ),

        // Opération avec State Provider personnalisé
        new GetCollection(
            uriTemplate: '/articles/trending',
            provider: TrendingArticlesProvider::class,
            openapiContext: ['summary' => 'Articles tendance'],
        ),
    ],
)]
class Article
{
    // ...
}
src/Controller/PublishArticleController.phpphp
// Controller pour opération personnalisée
namespace App\Controller;

use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpKernel\Attribute\AsController;

#[AsController]
class PublishArticleController extends AbstractController
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
    ) {}

    public function __invoke(Article $article): Article
    {
        if ($article->getStatus() === 'published') {
            throw $this->createNotFoundException('Article already published');
        }

        $article->setStatus('published');
        $article->setPublishedAt(new \DateTimeImmutable());

        $this->entityManager->flush();

        return $article;
    }
}
src/State/TrendingArticlesProvider.phpphp
// State Provider pour logique de lecture personnalisée
namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Repository\ArticleRepository;

final class TrendingArticlesProvider implements ProviderInterface
{
    public function __construct(
        private readonly ArticleRepository $repository,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
    {
        // Logique personnalisée pour les articles tendance
        return $this->repository->createQueryBuilder('a')
            ->where('a.status = :status')
            ->andWhere('a.publishedAt > :date')
            ->setParameter('status', 'published')
            ->setParameter('date', new \DateTimeImmutable('-7 days'))
            ->orderBy('a.viewCount', 'DESC')
            ->setMaxResults(10)
            ->getQuery()
            ->getResult();
    }
}

Les State Providers gèrent la lecture, les State Processors gèrent l'écriture. Les controllers restent disponibles pour les cas complexes.

Question 25 : Comment gérer les migrations de base de données en production ?

Les migrations Doctrine doivent être conçues pour s'exécuter sans downtime et permettre un rollback facile.

migrations/Version20260202100000.phpphp
// Migration safe : ajout de colonne nullable
declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260202100000 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Add role column to users table (nullable first)';
    }

    public function up(Schema $schema): void
    {
        // Étape 1 : Ajouter la colonne nullable
        $this->addSql('ALTER TABLE users ADD role VARCHAR(50) DEFAULT NULL');

        // Création d'index CONCURRENTLY (PostgreSQL - non bloquant)
        $this->addSql('CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_role ON users (role)');
    }

    public function down(Schema $schema): void
    {
        $this->addSql('DROP INDEX IF EXISTS idx_users_role');
        $this->addSql('ALTER TABLE users DROP COLUMN role');
    }
}
migrations/Version20260202100001.phpphp
// Migration de données (séparée)
final class Version20260202100001 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Populate role column with default value';
    }

    public function up(Schema $schema): void
    {
        // Migration par lots pour les grandes tables
        $this->addSql("UPDATE users SET role = 'ROLE_USER' WHERE role IS NULL");
    }

    public function down(Schema $schema): void
    {
        // Pas de rollback nécessaire pour les données
    }
}
migrations/Version20260202100002.phpphp
// Migration finale : rendre la colonne non-nullable
final class Version20260202100002 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Make role column NOT NULL';
    }

    public function up(Schema $schema): void
    {
        // Vérification préalable
        $count = $this->connection->fetchOne('SELECT COUNT(*) FROM users WHERE role IS NULL');
        if ($count > 0) {
            throw new \RuntimeException("$count users still have NULL role");
        }

        $this->addSql('ALTER TABLE users ALTER COLUMN role SET NOT NULL');
        $this->addSql("ALTER TABLE users ALTER COLUMN role SET DEFAULT 'ROLE_USER'");
    }

    public function down(Schema $schema): void
    {
        $this->addSql('ALTER TABLE users ALTER COLUMN role DROP NOT NULL');
        $this->addSql('ALTER TABLE users ALTER COLUMN role DROP DEFAULT');
    }
}
bash
# Commandes de gestion des migrations
php bin/console doctrine:migrations:status          # État des migrations
php bin/console doctrine:migrations:migrate         # Exécuter les migrations
php bin/console doctrine:migrations:migrate prev    # Rollback dernière migration
php bin/console doctrine:migrations:diff            # Générer migration depuis schéma
php bin/console doctrine:migrations:execute --down  # Rollback spécifique

La stratégie expand-contract (3 déploiements) garantit un déploiement sans downtime : ajouter nullable → migrer données → contraindre.

Conclusion

Ces 25 questions couvrent l'essentiel des entretiens Symfony, des fondamentaux du Service Container aux patterns de production avancés.

Checklist de préparation :

  • ✅ Service Container et injection de dépendances
  • ✅ Doctrine ORM : relations, requêtes, filtres
  • ✅ Sécurité : authentification, voters, JWT
  • ✅ Formulaires et validation personnalisée
  • ✅ Messenger : traitement asynchrone et gestion des erreurs
  • ✅ Testing : unitaire, fonctionnel, fixtures
  • ✅ Architecture : CQRS, Repository Pattern
  • ✅ API Platform : REST, opérations personnalisées
  • ✅ Production : performance, logging, déploiement
Aller plus loin

Chaque question mérite un approfondissement avec la documentation officielle de Symfony. Les recruteurs valorisent les candidats qui comprennent les choix architecturaux du framework et savent justifier leurs décisions techniques.

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Tags

#symfony
#php
#interview
#doctrine
#entretien technique

Partager

Articles similaires