## LOT
Après une fonction cohérente : `php -l`, PHPUnit, PHPStan, `lint:container --resolve-env-vars`, `lint:yaml`, `lint:twig --show-deprecations`, `composer validate --strict`, `composer audit`, `doctrine:schema:validate` (mapping et sync), `doctrine:migrations:up-to-date` si base. Scan sécu : SQL concaténé, `$_GET`/`$_POST`, hasher MD5/SHA1, suppression en GET, `|raw`, formulaire POST sans CSRF, login sans limiteur, formulaire public sans rate limiter dans le contrôleur, log de secret, upload nu, `public/_maquette`. Chaque route contrôleur doit apparaître dans une méthode de test (`request`, `crawler` ou `assert`). Un commentaire qui cite l’URL ne compte pas. `access_control` avec ROLE_ exige les 3 cas **sur cette zone** (visiteur → connexion ou 401, connecté sans le rôle → 403, rôle requis → accès). Un 403 ailleurs ne compte pas. Pas 3 tests par URL de la zone. Entité sans fichier de migration = FAIL. S’il y a des écrans : attaques machine (XSS, CSRF, page interdite, IDOR non connecté) sur `local_url` ; injoignable = BLOCKED. Étienne, Paul et Antoine ne peuvent pas écrire PASS sans cette preuve.
Produire et contrôler une app Symfony 7.4.
v4.0 · première version
Symfo7 est un cadre personnel : CLI PHP, skill Olivia, huit agents. Ce n’est pas Symfony. La revue humaine reste obligatoire.
Comment ça marche
-
Besoin
Un cahier des charges unique. Nadia en fait un contrat métier.
-
Conception
Olivia propose 2 univers. GO A ou B. Puis le squelette Symfony 7.4.
-
Production et contrôles
Un agent à la fois. Portes qualité. Deux échecs sur le même défaut : stop.
Portes de l’usine
syntaxe · PHPUnit · PHPStan · CSRF · limiteur de connexion · 3 cas ROLE_ · scan sécu · attaques machine · accessibilité 390 / 820 / 1440
« En ligne » et « sûr en ligne » sont deux portes séparées. Horizon n’a pas « sûr en ligne ».
Détail technique
Huit agents et le pipeline
Olivia pilote. Elle ne code pas. L’équipe est figée à huit prénoms.
Olivia
Chef. Analyse, lots, DESIGN (2 univers, GO A ou B). Ne code pas.
Nadia
Contrat métier validé.
Étienne
Doctrine, entités, migrations, jeu de données.
Paul
Backend Symfony : contrôleurs, services, formulaires.
Chloé
Fondation visuelle puis habillage de lot. Twig, CSS, accessibilité.
Antoine
Contrôle du backend de Paul.
Inès
Contrôle des vues, WCAG, responsive 390 / 820 / 1440.
Diane
Audit sécurité.
Olivia → Nadia → Olivia (packs) → GO A ou B → création du projet → Chloé fondation → Étienne → Paul → Antoine → Chloé lot → Inès → Diane → Olivia.
Horizon Voyages
Le test : un workflow tenu, de la sécurité appliquée, un code relisible. Pas un produit client.
Le but n’était pas un back-office complet. L’admin gère séjours, réservations et messages. Les clients sont en lecture seule.
Sur la connexion, deux boutons « Tester en tant que client » et « Tester en tant qu’administrateur » existent pour un recruteur : un clic, sans mot de passe à recopier. POST + jeton CSRF. Ce n’est pas un oubli de sécurité.
Site test
Horizon Voyages
Agence fictive. Catalogue, compte, réservation sans paiement. Connexion test en un clic.
Ouvrir le site testCaptures du site
-
Accueil. Le bandeau dit que rien n’est vendu. -
Fiche séjour et demande de réservation. Aucune somme n’est encaissée. -
Tableau de bord admin, volontairement limité.
Extraits de code
QUALITY_POLICY vient de l’usine Symfo7. Les trois autres extraits viennent d’Horizon. Chaque bloc est continu. Le reste du fichier n’est pas recopié.
form_login:
login_path: app_connexion
check_path: app_connexion
enable_csrf: true
username_parameter: _username
password_parameter: _password
success_handler: App\Security\ConnexionSuccessHandler
login_throttling:
max_attempts: 5
interval: '1 minute'
logout:
path: app_logout
target: app_accueil
enable_csrf: true
csrf_parameter: _csrf_token
csrf_token_id: logout
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/mes-reservations, roles: ROLE_CLIENT }
- { path: ^/mon-compte, roles: ROLE_CLIENT }
public function creer(Utilisateur $client, int $dateDepartId, int $nombrePersonnes): Reservation
{
if ($nombrePersonnes < 1) {
throw new ExceptionMetier('Indiquez un nombre de personnes.');
}
$dateDepart = $this->dateDepartRepository->find($dateDepartId);
if (!$dateDepart instanceof DateDepart) {
throw new ExceptionMetier('Cette date de départ n’existe pas.');
}
$sejour = $dateDepart->getSejour();
if (!$sejour->isVisible()) {
throw new ExceptionMetier('Ce séjour n’est plus ouvert à la réservation.');
}
$aujourdHui = new DateTimeImmutable('today');
if ($dateDepart->getDepartLe() < $aujourdHui) {
throw new ExceptionMetier('Cette date de départ est déjà passée.');
}
if ($dateDepart->getPlaces() < $nombrePersonnes) {
throw new ExceptionMetier('Il n’y a plus assez de places pour cette date.');
}
$reservation = (new Reservation())
->setClient($client)
->setDateDepart($dateDepart)
->setNombrePersonnes($nombrePersonnes)
->setMontantTotal($sejour->getPrixParPersonne() * $nombrePersonnes)
->setStatut(StatutReservation::EnAttente);
$this->entityManager->persist($reservation);
$this->entityManager->flush();
return $reservation;
}
public function testVisiteurEstRenvoyeVersConnexionPourLeCompte(): void
{
$client = $this->clientAvecDemo();
$client->request('GET', '/mes-reservations');
self::assertResponseRedirects('/connexion');
$client->request('GET', '/mon-compte');
self::assertResponseRedirects('/connexion');
}
public function testVisiteurRefuseAdminVersConnexion(): void
{
$client = $this->clientAvecDemo();
$client->request('GET', '/admin');
self::assertResponseRedirects('/connexion');
}
public function testClientRefuseAdminVoitPageInterdite(): void
{
$client = $this->clientAvecDemo();
$client->loginUser($this->utilisateur(DemoDataset::CLIENT_EMAIL));
$client->request('GET', '/admin');
self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
self::assertSelectorTextContains('h1', 'Cette action n’a pas abouti.');
}
Site test pour l’usine. Pas un CRUD admin complet. Horizon n’a pas la porte « sûr en ligne ».