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.

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.
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.
// 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']);
};// 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.
// 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;
}
}
}# 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.
// 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();
}
}// 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.
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.
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;
}
}#[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.
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.
// 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);
}
}// 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);
}
}# config/packages/doctrine.yaml
doctrine:
orm:
filters:
soft_delete:
class: App\Doctrine\Filter\SoftDeleteFilter
enabled: trueLes 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.
# 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 }// 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.
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());
}
}// 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
}
}
}{# 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.
# 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 heurenamespace 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
}
}// 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.
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'],
]);
}
}#[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.
// 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
}// 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();
}
}
}// 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;
}// 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.
// 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',
) {}
}// 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);
}
}# 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// 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.
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
);
}
}
}// 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,
};
}
}# 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 messageLa 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.
// 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],
];
}
}// 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.
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,
];
}
}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'];
}
}// 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
}
}# Chargement des fixtures
php bin/console doctrine:fixtures:load
php bin/console doctrine:fixtures:load --group=test
php bin/console doctrine:fixtures:load --env=testDAMADoctrineTestBundle 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.
// 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 = [],
) {}
}// 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;
}
}// 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,
) {}
}// 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;
}
}// 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.
// 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;
}// 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();
}
}# config/services.yaml
# Binding de l'interface à l'implémentation
services:
App\Repository\Contract\ArticleRepositoryInterface:
alias: App\Repository\ArticleRepositoryL'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.
# 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# 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// Gestion des secrets sensibles (chiffrés)
// php bin/console secrets:set DATABASE_URL --env=prod
// php bin/console secrets:list --reveal --env=prod// 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;
}
}// 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.
# 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# 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'// 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();
}# 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-authoritativeOPcache 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.
# 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'// 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;
}
}
}// 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.
#!/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!"# .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.shLe 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.
// 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;
}// 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);
}
}# 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.
// 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
{
// ...
}// 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;
}
}// 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.
// 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');
}
}// 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
}
}// 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');
}
}# 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écifiqueLa 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
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
Partager
Articles similaires

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

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

SQL pour les Data Analysts : fonctions de fenetrage, CTE et requetes avancees
Guide complet sur les fonctions de fenetrage SQL (window functions), les CTE et les requetes analytiques avancees. Syntaxe, exemples concrets et patterns utilises en entretien data analyst.