Migrations Doctrine — règles, compatibilité MariaDB/SQLite, sécurité financière
Kohinos repose sur Doctrine ORM 2.14+ et DBAL 3.x
(pas DBAL 4.x — l'API Schema::createTable() et la signature des
plateformes ont changé). La cible prod est MariaDB, les tests tournent
sur SQLite in-memory. Conséquence directe : toute migration
doit fonctionner sur les deux moteurs, sous peine de casser la CI ou la prod. Et comme
la base contient les soldes eMLc, les mandats SEPA et les engagements SSA, une migration
ratée peut détruire de la monnaie locale. La règle est donc : relire,
rendre réversible, tester sur les deux moteurs, ne pas faire de DML risqué.
Cycle de vie d'une migration
flowchart LR A["1. Modifier entitéPipeline minimum à dérouler avant tout commit de migration. Aucun de ces neuf points n'est optionnel.
(attributes PHP 8)"] --> B["2. doctrine:migrations:diff"] B --> C["3. Relire le SQL généré"] C --> D["4. Compléter down() réversible"] D --> E["5. migrate (env=dev MariaDB)"] E --> F["6. migrate prev + migrate (idempotence)"] F --> G["7. migrate (env=test SQLite)"] G --> H["8. tests ciblés + schema:validate"] H --> I["9. CHANGELOG.md + commit"]
Commandes de référence
# Générer (jamais commiter sans relecture)
php bin/console doctrine:migrations:diff
# Appliquer sur dev (MariaDB)
php bin/console doctrine:migrations:migrate --no-interaction
# Rollback la dernière puis rejouer (vérifie down() + idempotence)
php bin/console doctrine:migrations:migrate prev --no-interaction
php bin/console doctrine:migrations:migrate --no-interaction
# Appliquer sur SQLite (env de test)
php bin/console doctrine:database:create --env=test
php bin/console doctrine:migrations:migrate --env=test --no-interaction
# Valider la cohérence schéma ⇄ entités
php bin/console doctrine:schema:validate
La config est dans config/packages/doctrine_migrations.yaml : les
migrations vivent sous src/Migrations/, la table de tracking est
migration_versions, et un décorateur maison
(App\Utils\MigrationFactoryDecorator) injecte les services nécessaires
aux migrations qui font appel au container.
Incompatibilités SQLite ⇄ MariaDB
SQLite est un moteur volontairement minimaliste : certaines opérations DDL courantes sur MariaDB y sont émulées par recréation de table, d'autres sont silencieusement ignorées. Les pièges les plus fréquents rencontrés sur Kohinos :
| Opération | MariaDB | SQLite | Impact |
|---|---|---|---|
ALTER TABLE … CHANGE col … (changement de type) |
In-place, rapide. | Recrée la table : copie, drop, rename. Les FK pointant dessus sautent. | Lent en test, possible perte d'index. |
FOREIGN KEY avec ON DELETE CASCADE |
Appliquée systématiquement. | PRAGMA foreign_keys = OFF par défaut. |
Tests qui passent en SQLite mais cassent les invariants en prod. |
DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP |
Supporté. | Non supporté. Doit être géré par PHP (@PreUpdate). |
Migration qui ne s'applique pas en test. |
Colonnes json |
Stockée LONGTEXT avec validation. |
Stockée TEXT sans validation. |
Erreur silencieuse sur JSON malformé en test. |
ENUM |
Supporté nativement. | Émulé via CHECK sur VARCHAR. |
Préférer string + enum PHP 8.1 côté Doctrine. |
ENGINE=InnoDB · COLLATE utf8_general_ci |
Obligatoire pour InnoDB. | Ignoré silencieusement. | SQL non portable : à isoler dans un bloc getPlatform(). |
AUTO_INCREMENT |
Compteur explicite. | Émulé via ROWID / INTEGER PRIMARY KEY. |
Les IDs en test ne reflètent pas la séquence prod. |
DROP COLUMN |
In-place. | Recrée la table — toutes les FK et index annexes doivent être ré-écrits. | Risque de perte d'index si le diff Doctrine est joué tel quel. |
FULLTEXT INDEX |
Supporté MyISAM/InnoDB. | Non supporté. | À garder hors migration commune, ou guarder par plateforme. |
Pattern de guard plateforme à utiliser quand on ne peut vraiment pas écrire du SQL portable :
public function up(Schema $schema): void
{
$platform = $this->connection->getDatabasePlatform()->getName();
if ($platform === 'mysql') {
$this->addSql('ALTER TABLE flux ADD FULLTEXT INDEX idx_flux_reference (reference)');
return;
}
// SQLite (tests) : on saute, l'index FULLTEXT n'a pas de sens en in-memory.
}
Règles dures
- Toute migration impactant des données financières (soldes, opérations, mandats SEPA, items SSA) doit avoir un aperçu
--dry-runou un script PHP de validation associé. - JAMAIS de
DROP COLUMNsur une table financière (flux,operation,adherent.ecompte,prestataire.ecompte,siege.ecompte,solidoume_item,sepa_mandate,recurring_*_request) sans backup vérifié et restauré au préalable. - JAMAIS de DML risqué dans
up()(UPDATE/DELETEsansWHERE, recalcul de balances, ré-encodage de hash) — préférer une commande Symfony dédiée, idempotente et observable. - Toujours réversible :
down()non vide et testée localement. Une migration sansdown()est rejetée en review. - Index obligatoires sur les colonnes filtrées par les requêtes critiques :
helloasso_id,mandate_reference,solidoume_parameter_id,last_processed_month,payzen_order_id. - Aucune migration ne modifie un hash de mot de passe, un IBAN chiffré, un UMR ou un UUID déjà émis. Ces champs sont des références pour les utilisateurs et les banques.
Migrations IBAN chiffrés — exemple type
Les IBAN SepaMandate::iban sont chiffrés via libsodium. Une mauvaise
migration ré-encrypterait deux fois et casserait les futurs prélèvements. Pattern
de migration sûr :
# 1. Aperçu sans écriture
php bin/console app:migrate-iban-encryption --dry-run
# 2. Vérifier le rapport généré dans var/log/iban-migration-preview-*.json
# Champs attendus : total_mandates, already_encrypted, to_encrypt, errors[]
# 3. Window double-encryption : la commande accepte les deux formats pendant la migration
# - cleartext : KOHINOS-detected (prefix bytes 'FR' ou 'BE')
# - chiffré v1 : prefix sodium "ENC1:" + base64
# On ne touche QUE les cleartext, on tague le résultat "ENC1:".
# 4. Exécution réelle
php bin/console app:migrate-iban-encryption
# 5. Vérification post-migration
# - aucun IBAN cleartext restant
# - tous les SepaMandate.status restent inchangés
# - les RecurringSepaPurchaseRequest associés sont toujours résoluble par UMR
php bin/console kohinos:sepa:verify-encryption
Le DDL (ajout de colonne iban_encryption_version) est dans une migration
Doctrine, mais la conversion des données est dans une commande
Symfony dédiée — c'est l'application stricte de la règle "pas de DML dans
up()".
Tests sur SQLite in-memory
La config de test (config/packages/test/doctrine.yaml) bascule le driver
sur pdo_sqlite avec memory: true. Toutes les migrations sont
rejouées depuis zéro à chaque php bin/console app:init-test-database
--env=test --purge. Cas de blocage déjà rencontrés :
- Foreign keys désactivées : SQLite par défaut n'applique pas les FK. Si la migration ajoute une FK et qu'un test compte sur le rejet, le test passe en mémoire mais échoue en prod. La fixture doit donc aussi contrôler la cohérence référentielle au niveau applicatif.
- Pragmas SQLite : si une migration émet
PRAGMA, l'envelopper dans le guard de plateforme — MariaDB le rejette. - Recréation de table sur
ALTER: certains tests Sonata Admin chargent ~5 000 lignes ausetUp. Une migrationCHANGE COLUMNen SQLite peut faire grimper le temps de test à plusieurs secondes. Préférer scinder migration structurelle et data backfill. - Types
DECIMAL(10, 2): SQLite stocke enNUMERICavec arithmétique flottante. Les invariants monétaires doivent utiliserBigDecimalcôté PHP, jamais comparer des floats SQLite avec===.
Doctrine attributes uniquement
type: attribute — pas d'annotations
Le projet est configuré en mapping: { type: attribute }. Les
docblock annotations Doctrine/Gedmo ne sont pas lues. Toute migration
générée à partir d'une entité avec annotations docblock sera vide (faux négatif), ou
au contraire générera des DROP intempestifs (faux positif) — relire systématiquement
le SQL produit.
// ❌ INTERDIT
/** @ORM\Column(type="string") @Gedmo\Slug(fields={"name"}) */
// ✅ OBLIGATOIRE
#[ORM\Column(length: 100)]
#[Gedmo\Slug(fields: ['name'])]
Cela vaut aussi pour : #[ORM\PostPersist],
#[ORM\HasLifecycleCallbacks], #[ORM\MappedSuperclass].
Conséquence sur l'héritage métier : AccountableObject sans
#[ORM\MappedSuperclass] ne déclenche pas son postPersist et
le compte associé n'est jamais créé. Résultat : getBalance() lève
sur null à la première opération financière, et la migration de la table fille semble
cohérente alors qu'elle est cassée à l'exécution.
Anti-patterns
| Anti-pattern | Pourquoi c'est dangereux | À faire à la place |
|---|---|---|
Commit du diff sans relecture |
Doctrine peut suggérer un ALTER COLUMN qui inverse l'ordre des colonnes ou recrée un index avec un nom différent. |
Toujours relire le SQL, supprimer les changements cosmétiques (ordre, default identique reformulé). |
| Migration qui modifie des hash existants | Casse l'authentification utilisateur, casse les UMR SEPA, casse les empreintes audit. | Versionner le hash (password_version, iban_encryption_version) et migrer à l'usage. |
| Nouvelle FK sans index | JOIN en table scan. Sur la table flux en prod (>500 k lignes), un seul JOIN non indexé bloque la commande kohinos:ssa:execute. |
Ajouter explicitement $table->addIndex(['foo_id']) dans la même migration. |
$em->flush() dans up() |
Doctrine Migrations ne fournit qu'une connexion DBAL — pas d'UoW. L'appel casse au runtime, ou pire, écrit sans respecter les listeners (audit, hash). | Utiliser uniquement l'API Schema et $this->addSql(). Pour les data migrations, créer une commande Symfony idempotente. |
DROP TABLE dans up() sans archivage |
Perte définitive de données financières si la migration est jouée en prod par erreur. | Renommer en tablename_archived_YYYYMMDD, attendre une release de purge. |
NOT NULL sans DEFAULT ni backfill |
Échec sur table non vide. En SQLite la migration recrée la table et perd les lignes existantes sans erreur claire. | 1. Ajouter colonne nullable. 2. Backfill via commande. 3. Migration suivante NOT NULL. |
| Mélanger DDL et DML | Pas atomique en SQLite (qui n'a pas de DDL transactionnel propre). Une moitié peut être appliquée. | Une migration = DDL pur. Une commande = data. |
Checklist pré-commit migration
git add src/Migrations/
- ☐
down()non vide et inverse strictementup(). - ☐ Migration testée localement :
migrate→migrate prev→migratesans erreur. - ☐ Migration testée sur SQLite (
--env=test) ET MariaDB (--env=dev). - ☐
doctrine:schema:validatene signale plus d'écart. - ☐ Aucun
UPDATEouDELETEsansWHEREdansup(). - ☐ Toute nouvelle FK s'accompagne d'un index sur la colonne enfant.
- ☐ Pas de
$em->flush(), pas de service container appelé dansup(). - ☐ Si data migration nécessaire : commande dédiée idempotente avec
--dry-runpar défaut. - ☐ Tests financiers ciblés rejoués (
--testsuite=ssa,--testsuite=sepa). - ☐
CHANGELOG.mdmis à jour avec la migration (etCHANGELOG_USER.mdsi visible côté utilisateur).
Pour aller plus loin
src/Migrations/— historique complet (les trois dernières sont la référence de style).config/packages/doctrine_migrations.yaml— config Doctrine Migrations.config/packages/test/doctrine.yaml— bascule SQLite in-memory..claude/agents/doctrine-migration-reviewer.md— checklist du sous-agent reviewer.doc/TROUBLESHOOTING.md— erreurs Doctrine/SF7 fréquentes post-migration.- Conventions PHP & Doctrine
- Tests & qualité
- Invariants financiers