documentation
Kohinos v3 Doc générée le commit 8d60b1904

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é
(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"]
Pipeline minimum à dérouler avant tout commit de migration. Aucun de ces neuf points n'est optionnel.

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érationMariaDBSQLiteImpact
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

⚠ Sécurité financière — non négociable
  • Toute migration impactant des données financières (soldes, opérations, mandats SEPA, items SSA) doit avoir un aperçu --dry-run ou un script PHP de validation associé.
  • JAMAIS de DROP COLUMN sur 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/DELETE sans WHERE, 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 sans down() 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 :

Doctrine attributes uniquement

⚠ Mapping 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-patternPourquoi 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

À cocher avant chaque git add src/Migrations/
  • ☐ down() non vide et inverse strictement up().
  • ☐ Migration testée localement : migrate → migrate prev → migrate sans erreur.
  • ☐ Migration testée sur SQLite (--env=test) ET MariaDB (--env=dev).
  • ☐ doctrine:schema:validate ne signale plus d'écart.
  • ☐ Aucun UPDATE ou DELETE sans WHERE dans up().
  • ☐ Toute nouvelle FK s'accompagne d'un index sur la colonne enfant.
  • ☐ Pas de $em->flush(), pas de service container appelé dans up().
  • ☐ Si data migration nécessaire : commande dédiée idempotente avec --dry-run par défaut.
  • ☐ Tests financiers ciblés rejoués (--testsuite=ssa, --testsuite=sepa).
  • ☐ CHANGELOG.md mis à jour avec la migration (et CHANGELOG_USER.md si visible côté utilisateur).

Pour aller plus loin

Sources & liens
  • 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