Fixtures — DoctrineFixturesBundle, Foundry, Alice
TL;DR — Fixtures = jeux de données reproductibles pour dev, demo, tests.
DoctrineFixturesBundleest le minimum syndical, mais sur projets sérieux on utilise Zenstruck/Foundry (DSL moderne, factories) ou Alice (YAML déclaratif). Faker pour le contenu, groupes pour scénarios, batching pour gros volumes.
Mental model — ASCII diagram + analogy
Analogie : Fixtures = plateau de jeu pré-positionné avant la partie. Tu choisis le scénario (--group=demo, --group=tests), tu charges, tu joues. Foundry/Alice = assistant qui peuple le plateau intelligemment.
AppFixtures (orchestrator)
│
├── load(): registers Factories
│
├── UserFactory::createMany(50) ──┐
├── ProductFactory::createMany(200)│ ────► IdentityMap → DB
└── OrderFactory::createMany(1000) │
│
(Foundry stories tying domains together)Foundry ≈ FactoryBot (Ruby), Alice ≈ Factory Boy (Python) en YAML.
Code minimal — realistic snippet
composer require --dev orm-fixtures zenstruck/foundry fakerphp/faker<?php
// src/DataFixtures/AppFixtures.php
namespace App\DataFixtures;
use App\Factory\AuthorFactory;
use App\Factory\BookFactory;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Persistence\ObjectManager;
final class AppFixtures extends Fixture implements FixtureGroupInterface
{
public static function getGroups(): array
{
return ['demo'];
}
public function load(ObjectManager $manager): void
{
$authors = AuthorFactory::createMany(20);
foreach ($authors as $author) {
BookFactory::createMany(random_int(2, 8), [
'author' => $author,
]);
}
// pas de $manager->flush() : Foundry s'en charge
}
}<?php
// src/Factory/AuthorFactory.php
namespace App\Factory;
use App\Entity\Author;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
/**
* @extends PersistentProxyObjectFactory<Author>
*/
final class AuthorFactory extends PersistentProxyObjectFactory
{
public static function class(): string
{
return Author::class;
}
protected function defaults(): array
{
return [
'email' => self::faker()->unique()->safeEmail(),
];
}
protected function initialize(): static
{
return $this
->afterPersist(function (Author $author): void {
// hook après persist si besoin
});
}
}<?php
// src/DataFixtures/ProdSeedFixtures.php
namespace App\DataFixtures;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
final class ProdSeedFixtures extends Fixture implements FixtureGroupInterface
{
public static function getGroups(): array { return ['prod-seed']; }
public function load(ObjectManager $manager): void
{
// données INDISPENSABLES en prod : pays, devises, rôles…
foreach (['EUR', 'USD', 'TND'] as $code) {
$currency = new \App\Entity\Currency($code);
$manager->persist($currency);
}
$manager->flush();
}
}# Charger un groupe précis
php bin/console doctrine:fixtures:load --group=demo --no-interaction
# Append (ne pas purger la DB)
php bin/console doctrine:fixtures:load --append
# En tests
APP_ENV=test php bin/console doctrine:fixtures:load --group=tests -n# avec nelmio/alice (alternative déclarative)
# fixtures/users.yaml
App\Entity\Author:
author_{1..20}:
email: '<safeEmail()>'
App\Entity\Book:
book_{1..200}:
title: '<sentence(3)>'
author: '@author_*'
tags: '3x @tag_*'Patterns courants — 3–6 patterns
- Groupes par scénario :
demo,tests,prod-seed,qa-load. Chaque CI a son groupe. - Fixtures dépendantes : Foundry évite l'implémentation
DependentFixtureInterfacecar les factories s'appellent entre elles. Avec le bundle de base :getDependencies(): [UserFixtures::class]. - Stories Foundry :
class DemoStory extends Story { public function build(): void { ... } }regroupe un scénario complet (utilisateurs + commandes + paiements en état cohérent). - Batch + clear pour 100k+ entités :php
for ($i = 0; $i < 100_000; $i++) { $m->persist(BookFactory::createOne()->_real()); if ($i % 500 === 0) { $m->flush(); $m->clear(); } } - Référence par alias :
$this->addReference('admin', $user)puisgetReference('admin')(bundle classique) ; Foundry exposefind()/findOrCreate(). - Seed dispatched events désactivés : passer un flag pour éviter d'envoyer 10k mails Mailer au seed.
Versions — Symfony 5.4 / 6.4 / 7.x
| Composant | 5.4 | 6.4 | 7.x |
|---|---|---|---|
| DoctrineFixturesBundle | 3.x | 3.5+ | 3.5+ |
| Zenstruck/Foundry | 1.x (méthode new() non persistante) | 2.x (split ObjectFactory / PersistentObjectFactory) | 2.x (PHP 8.2+) |
| Alice (nelmio/alice) | 3.x | 3.x | 3.x maintenu mais momentum plus faible |
| Faker | fzaninotto/faker (archived) | fakerphp/faker (fork) | fakerphp/faker v1.23+ |
- Foundry 2 : break majeur —
Proxyséparée de l'entité réelle.$user->_real()pour récupérer l'objet brut.ProxyRepositoryDecoratorpour assertions. fzaninotto/fakerabandonné en 2020 → migrer.
Pitfalls — 5–8 concrete traps
--purge-with-truncatecasse FK : sur Postgres avec contraintes, utiliser--purge-with-truncatepeut violer FK ; tester par groupe.- Ordre aléatoire : sans
DependentFixtureInterfaceni Foundry, l'ordre n'est pas garanti → erreurs sporadiques. - Fixtures qui frappent des API externes : tests CI plantent en air-gapped. Toujours mocker en fixtures ou utiliser un
HttpClientmock. - Faker
unique()exhaustion :unique()->randomNumber(2)n'a que 100 valeurs → exception après 100 entités. - Performance :
flush()à chaque entité = O(n²) sur graph profond. Batch impératif au-delà de quelques centaines. - Fixtures vs migrations data : ne pas mélanger ! Données de référence métier (currencies, roles) → migrations ou commande dédiée. Fixtures = dev/test uniquement.
- Locale Faker :
Factory::create('fr_FR'); oublier la locale donne du contenu anglais incohérent en demo. - Foundry + listener Doctrine : un événement
postPersistqui dispatch un Messenger envoie 10k messages en seed → flusher la queue test ou désactiver l'event subscriber pendant le load.
Comment un staff engineer raisonne sur les fixtures
Le piège mental n°1 : croire que « fixtures » désigne une seule chose. C'est en réalité trois besoins disjoints qui ont des cycles de vie, des SLA et des propriétaires différents.
| Besoin | Outil | Déterminisme exigé | Volume | Vit où |
|---|---|---|---|---|
| Données de référence métier (devises, rôles, pays, plan comptable) | Migration data ou commande dédiée, jamais fixtures dev | Total (la prod en dépend) | Petit | Migrations versionnées |
| Seed dev/demo (peupler un env pour cliquer) | AppFixtures + Foundry, groupes demo/perf | Faible | Moyen à très gros | src/DataFixtures |
| State de test (arranger l'AAA d'un test) | Foundry factories/stories, jamais doctrine:fixtures:load global | Reproductible via seed | Minimal (3 lignes, pas 3000) | Dans le test, près de l'assertion |
Trois erreurs d'architecture classiques qui découlent de la confusion :
- Charger les fixtures de demo en test. Un
doctrine:fixtures:loadglobal avant la suite couple chaque test à un dataset partagé géant : un test qui modifie un compteur casse un autre test à distance, et personne ne sait quel test « possède » la donnée. Le bon réflexe : chaque test crée exactement ce qu'il assert, via factory, dans son proprearrange. La DB est remise à zéro parResetDatabase. - Mettre des données de référence en fixtures. Si tes
Currencysont en fixtures et pas en migration, ta prod n'a pas d'euros tant que personne n'a lancé une commande dev. Les données dont dépend le code de prod sont du schéma fonctionnel : elles vivent dans une migration, testées par le pipeline de déploiement. - Faker en demo sans seed ni locale. Une demo commerciale avec des noms anglais incohérents et des montants ubuesques décrédibilise le produit. Locale (
fr_FR), providers métier (IBAN/SIRET valides), seed fixe pour des captures d'écran reproductibles.
Le coût caché : déterminisme et flakiness
Faker est non déterministe par défaut. Conséquence : un test qui passe « la plupart du temps » mais frappe parfois un cas limite (randomFloat qui tombe sur 0.00, unique() qui s'épuise) est un test flaky. Deux disciplines :
- Seed reproductible :
FOUNDRY_FAKER_SEED=<run-id>en CI. Un flake devient rejouable à l'identique — tu copies le seed du run rouge et tu reproduis en local. Sans ça, un flake Faker est une chasse au fantôme. - Ne jamais asserter sur du contenu Faker. Assert sur la structure (
assertResponseIsSuccessful,assertSelectorCount(3, ...)), jamais surassertSame('Jean Dupont', ...). Si tu as besoin d'une valeur précise, fournis-la viawith()— c'est le point du test, pas du hasard.
Testing — phpunit / KernelTestCase
<?php
namespace App\Tests\Functional;
use App\Factory\AuthorFactory;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
final class AuthorListTest extends WebTestCase
{
use ResetDatabase; // remet DB à zéro avant chaque test (transaction)
use Factories; // active Foundry
public function testListShowsAuthors(): void
{
AuthorFactory::createMany(3, ['email' => function() {
static $i = 0;
return sprintf('user%[email protected]', $i++);
}]);
$client = static::createClient();
$client->request('GET', '/authors');
self::assertResponseIsSuccessful();
self::assertSelectorCount(3, 'tr.author-row');
}
}ResetDatabase peut s'appuyer sur un wrapping transaction (rapide) ou un schema reset (lent). Configurer via config/packages/test/zenstruck_foundry.yaml.
🎬 Cas d'usage concrets
Scénario 1 — Fixtures Foundry pour tests fonctionnels d'un cabinet d'avocats
Le cabinet juridique de l'exemple précédent compte 47 développeurs répartis sur trois équipes (DMS, facturation, portail client). Pour fiabiliser leur suite de 1 800 tests fonctionnels, ils s'appuient sur zenstruck/foundry. Chaque agrégat métier — Dossier, Client, Facture, Document — dispose d'une factory qui produit un objet valide par défaut, avec des States nommés (closed(), withImpayes(), archived()) pour exprimer des situations métier sans alourdir le test. La factory DossierFactory::new()->withImpayes(3)->withDocuments(50) construit en une ligne un scénario de clôture impossible. Les tests utilisent Story pour composer des scénarios récurrents (un cabinet type avec 5 associés, 20 collaborateurs, 200 dossiers). Le trait ResetDatabase en mode transactionnel ramène la durée moyenne d'un test fonctionnel de 1,2 s à 180 ms, ce qui a permis de garder la CI sous 8 minutes malgré la croissance du parc de tests.
Scénario 2 — Seed de démonstration pour un site e-commerce mode
Une marketplace e-commerce de mode utilise un environnement de démonstration partagé entre la prévente, le support et la formation. Les fixtures Doctrine standard (AppFixtures) chargent un dataset réaliste : 12 marchands fictifs, 800 produits avec photos générées via un service local de placeholders, 50 utilisateurs aux rôles variés (acheteur, vendeur, modérateur), historique de 2 000 commandes étalées sur six mois. Les fixtures sont structurées en plusieurs FixtureGroup (demo, e2e, perf) pour produire des datasets de tailles différentes selon la cible. La fixture perf génère 200 000 produits via batch flush et EntityManager::clear() toutes les 500 entités pour éviter l'explosion mémoire. Le démo nightly job tear-down/setup en moins de 5 minutes, et chaque commercial peut booker une démo sur un environnement vierge à la demande.
Scénario 3 — Fixtures CI pour SaaS comptable multi-tenant
Un SaaS comptable français destiné aux experts-comptables et leurs clients PME maintient un parc de 350 tests d'intégration sur l'API REST. Le dataset CI est minimaliste : 1 cabinet d'expertise, 2 dossiers clients, 1 exercice fiscal ouvert, 5 écritures comptables types (vente, achat, OD, banque, TVA), conformes au PCG français. Les fixtures sont chargées via Foundry avec un seed déterministe (variable d'environnement FOUNDRY_FAKER_SEED fixée au numéro de run CI) garantissant que les valeurs aléatoires sont reproductibles entre runs — un test qui flake peut être rejoué à l'identique en réinjectant le même seed. Les valeurs sensibles (numéros SIRET, IBAN) sont générées par des providers Faker personnalisés respectant les règles de checksum (modulo 97 pour IBAN, clé Luhn pour SIRET) afin que les validations métier réelles passent en test. Cette discipline a réduit les flakes "données invalides" de 12 % à moins de 0,2 % sur six mois.
🛠️ Exemple end-to-end
Use case : construire avec Foundry une factory DossierFactory qui produit un dossier réaliste, avec états closed() et withImpayes(), et l'utiliser dans un test fonctionnel.
<?php
// src/Factory/DossierFactory.php
declare(strict_types=1);
namespace App\Factory;
use App\Domain\Dossier\Entity\Dossier;
use App\Domain\Dossier\Entity\Facture;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
/**
* @extends PersistentProxyObjectFactory<Dossier>
*/
final class DossierFactory extends PersistentProxyObjectFactory
{
public static function class(): string
{
return Dossier::class;
}
protected function defaults(): array|callable
{
return [
// ClientFactory::new() = "factory as default" : lazy, n'est instanciée
// que si l'appelant ne fournit pas son propre client.
'client' => ClientFactory::new(),
'reference' => self::faker()->bothify('DOS-####-??##'),
'statut' => 'OUVERT',
];
}
// State « pur attribut » → utiliser ->with(), évalué AVANT instanciation.
// Préférable quand l'état est un simple champ : composable, pas de DB round-trip.
public function closed(): self
{
return $this->with([
'statut' => 'CLOTURE',
'closedAt' => new \DateTimeImmutable(),
]);
}
// State « graphe d'objets » → afterPersist : on a besoin de l'ID du dossier
// persisté pour rattacher les factures. createMany() ici persiste aussitôt.
public function withImpayes(int $n = 1): self
{
return $this->afterPersist(function (Dossier $d) use ($n): void {
FactureFactory::createMany($n, [
'dossier' => $d,
'reglee' => false,
'montant' => self::faker()->randomFloat(2, 500, 5000),
]);
});
}
}
// tests/Functional/CloturerDossierTest.php
namespace App\Tests\Functional;
use App\Factory\DossierFactory;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
final class CloturerDossierTest extends WebTestCase
{
use Factories;
use ResetDatabase;
public function testCloturerEchoueSiFacturesImpayees(): void
{
$client = static::createClient();
$dossier = DossierFactory::new()->withImpayes(2)->create();
$client->loginUser(UserFactory::associe()->create()->_real());
$client->request('POST', '/dossiers/' . $dossier->getId() . '/cloturer');
self::assertResponseStatusCodeSame(409);
self::assertJsonContains(['error' => '2 facture(s) impayée(s)']);
}
public function testCloturerSucceedeSiToutEstRegle(): void
{
$client = static::createClient();
$dossier = DossierFactory::createOne();
$client->loginUser(UserFactory::associe()->create()->_real());
$client->request('POST', '/dossiers/' . $dossier->getId() . '/cloturer');
self::assertResponseIsSuccessful();
DossierFactory::assert()->exists(['statut' => 'CLOTURE']);
}
}Quand utiliser / éviter
Utiliser :
- Démos commerciales reproductibles.
- Tests d'intégration end-to-end.
- Onboarding dev (1 commande → app peuplée).
Éviter / alternatives :
- Données prod : utiliser un dump anonymisé + RGPD, pas des fixtures.
- Tests unitaires purs : mocks suffisent.
- Smoke tests perf : générer en SQL brut, 10x plus rapide.
Performance : générer 1M de lignes en < 1 minute
public function load(ObjectManager $m): void
{
$conn = $m->getConnection();
$conn->beginTransaction();
$stmt = $conn->prepare('INSERT INTO books (title, author_id, created_at) VALUES (?, ?, ?)');
for ($i = 0; $i < 1_000_000; $i++) {
$stmt->executeStatement([
'Book #' . $i,
random_int(1, 1000),
(new \DateTimeImmutable())->format('Y-m-d H:i:s'),
]);
}
$conn->commit();
}Bypass Doctrine ORM → 50x plus rapide. À réserver aux seeds de bench/load test.
Pour Postgres, encore plus rapide via COPY FROM STDIN :
$pdo = $conn->getNativeConnection();
$pdo->pgsqlCopyFromArray('books', [
"Book A\t1\t2024-01-01",
"Book B\t2\t2024-01-01",
]);Le spectre des stratégies de chargement
Il n'y a pas « une » façon de charger. Choisis selon le volume et le besoin de validité métier :
| Stratégie | Débit indicatif | Passe par | Valide les invariants ? | Quand |
|---|---|---|---|---|
| Foundry / ORM, flush par entité | ~hundreds/s | UoW + events + lifecycle | Oui (constructeurs, events) | Tests, petits seeds — la correction prime |
ORM batch (flush+clear /500) | ~few k/s | UoW, IdentityMap bornée | Oui | Seeds demo 10k–100k |
INSERT préparé en transaction (DBAL) | ~tens of k/s | SQL direct, pas d'ORM | Non (bypass entités) | Seeds perf/bench |
COPY FROM (PG) / LOAD DATA (MySQL) | ~hundreds of k/s+ | Protocole bulk natif | Non | 1M+ lignes, load tests |
Le piège mémoire du batch ORM : sans clear(), l'UnitOfWork garde une référence vers chaque entité persistée → la mémoire croît linéairement et le flush devient O(n²) car Doctrine recalcule les changesets de tout le graphe géré. flush() puis clear() toutes les ~500 entités borne l'IdentityMap. Désactive aussi le SQL logger en boucle de seed ($conn->getConfiguration()->setSQLLogger(null)) : il accumule chaque requête en mémoire. Pour les inserts DBAL/COPY, souviens-toi que tu bypasses toute la logique métier — pas de hash de password, pas de createdAt auto, pas d'event : à réserver aux données dont la validité n'est pas testée par le code qui les lit.
Observabilité d'un seed long
Un seed de 200k entités qui tourne 6 minutes sans feedback est ingérable. En prod-grade :
- Progress bar :
Symfony\Component\Console\Helper\ProgressBardans une commande dédiée plutôt quedoctrine:fixtures:load, pour piloter le batching et afficher l'avancement. - Idempotence : un seed qu'on peut relancer sans doublon (clé naturelle +
randomOrCreate()/ upsert), sinon un re-run double les données. - Borne mémoire : logger
memory_get_peak_usage(true)par batch pour détecter une fuite (clear oublié) avant l'OOM en CI.
Foundry stories — orchestrer des scenarios complets
<?php
namespace App\Story;
use App\Factory\UserFactory;
use App\Factory\OrderFactory;
use Zenstruck\Foundry\Story;
final class DemoStory extends Story
{
public function build(): void
{
$admin = UserFactory::createOne(['email' => '[email protected]', 'roles' => ['ROLE_ADMIN']]);
$this->addState('admin', $admin);
$customers = UserFactory::createMany(50);
foreach ($customers as $c) {
OrderFactory::createMany(random_int(1, 5), ['customer' => $c]);
}
}
}
// Dans une fixture ou un test
DemoStory::load(); // idempotent : un 2e appel ne re-charge pas
$admin = DemoStory::get('admin'); // ou la magic method : DemoStory::admin()Story vs Factory — quand monter d'un cran
| Factory | Story | |
|---|---|---|
| Granularité | un agrégat (User, Order) | un scénario cohérent (marketplace peuplée) |
| Réutilisation | partout | états nommés partagés entre tests (::admin()) |
| Idempotence | non pertinent | ::load() ne s'exécute qu'une fois par process |
| Coût | crée à la demande | charge tout son graphe au premier accès |
Règle de staff : une factory répond à « comment fabriquer un X valide ? ». Une Story répond à « à quoi ressemble un monde dans lequel mon test a du sens ? ». N'utilise pas une Story pour ce qu'une factory + un with() suffisent — sinon tu crées un couplage caché entre tests via l'état partagé. En PHPUnit 10+, préfère l'attribut #[WithStory(DemoStory::class)] au-dessus de la classe/méthode de test plutôt qu'un ::load() manuel : Foundry garantit le chargement avant le test et le reset après.
Foundry sans persistance — factory comme builder d'objets
Foundry n'est pas réservé à la DB. withoutPersisting() (ou Zenstruck\Foundry\object()) produit un objet hydraté sans toucher Doctrine — idéal en test unitaire pour fabriquer une entité valide à passer à un service mocké, sans KernelTestCase ni base.
use function Zenstruck\Foundry\object;
$dossier = DossierFactory::new()->withoutPersisting()->create(); // Dossier, jamais persisté
$dossier = object(Dossier::class, ['reference' => 'DOS-0001']); // équivalent courtCôté persistance, deux décisions structurantes :
- Lazy par défaut (Foundry 2) : une factory passée en attribut (
'client' => ClientFactory::new()) n'est instanciée que si l'appelant ne fournit pas de valeur. Cela évite de créer 50Clientinutiles quand le test fournit toujours le sien. - Proxy vs objet réel :
createOne()rend unProxyqui se rafraîchit depuis la DB (utile pour vérifier un état après une requête HTTP)._real()casse cette magie et rend l'entité brute — indispensable pourloginUser(), pour comparer par identité (===), ou pour passer l'objet à du code qui type-hinte l'entité concrète et non le proxy. En PHP 8.4+, Foundry s'appuie sur les lazy objects natifs et le proxy disparaît, mais l'API_real()/_refresh()reste.
🏋️ Exercices
Progression : implémenter → production-grade → casser puis réparer. Chaque exercice suppose une entité Book liée à un Author (ManyToOne) et un Tag (ManyToMany), sur Symfony 7.x / PHP 8.2+.
Exercice 1 — Factory + states (échauffement)
Objectif : écrire BookFactory avec un default valide et deux states published() / draft() exprimés via with(), puis un test qui crée 3 livres publiés et asserte le compte.
Indice / Solution
published() retourne $this->with(['publishedAt' => self::faker()->dateTimeThisYear()]), draft() retourne $this->with(['publishedAt' => null]). Dans le test (use Factories, ResetDatabase), BookFactory::new()->published()->many(3)->create() puis BookFactory::assert()->count(3) et BookFactory::assert()->exists(['...']). Vérifie que draft()->create()->_get('publishedAt') est null.
Exercice 2 — Graphe cohérent via afterPersist + Story
Objectif : ajouter à BookFactory un state withReviews(int $n) qui crée des avis liés au livre persisté, et une CatalogStory qui monte un catalogue (1 auteur best-seller, 20 livres, des tags partagés réutilisés via un pool).
Indice / Solution
withReviews() doit utiliser afterPersist(fn(Book $b) => ReviewFactory::createMany($n, ['book' => $b])) car on a besoin de l'ID. La Story : addToPool('tags', TagFactory::createMany(5)) puis dans la boucle BookFactory::createMany(20, ['author' => $author, 'tags' => self::getRandomSet('tags', 2)]). Expose bestSeller() via state nommé (addState('bestSeller', ...)). Préfère #[WithStory(CatalogStory::class)] au ::load() manuel.
Exercice 3 — Provider Faker métier + déterminisme (production-grade)
Objectif : écrire un provider Faker custom isbn13() qui génère un ISBN-13 avec clé de contrôle valide (somme pondérée mod 10), l'enregistrer, l'utiliser dans BookFactory, et garantir que la suite est reproductible via FOUNDRY_FAKER_SEED.
Indice / Solution
Crée class IsbnProvider extends \Faker\Provider\Base { public function isbn13(): string {...} }. Génère 12 chiffres, calcule la 13e : $check = (10 - ($sum % 10)) % 10 où $sum pondère alternativement par 1 et 3. Enregistre via la config Foundry (zenstruck_foundry.faker.provider) ou self::faker()->addProvider(new IsbnProvider(self::faker())) dans initialize(). Pour le déterminisme : exécute FOUNDRY_FAKER_SEED=42 vendor/bin/phpunit deux fois → mêmes ISBN. Assert qu'un validateur métier (Assert\Isbn) accepte la valeur générée — c'est tout l'intérêt d'un provider valide.
Exercice 4 — Casser puis réparer : la fuite mémoire du seed
Objectif : écrire une commande app:seed:books qui insère 100 000 livres via l'ORM en oubliant volontairement clear(), observer la montée mémoire (OOM ou ralentissement quadratique), puis réparer.
Indice / Solution
Version cassée : boucle $em->persist($book); if ($i % 500 === 0) $em->flush(); sans $em->clear(). Logge memory_get_peak_usage(true) tous les 5000 → croissance linéaire, et chaque flush ralentit (changeset recalculé sur tout l'UoW). Réparation : ajouter $em->clear() après chaque flush (attention : clear() détache l'Author — re-getReference() ou re-fetch). Bonus : désactiver le SQL logger ($em->getConnection()->getConfiguration()->setMiddlewares([]) ou setSQLLogger(null) selon version), wrapper dans une transaction unique. Mesure le delta mémoire avant/après.
Exercice 5 — Casser puis réparer : la cascade d'events au seed
Objectif : une factory OrderFactory déclenche un postPersist listener Doctrine qui dispatch un message Messenger « envoyer email de confirmation ». Seeder 10 000 commandes envoie 10 000 emails. Reproduire, puis neutraliser proprement.
Indice / Solution
Reproduis : crée le listener + un Messenger handler qui logge un « email envoyé ». OrderFactory::createMany(10_000) → 10k logs. Trois réparations, par ordre de propreté : (1) OrderFactory::new()->withoutDoctrineEvents(OrderConfirmationListener::class)->many(10_000)->create() — chirurgical, recommandé ; (2) router le transport Messenger vers sync/null en env de seed via config/packages/<env>/messenger.yaml ; (3) un flag applicatif. Montre pourquoi (1) est supérieur : il cible exactement le listener sans toucher la config globale ni masquer d'autres events légitimes.
Exercice 6 — Bench des stratégies de chargement (architecte)
Objectif : charger 1 000 000 de lignes via les 4 stratégies du tableau (ORM batch, INSERT DBAL préparé, COPY/LOAD DATA), mesurer débit + pic mémoire, et écrire la règle de décision.
Indice / Solution
Chronomètre microtime(true) + memory_get_peak_usage(true) par stratégie sur la même machine/DB. Attendu : ORM batch ~quelques k/s, DBAL préparé en transaction ~10x, COPY FROM STDIN ~10–50x encore. Documente le coût caché : ORM = invariants validés mais lent ; bulk = aucune validation, pas de hash password ni createdAt. La règle : valider via ORM pour ce que le code lit/teste, bulk pour les seeds perf jetables. Vérifie un échantillon post-load (intégrité FK, comptes) car COPY ne te protège de rien.
🎤 En entretien
Q : Pourquoi ne jamais charger les fixtures de demo dans la suite de tests ? Parce que ça couple chaque test à un dataset partagé géant : un test qui mute une donnée en casse un autre à distance, l'ordre devient significatif, et la cause d'un échec est introuvable. Le pattern correct est arrange-local : chaque test crée exactement ce qu'il asserte via factory, et ResetDatabase (transactionnel) ramène la DB à zéro entre chaque test.
Q : Données de référence (devises, rôles) — fixtures ou migration ? Pourquoi ? Migration (ou commande de déploiement), jamais fixtures dev. Si la prod dépend de la donnée pour fonctionner, c'est du schéma fonctionnel versionné et déployé par le pipeline. Les fixtures sont un outil dev/test : la prod ne lance jamais doctrine:fixtures:load, donc compter dessus pour des données critiques garantit une prod cassée.
Q : Tu seedes 100k entités via l'ORM et la mémoire explose. Que se passe-t-il ? L'UnitOfWork garde une référence vers chaque entité managée : sans clear() périodique, la mémoire croît linéairement et chaque flush() recalcule le changeset de tout le graphe → comportement quadratique. Fix : flush() + clear() toutes les ~500 entités (en réattachant les références détachées), désactiver le SQL logger, et au-delà passer en INSERT DBAL ou COPY si la validation ORM n'est pas nécessaire.
Q : Proxy vs _real() en Foundry 2 — quand le _real() est-il obligatoire ? Le Proxy se rafraîchit automatiquement depuis la DB, pratique pour vérifier un état après une requête HTTP. Mais il faut _real() (l'entité brute) quand le code consommateur type-hinte l'entité concrète (loginUser(User $u)), pour une comparaison par identité (===), ou pour éviter un round-trip DB intempestif. En PHP 8.4+ Foundry utilise les lazy objects natifs et le proxy s'efface, mais l'API reste pour la compatibilité.
Liens
- DoctrineFixturesBundle — https://symfony.com/bundles/DoctrineFixturesBundle/current/index.html
- Foundry — https://github.com/zenstruck/foundry
- nelmio/alice — https://github.com/nelmio/alice
- fakerphp/faker — https://fakerphp.org
- "Symfony Foundry 2 announcement" — https://les-tilleuls.coop/blog/foundry-2
- Postgres COPY perf — https://www.postgresql.org/docs/current/sql-copy.html