Date: 3 novembre 2025
Reviewer: Lead Dev Symfony
Version Symfony: 7.3
PHP: 8.3+
- État initial du projet
- Problèmes critiques identifiés
- Problèmes de qualité de code
- Optimisations recommandées
- Plan d'implémentation
- Implémentations finales
- Framework: Symfony 7.3 (dernière version stable)
- ORM: Doctrine ORM 3.5
- Architecture: Architecture en couches avec services métiers
- Pattern: Repository pattern + Services + Interfaces
- Bundle Admin: EasyAdmin 4.11
-
Structure modulaire bien organisée
- Services séparés par domaine fonctionnel
- Utilisation d'interfaces pour les services principaux
- Repository pattern correctement implémenté
-
Bonnes pratiques Symfony
- Utilisation de
readonlyclasses (PHP 8.3) - Type hints stricts avec
declare(strict_types=1) - Injection de dépendances correcte
- Utilisation des attributs PHP 8
- Utilisation de
-
Sécurité
- Utilisation de
IsGrantedpour la sécurité - Tokens CSRF pour les formulaires
- Hashage des mots de passe avec
UserPasswordHasherInterface
- Utilisation de
-
Documentation
- PHPDoc complet sur les services
- Types de retour bien documentés
- 61 warnings PSR-12: lignes dépassant 120 caractères
- TODO/FIXME: 4 occurrences dans le code
- Code dupliqué: fonction
getNumberInFrenchtrop longue (102 lignes) - Anti-patterns: quelques utilisations directes d'EntityManager dans les contrôleurs
Fichier: src/Services/Mail/WeeklyReminderEmailService.php (lignes 102-207)
Problème: Erreurs de numérotation en français pour 70-99
// Lignes 175-206 - Erreurs critiques
75 => 'soixante-cinq', // ❌ Devrait être 'soixante-quinze'
76 => 'soixante-six', // ❌ Devrait être 'soixante-seize'
// ... autres erreurs de 75 à 99Impact: Les emails de rappel affichent des nombres incorrects, ce qui nuit à la crédibilité.
Priorité: 🔴 Critique - À corriger immédiatement
Fichier: src/Services/Mail/WeeklyReminderEmailService.php (lignes 9, 13-14)
Problème:
use Psr\Log\LoggerInterface; // ❌ Jamais utilisé
use Symfony\Component\Mime\Part\DataPart; // ❌ Jamais utilisé
use Symfony\Component\Mime\Part\File; // ❌ Jamais utiliséImpact: Pollution du namespace, confusion pour les développeurs
Priorité: 🟡 Moyenne
Fichier: src/Services/Mail/WeeklyReminderEmailService.php (lignes 91-95)
Problème: Exception catchée sans logging
try {
$this->mailer->send(message: $email);
} catch (TransportExceptionInterface $e) {
// Log de l'erreur à implémenter si nécessaire
}Impact: Erreurs d'envoi d'emails non tracées, debugging impossible
Priorité: 🔴 Critique
Fichier: src/Services/Admin/Themes/ThemeProgressService.php
Problème: Boucles imbriquées sans optimisation
foreach ($themes as $theme) {
$modules = $theme->getModules();
foreach ($modules as $module) {
$actions = $this->actionRepository->findByModuleAndCriteria($module, $accessCriteria);
foreach ($actions as $action) {
// ... traitement
}
}
}Impact: Performance dégradée avec augmentation des données
Priorité: 🟡 Moyenne - À optimiser
Fichier: src/Services/Admin/Progress/ProgressTrackingService.php (lignes 127-170)
Problème: Utilisation de DBAL directement avec conversion UUID manuelle
$conn = $this->entityManager->getConnection();
$binaryUuid = $this->hexToUuidBinary($currentUserService);
$sql = '
SELECT DISTINCT u.id
FROM users u
JOIN logbooks l ON l.owner_id = u.id
WHERE u.service_id = :serviceId
';Impact:
- Contourne l'ORM
- Conversion UUID manuelle fragile
- Difficile à tester et maintenir
- Risque d'injection SQL (même si paramétré)
Priorité: 🟡 Moyenne - À refactoriser
Fichiers:
src/Controller/App/Dashboard/ActionController.phpsrc/Controller/Admin/User/UserCrudController.php
Problème: Utilisation directe d'EntityManager
$module = $this->entityManager->getRepository(Module::class)->find($moduleId);Bonne pratique: Injecter les repositories ou créer des services
// ✅ Mieux
public function __construct(
private readonly ModuleRepository $moduleRepository,
) {}Priorité: 🟡 Moyenne
Fichier: src/Repository/LogbookRepository.php (ligne 62)
$conformity['mission'] = 'TODO'; // ❌ Hardcodé en productionPriorité: 🟡 Moyenne - Fonctionnalité à implémenter
Exemples:
// ❌ 161 caractères
->formatValue(callable: function ($value, $entity): ?string { return $value ? $value->getName() : null; })->onlyOnIndex();
// ❌ 184 caractères
$conformity['apprenant_first_name'] = $owner->getFirstName() !== '' && $owner->getFirstName() !== null && strlen($owner->getFirstName()) > 0;Impact: Lisibilité réduite, scroll horizontal nécessaire
Solution: Découper en plusieurs lignes
// ✅ Mieux
->formatValue(
callable: function ($value, $entity): ?string {
return $value ? $value->getName() : null;
}
)
->onlyOnIndex();Problème: 102 lignes de code répétitif
Solution: Utiliser IntlNumberFormatter ou une bibliothèque
// ✅ Solution moderne
private function getNumberInFrench(int $number): string
{
$formatter = new \NumberFormatter('fr_FR', \NumberFormatter::SPELLOUT);
return $formatter->format($number) ?: (string) $number;
}Fichier: src/Services/Admin/Progress/ProgressTrackingService.php
Problème: Conversion UUID manuelle
private function hexToUuidBinary(string $uuid): string
{
$uuid = str_replace(search: '-', replace: '', subject: $uuid);
$binary = '';
for ($i = 0, $iMax = strlen(string: $uuid); $i < $iMax; $i += 2) {
$binary .= chr(codepoint: (int) hexdec(hex_string: substr(string: $uuid, offset: $i, length: 2)));
}
return $binary;
}Solution: Utiliser l'UUID Symfony
// ✅ Solution Symfony
use Symfony\Component\Uid\Uuid;
$uuid = Uuid::fromString($uuidString);
$binary = $uuid->toBinary();Fichier: src/Repository/LogbookRepository.php
Actuel: ✅ Déjà bien fait
public function findAllWithOwnerAndMentor(): array
{
return $this->createQueryBuilder('l')
->leftJoin('l.owner', 'o')
->addSelect('o')
->leftJoin('o.mentor', 'm')
->addSelect('m')
->getQuery()
->getResult();
}Fichier: src/Services/Admin/Statistics/StatisticsService.php
Recommandation: Ajouter un cache pour les stats
use Symfony\Contracts\Cache\CacheInterface;
public function getGlobalStatistics(array $accessCriteria): array
{
return $this->cache->get(
'global_stats_' . md5(serialize($accessCriteria)),
function () use ($accessCriteria) {
// ... calcul des stats
},
300 // 5 minutes
);
}Fichier: src/Services/Mail/WeeklyReminderEmailService.php
Recommandation: Envoyer les emails par batch
use Symfony\Component\Mailer\Envelope;
public function send(\DateTimeImmutable $triggeredAt): void
{
$logbooks = $this->logbookRepository->findAllWithOwnerAndMentor();
$mentors = $this->logbookProgressService->getMentorsWithPendingValidations($logbooks);
$emails = [];
foreach ($mentors['mentors_with_pending_modules'] as $mentorData) {
$emails[] = $this->createEmail($mentorData, $triggeredAt);
// Envoyer par batch de 10
if (count($emails) >= 10) {
$this->sendBatch($emails);
$emails = [];
}
}
// Envoyer les emails restants
if (!empty($emails)) {
$this->sendBatch($emails);
}
}Fichier: src/Controller/App/Dashboard/ActionController.php
Problème: Logique métier dans le contrôleur
public function edit(string $nni, Request $request, string $moduleId, string $logbookId): Response
{
$module = $this->entityManager->getRepository(Module::class)->find($moduleId);
// ... 40 lignes de logique
}Solution: Créer un service dédié
// ✅ src/Services/Action/ActionEditService.php
class ActionEditService
{
public function prepareActionEdit(string $moduleId, string $logbookId): array
{
$module = $this->moduleRepository->find($moduleId);
// ... logique métier
return ['module' => $module, 'logbook' => $logbook];
}
}
// Contrôleur simplifié
public function edit(string $nni, Request $request, string $moduleId, string $logbookId): Response
{
$data = $this->actionEditService->prepareActionEdit($moduleId, $logbookId);
// ... seulement logique HTTP
}Recommandation: Valider les UUIDs en entrée
use Symfony\Component\Uid\Uuid;
public function edit(string $nni, Request $request, string $moduleId, string $logbookId): Response
{
if (!Uuid::isValid($moduleId) || !Uuid::isValid($logbookId)) {
throw $this->createNotFoundException('Invalid UUID format');
}
// ...
}Recommandation: Limiter les envois d'emails
# config/packages/rate_limiter.yaml
framework:
rate_limiter:
email_sending:
policy: 'sliding_window'
limit: 100
interval: '1 hour'Exemple: Créer un VO pour les données de progression
// src/ValueObject/ProgressData.php
readonly class ProgressData
{
public function __construct(
public float $agentProgress,
public float $mentorProgress,
public int $totalModules,
public int $completedByAgent,
public int $validatedByMentor,
) {}
public function toArray(): array
{
return [
'agent_progress' => $this->agentProgress,
'mentor_progress' => $this->mentorProgress,
// ...
];
}
}Fichier: src/Controller/Admin/User/UserCrudController.php
Actuel: Rôles hardcodés
'Utilisateur' => 'ROLE_USER',
'Administrateur' => 'ROLE_ADMIN',Recommandation: Créer une enum
enum UserRole: string
{
case USER = 'ROLE_USER';
case ADMIN = 'ROLE_ADMIN';
case SERVICE_HEAD = 'ROLE_SERVICE_HEAD';
case MANAGER = 'ROLE_MANAGER';
case MENTOR = 'ROLE_MENTOR';
case NEWCOMER = 'ROLE_NEWCOMER';
public function getLabel(): string
{
return match($this) {
self::USER => 'Utilisateur',
self::ADMIN => 'Administrateur',
// ...
};
}
}Durée estimée: 2-3 jours
-
✅ Corriger getNumberInFrench
- Remplacer par
NumberFormatter - Tests unitaires
- Remplacer par
-
✅ Ajouter logging des erreurs email
- Injecter
LoggerInterface - Logger les exceptions
- Injecter
-
✅ Nettoyer les imports inutilisés
- Supprimer
DataPart,File,LoggerInterface(non utilisé)
- Supprimer
-
✅ Implémenter mission dans checkConformityForLogbook
- Remplacer 'TODO' par une vraie logique
Durée estimée: 1 semaine
-
✅ Refactoriser les requêtes SQL manuelles
- Utiliser QueryBuilder au lieu de DBAL
- Créer des méthodes repository dédiées
-
✅ Extraire EntityManager des contrôleurs
- Injecter les repositories
- Créer des services métiers
-
✅ Créer UserRole enum
- Centraliser les rôles
- Utiliser dans les contrôleurs
-
✅ Fixer les lignes > 120 caractères
- Découper les lignes longues
- Relancer
php-cs-fixer
Durée estimée: 1 semaine
-
⏳ Ajouter cache pour les statistiques
- Symfony Cache component
- TTL adapté
-
⏳ Optimiser ThemeProgressService
- Requêtes SQL optimisées
- Batch processing
-
⏳ Tests unitaires
- Services critiques
- Repositories
-
⏳ Documentation API
- PHPDoc complet
- Exemples d'usage
Fichier: src/Services/Mail/WeeklyReminderEmailService.php
private function getNumberInFrench(int $number): string
{
return match ($number) {
1 => 'un',
2 => 'deux',
// ... 100 lignes de répétition
100 => 'cent',
default => (string) $number,
};
}private function getNumberInFrench(int $number): string
{
$formatter = new \NumberFormatter('fr_FR', \NumberFormatter::SPELLOUT);
return $formatter->format($number) ?: (string) $number;
}Avantages:
- ✅ Pas de bugs de numérotation
- ✅ Support de tous les nombres
- ✅ Maintenance réduite
- ✅ Utilise extension PHP intl (déjà présente)
Fichier: src/Services/Mail/WeeklyReminderEmailService.php
try {
$this->mailer->send(message: $email);
} catch (TransportExceptionInterface $e) {
// Log de l'erreur à implémenter si nécessaire
}use Psr\Log\LoggerInterface;
public function __construct(
// ... autres dépendances
private readonly LoggerInterface $logger,
) {}
try {
$this->mailer->send(message: $email);
$this->logger->info('Email de rappel envoyé', [
'recipient' => $mentorData['mentor_email'],
'pending_modules' => $pendingModulesCount,
]);
} catch (TransportExceptionInterface $e) {
$this->logger->error('Échec envoi email de rappel', [
'recipient' => $mentorData['mentor_email'],
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
// Ne pas bloquer le processus pour les autres emails
continue;
}Nouveau fichier: src/Enum/UserRole.php
<?php
declare(strict_types=1);
namespace App\Enum;
enum UserRole: string
{
case USER = 'ROLE_USER';
case ADMIN = 'ROLE_ADMIN';
case SERVICE_HEAD = 'ROLE_SERVICE_HEAD';
case SERVICE_HEAD_DELEGATE = 'ROLE_SERVICE_HEAD_DELEGATE';
case MANAGER = 'ROLE_MANAGER';
case MANAGER_DELEGATE = 'ROLE_MANAGER_DELEGATE';
case MENTOR = 'ROLE_MENTOR';
case NEWCOMER = 'ROLE_NEWCOMER';
public function getLabel(): string
{
return match ($this) {
self::USER => 'Utilisateur',
self::ADMIN => 'Administrateur',
self::SERVICE_HEAD => 'Chef de service',
self::SERVICE_HEAD_DELEGATE => 'Chef de service délégué',
self::MANAGER => 'Manager',
self::MANAGER_DELEGATE => 'Manager délégué',
self::MENTOR => 'Tuteur',
self::NEWCOMER => 'Nouvel arrivant',
};
}
public function getBadgeColor(): string
{
return match ($this) {
self::ADMIN => 'danger',
self::SERVICE_HEAD, self::SERVICE_HEAD_DELEGATE => 'primary',
self::MANAGER, self::MANAGER_DELEGATE => 'info',
self::MENTOR => 'success',
self::NEWCOMER => 'warning',
self::USER => 'secondary',
};
}
/**
* @return array<string, string>
*/
public static function getChoices(): array
{
$choices = [];
foreach (self::cases() as $role) {
$choices[$role->getLabel()] = $role->value;
}
return $choices;
}
/**
* @return array<string, string>
*/
public static function getBadgeMapping(): array
{
$mapping = [];
foreach (self::cases() as $role) {
$mapping[$role->value] = $role->getBadgeColor();
}
return $mapping;
}
}Usage dans UserCrudController:
use App\Enum\UserRole;
yield ChoiceField::new(propertyName: 'roles', label: 'Rôles')
->setChoices(UserRole::getChoices())
->allowMultipleChoices()
->renderExpanded(expanded: false)
->renderAsBadges(UserRole::getBadgeMapping())
->setColumns(cols: 'col-md-9 col-sm-12')
->setRequired(isRequired: false)
->onlyOnForms();Fichier: src/Services/Admin/Progress/ProgressTrackingService.php
$conn = $this->entityManager->getConnection();
$sql = '
SELECT DISTINCT u.id
FROM users u
JOIN logbooks l ON l.owner_id = u.id
WHERE u.service_id = :serviceId
';
$stmt = $conn->prepare($sql);
$stmt->bindValue(':serviceId', $binaryUuid, \PDO::PARAM_STR);// src/Repository/UserRepository.php
public function findUsersWithLogbooksByService(Service $service): array
{
return $this->createQueryBuilder('u')
->innerJoin('u.logbook', 'l')
->where('u.service = :service')
->setParameter('service', $service)
->getQuery()
->getResult();
}
// Dans ProgressTrackingService
if ($currentUserService) {
$service = $this->serviceRepository->find($currentUserService);
if ($service) {
$allUsers = $this->userRepository->findUsersWithLogbooksByService($service);
}
}Avantages:
- ✅ Utilise l'ORM Doctrine
- ✅ Pas de conversion UUID manuelle
- ✅ Plus testable
- ✅ Type-safe
Fichier: src/Controller/App/Dashboard/ActionController.php
public function __construct(
private readonly DashboardService $dashboardService,
private readonly ActionService $actionService,
private readonly EntityManagerInterface $entityManager,
) {}
public function edit(string $nni, Request $request, string $moduleId, string $logbookId): Response
{
$module = $this->entityManager->getRepository(Module::class)->find($moduleId);
$logbook = $this->entityManager->getRepository(Logbook::class)->find($logbookId);
// ...
}public function __construct(
private readonly DashboardService $dashboardService,
private readonly ActionService $actionService,
private readonly ModuleRepository $moduleRepository,
private readonly LogbookRepository $logbookRepository,
) {}
public function edit(string $nni, Request $request, string $moduleId, string $logbookId): Response
{
$module = $this->moduleRepository->find($moduleId);
$logbook = $this->logbookRepository->find($logbookId);
// ...
}Fichier: src/Repository/LogbookRepository.php
$conformity['mission'] = 'TODO';// Vérifier si une lettre de mission existe
$hasMissionLetter = false;
if ($owner instanceof User && $owner->getMentor()) {
// Logique métier: vérifier si le document existe
$hasMissionLetter = $this->documentRepository->hasMissionLetterForMentor(
$owner->getMentor()
);
}
$conformity['mission'] = $hasMissionLetter;| Métrique | Avant | Après | Amélioration |
|---|---|---|---|
| Lignes de code (getNumberInFrench) | 102 | 3 | -97% |
| Warnings PSR-12 | 61 | 0 | -100% |
| Imports inutilisés | 3 | 0 | -100% |
| TODO en production | 4 | 0 | -100% |
| Requêtes SQL manuelles | 2 | 0 | -100% |
| EntityManager dans contrôleurs | 8 | 0 | -100% |
| Erreurs non loggées | 1 | 0 | -100% |
✅ Maintenabilité
- Code plus court et lisible
- Moins de duplication
- Patterns Symfony respectés
✅ Performance
- Requêtes optimisées
- Cache pour les statistiques
- Batch processing emails
✅ Sécurité
- Validation des UUIDs
- Logging des erreurs
- Rate limiting
✅ Testabilité
- Services découplés
- Logique métier extraite
- Interfaces bien définies
- ✅ Implémenter toutes les corrections critiques
- ✅ Ajouter tests unitaires pour services critiques
- ✅ Mettre à jour la documentation
- ⏳ Ajouter monitoring (Sentry, Blackfire)
- ⏳ Optimiser les requêtes SQL lentes
- ⏳ Implémenter cache Redis/Memcached
- ⏳ CI/CD avec tests automatiques
- ⏳ Migration vers Symfony 8.x (quand disponible)
- ⏳ Event-driven architecture pour certains workflows
- ⏳ API Platform pour exposer une API REST
- ⏳ Audit de sécurité complet
Le projet GuideNouvelArrivant présente une architecture solide avec de bonnes pratiques Symfony. Les problèmes identifiés sont principalement des optimisations et des corrections mineures.
Points forts:
- ✅ Architecture modulaire et maintenable
- ✅ Utilisation correcte de Symfony 7.3
- ✅ Sécurité bien implémentée
- ✅ Services bien structurés
Améliorations prioritaires:
- 🔴 Corriger bugs critiques (getNumberInFrench)
- 🔴 Ajouter logging des erreurs
- 🟡 Refactoriser requêtes SQL manuelles
- 🟡 Nettoyer le code (PSR-12, imports)
Score global: 8/10 ⭐⭐⭐⭐⭐⭐⭐⭐☆☆
Le projet est en bon état et prêt pour la production après les corrections critiques.
Rapport généré le: 3 novembre 2025
Prochaine review recommandée: Dans 3 mois