Doctrine Migrations — schéma versionné, zero-downtime
TL;DR —
DoctrineMigrationsBundlegénère des classesVersion*représentant un diff schéma. Chaque migration est versionnée Git, idempotente, et exécutée pardoctrine:migrations:migrate. La stratégie production "zero downtime" exige des migrations rétro-compatibles : expand → migrate data → contract.
Mental model — ASCII diagram + analogy
Analogie : Migrations = journal de bord d'un capitaine. Chaque entrée est datée (Version20240115120000), signée, immuable. Le navire (DB) avance ou recule entrée par entrée.
Working schema (entities) Live DB schema
│ │
│ │
▼ ▼
schema:create (in-memory) ───────► diff ───► generate migration
│
▼
MigrationsRepository
(folder migrations/)
│
migrate ─────────────┤
│
▼
doctrine_migration_versions
(table de tracking)Chaque migration a up() (forward) et down() (rollback). La table doctrine_migration_versions stocke version, executed_at, execution_time.
Code minimal — realistic snippet
composer require doctrine/doctrine-migrations-bundle# config/packages/doctrine_migrations.yaml
doctrine_migrations:
migrations_paths:
'DoctrineMigrations': '%kernel.project_dir%/migrations'
enable_profiler: false
transactional: true # chaque migration wrappée
all_or_nothing: true # rollback tout si une échoue
check_database_platform: true
storage:
table_storage:
table_name: 'doctrine_migration_versions'
version_column_length: 191# Génère depuis le diff entities ↔ DB
php bin/console doctrine:migrations:diff
# Applique
php bin/console doctrine:migrations:migrate --no-interaction
# Rollback à une version précise
php bin/console doctrine:migrations:execute 'DoctrineMigrations\Version20240115120000' --down
# Status
php bin/console doctrine:migrations:status --show-versions<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20240115120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add nullable status column to authors with default + backfill';
}
public function up(Schema $schema): void
{
// EXPAND : ajout colonne nullable, pas de contrainte stricte
$this->addSql('ALTER TABLE authors ADD status VARCHAR(20) DEFAULT NULL');
$this->addSql("CREATE INDEX idx_authors_status ON authors(status)");
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX idx_authors_status ON authors');
$this->addSql('ALTER TABLE authors DROP status');
}
public function isTransactional(): bool
{
// MySQL : DDL auto-commit donc transactional sans effet sur ALTER.
// Postgres : DDL transactionnel → true utile.
return true;
}
}<?php
// Migration DATA pure (pas DDL)
final class Version20240115130000 extends AbstractMigration
{
public function up(Schema $schema): void
{
$this->addSql("UPDATE authors SET status = 'active' WHERE status IS NULL");
}
public function down(Schema $schema): void
{
$this->addSql("UPDATE authors SET status = NULL WHERE status = 'active'");
}
}Patterns courants — 3–6 patterns
- Expand → Migrate → Contract : pour zero-downtime, 3 déploiements distincts.
- Expand : ajout colonne nullable / nouvelle table.
- Migrate data : backfill via migration data ou job batch.
- Contract : drop l'ancien champ après que tout le code l'ait abandonné.
- Migrations data via Messenger : pour gros volumes, dispatcher un message asynchrone depuis la migration plutôt qu'un
UPDATEbloquant. - Migration "no-op" en dev : sur projet ancien,
--allow-no-migrationlors d'init pour synchroniser sans rejouer 200 migrations. - Squash : périodiquement, regrouper N anciennes migrations en
Version<date>_baseline.php+ ré-init de la table de tracking. Documenter le process. - Multi-EM :
doctrine:migrations:migrate --em=auditavec configurationmigrations_pathsdistinct par EM. - Préfixe métier : namespace
App\Migrations\Tenant\ou similaire pour différencier sub-domains dans monorepo.
Versions — Symfony 5.4 / 6.4 / 7.x
| Version | Bundle | Notes |
|---|---|---|
| Symfony 5.4 | DoctrineMigrationsBundle 3.x | Migrations 3.x, signature up(Schema) retournant void. |
| Symfony 6.4 | DMB 3.3+ | transactional configurable, support isTransactional() par migration. |
| Symfony 7.x | DMB 3.4+ | DBAL 4 obligatoire, signatures stricte. getSql() array-shape typé. |
| Doctrine Migrations 4.x (en chemin) | — | Schema based diff évolue, support natif des Enum types. |
doctrine:schema:update --forceest interdit en prod : utiliser migrations.- L'ancienne config
doctrine_migrations.dir_name(DMB 2) →migrations_paths(DMB 3).
Pitfalls — 5–8 concrete traps
- Diff faux positif : différences de collation, ordre de colonnes ou de définition
BIGINT(20)→ diff génère du bruit. Utiliser--filter-expressionou aligner la config DBAL. down()non testée : 90% des équipes ne testent jamais le rollback ; le jour J ça échoue. Soit on l'écrit réellement, soit on assume forward-only et on documente.- Migration data dans une grosse transaction :
UPDATEsur 50M lignes lock toute la table. Préférer batchesLIMIT 10000+ sleep, ou job async. - MySQL DDL auto-commit :
ALTER TABLEne peut pas rollback.all_or_nothingest trompeur sur MySQL. - Conflits de timestamp : deux devs créent
Version20240115120000en parallèle → conflit Git silencieux. Utiliser timestamp à la seconde près + revue PR. - Schema diff sur Postgres types
JSONB/ENUM: Doctrine peut générer des changements répétés. Alignercommentetdefinitioncôté entité. - Renames : un
ALTER COLUMN RENAMEest interprété comme DROP+ADD → perte de données. Toujours review SQL généré. composer installpost-deploy : migrations exécutées avant que le code ne soit là → erreur de classe non trouvée. Ordonner : code → cache:clear → migrations:migrate → reload services.enable_profiler: trueen prod : leak mémoire sur grosses migrations.
Comment un staff engineer raisonne face à une migration
Avant d'écrire la moindre ligne SQL, un senior pose trois questions, dans l'ordre :
1. « Quel verrou cette opération pose-t-elle, et pendant combien de temps ? » C'est la question qui décide si un déploiement est un non-événement ou un incident. Le coût d'une migration ne se mesure pas en lignes de SQL mais en niveau de lock × durée × trafic concurrent. Repère mental par moteur :
| Opération | PostgreSQL | MySQL/InnoDB |
|---|---|---|
ADD COLUMN ... NULL (sans default volatile) | métadonnée, instantané (PG 11+) | INSTANT (8.0.12+), sinon copie |
ADD COLUMN ... DEFAULT <const> | instantané (PG 11+) | INSTANT (8.0.12+) |
ADD COLUMN ... DEFAULT <volatile/now()> | réécrit toute la table (ACCESS EXCLUSIVE) | copie complète |
CREATE INDEX | SHARE lock → bloque les écritures | bloque écritures (sauf ALGORITHM=INPLACE) |
CREATE INDEX CONCURRENTLY | ne bloque pas, hors transaction, 2 passes | n/a (utiliser pt-online-schema-change/gh-ost) |
ALTER COLUMN TYPE | souvent réécriture + ACCESS EXCLUSIVE | copie complète |
ADD NOT NULL sur colonne existante | scan complet sous lock (mitigé par CHECK ... NOT VALID + VALIDATE) | copie complète |
ADD FOREIGN KEY | SHARE ROW EXCLUSIVE + scan validation | copie / scan |
Le réflexe senior : sur Postgres, ajouter une NOT NULL se fait en deux temps — ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID (prise de lock courte), puis VALIDATE CONSTRAINT (scan sans bloquer les écritures), et seulement ensuite SET NOT NULL qui réutilise la contrainte déjà validée.
2. « Cette migration est-elle rétro-compatible avec le code actuellement en prod ? » Pendant un déploiement rolling/canary, l'ancien et le nouveau code tournent en même temps contre le même schéma. Une migration ne doit jamais casser le code de la version N-1. C'est tout le sens de expand/contract : on n'enlève jamais quelque chose que du code vivant lit encore. Règle d'or : une migration n'est sûre que si elle est compatible avec les deux versions du code qui l'entourent.
3. « Comment j'annule si ça tourne mal à 3h du matin ? » Trois stratégies, à choisir explicitement :
down()réelle et testée : possible pour du DDL réversible (add/drop colonne). Mais undown()quiDROP COLUMNdétruit les données écrites depuis — rarement le bon rollback en prod.- Forward-only : on n'annule jamais, on corrige par une migration suivante. Stratégie dominante dans les boîtes matures. Le
down()devient documentaire (lève une exception explicite). - Restore from backup : seul vrai filet pour les migrations destructrices. D'où le
snapshot DBobligatoire en pré-déploiement.
public function down(Schema $schema): void
{
// Forward-only assumé : on documente que le rollback passe par une
// migration corrective + restore, jamais par un down() qui perd des données.
throw new IrreversibleMigration(
'Migration forward-only : voir runbook rollback (snapshot + migration corrective).'
);
}Le piège mental n°1 : raisonner « est-ce que le SQL est correct ? » au lieu de « est-ce que ce SQL est sûr sous charge, en rolling deploy, avec rollback possible ? ». Le premier est un problème de junior ; le second est le métier.
Observabilité & garde-fous production
Une migration en prod n'est pas un acte aveugle. Ce qu'un senior instrumente :
- Timeout de lock défensif : avant un
ALTERrisqué, poserSET lock_timeout = '5s'(Postgres) /SET SESSION lock_wait_timeout = 5(MySQL). Mieux vaut une migration qui échoue vite et proprement qu'unALTERqui prend la file d'attente derrière une transaction longue et gèle la table. Sur Postgres, ajouterSET statement_timeoutpour les backfills.
public function up(Schema $schema): void
{
$this->addSql("SET lock_timeout = '5s'");
$this->addSql('ALTER TABLE authors ADD status VARCHAR(20) DEFAULT NULL');
}- Durée trackée :
doctrine:migrations:migrate --query-timelogge le temps par requête ; la tabledoctrine_migration_versions.execution_timehistorise. Alerter si une migration dépasse un budget (ex. 30 s). - Surveillance pendant l'exécution : un backfill long se surveille avec
pg_stat_activity(requêtes bloquées), la p99 applicative (Datadog/Grafana), et la réplication (lag du standby — unUPDATEmassif sature le WAL et fait diverger les replicas). - Kill switch : un backfill via Messenger se met en pause en stoppant le worker (
messenger:stop-workers) sans toucher à la prod ; unUPDATEgéant inline ne se met pas en pause — argument décisif pour le backfill asynchrone.
Testing — phpunit / KernelTestCase
Trois niveaux de test, du moins au plus coûteux :
- Lint statique :
doctrine:migrations:up-to-dateen CI échoue si une entité a divergé du schéma migré (undiffnon vide signifie une migration manquante). C'est le garde-fou le moins cher et le plus rentable. - Round-trip up/down : migrer jusqu'au bout, redescendre jusqu'à zéro, puis re-migrer. Si une
down()est cassée, la troisième étape explose. - Backfill idempotence : rejouer le même message de backfill deux fois et asserter que la seconde exécution n'écrit rien (rows affectées = 0).
Pour piloter les commandes Migrations en test, on les récupère via l'Application Symfony plutôt que de construire MigrateCommand à la main (il a besoin d'un DependencyFactory câblé et d'une Application parente) :
<?php
namespace App\Tests\Migrations;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Tester\CommandTester;
final class MigrationsRoundTripTest extends KernelTestCase
{
private function runMigration(string $version): string
{
$app = new Application(self::bootKernel());
$app->setAutoExit(false);
$cmd = $app->find('doctrine:migrations:migrate');
$tester = new CommandTester($cmd);
// setInputs([]) répond aux confirmations interactives résiduelles
$tester->execute(['version' => $version, '--no-interaction' => true]);
return $tester->getDisplay();
}
public function testUpThenDownThenUpIsConsistent(): void
{
$this->runMigration('latest'); // tout en haut
$this->runMigration('first'); // redescend à zéro
$out = $this->runMigration('latest'); // re-monte : valide les down()
self::assertStringContainsString('migrated', $out);
}
public function testSchemaIsUpToDateAfterMigrate(): void
{
$app = new Application(self::bootKernel());
$app->setAutoExit(false);
$this->runMigration('latest');
$tester = new CommandTester($app->find('doctrine:migrations:up-to-date'));
$exit = $tester->execute([]); // exit 0 = aucun diff entité ↔ schéma
self::assertSame(0, $exit, $tester->getDisplay());
}
}CI : DB éphémère par job (service postgres du runner), doctrine:database:create && doctrine:migrations:migrate avant chaque suite. Garder un dataset minimal. Important : tester sur le même moteur (et idéalement la même version majeure) que la prod — un round-trip vert sur SQLite ne prouve rien sur le comportement DDL/locking de Postgres ou MySQL.
⚠️ Anti-pattern courant : tester les migrations sur SQLite « parce que c'est rapide » puis déployer sur Postgres.
CREATE INDEX CONCURRENTLY, les advisory locks, l'auto-commit DDL MySQL, les typesJSONB/ENUMn'existent pas ou se comportent différemment. La rapidité gagnée se paie en incident de prod.
🎬 Cas d'usage concrets
Scénario 1 — Migration zero-downtime sur core bancaire
Une banque en ligne française opère un cœur de comptes courants servant 800 000 clients avec un SLA de 99,98 %. Renommer la colonne solde en solde_disponible impose un protocole expand/contract en quatre déploiements distincts. La première migration ajoute la nouvelle colonne solde_disponible nullable, sans toucher à solde. Le déploiement applicatif suivant écrit dans les deux colonnes simultanément (dual-write côté entité Doctrine). Une migration de backfill, exécutée hors heures de pointe via un job batch idempotent et chunkifié (50 000 lignes par batch, commit explicite), recopie solde vers solde_disponible puis vérifie la cohérence sur un échantillon de 1 % avant validation par le DBA d'astreinte. Le déploiement applicatif suivant supprime la lecture sur solde. Enfin, la migration contract supprime la colonne solde et ses index. Aucun verrou long n'est posé sur la table compte, et la latence p99 reste sous 80 ms pendant tout le processus, surveillée par Datadog.
Scénario 2 — SaaS RH : ajout de colonne avec backfill métier
Un SaaS RH français comptant 1 200 clients (collectivités, ESN, PME industrielles) déploie une nouvelle fonctionnalité de gestion d'astreintes nécessitant un champ taux_majoration sur la table convention_collective. La migration ajoute la colonne avec une valeur par défaut neutre (1.00), puis une seconde migration ouvre un événement métier consommé par un handler Messenger : pour chaque convention, le handler appelle un service de référentiel (mapping IDCC → taux légal) et persiste le taux correct. Les conventions sans correspondance déclenchent une tâche dans le backoffice support pour saisie manuelle. La migration ne bloque pas le déploiement applicatif ; les écrans RH affichent un état "taux par défaut" tant que le backfill n'est pas terminé. L'équipe orchestre la migration sur les 1 200 tenants en utilisant doctrine:migrations:migrate ciblé par connexion (architecture database-per-tenant) et un dashboard Grafana suit la progression.
Scénario 3 — E-commerce : évolution de schéma sur marketplace haute-volumétrie
Une marketplace e-commerce de mode (3 millions de commandes par an) doit splitter la table commande en commande et ligne_commande après deux ans d'exploitation où les lignes étaient stockées en JSON dans une colonne items. La migration suit le pattern strangler : création de ligne_commande avec FK vers commande, lifecycle Doctrine qui dual-écrit dans le JSON et la nouvelle table à chaque nouvelle commande, puis migration de backfill historique exécutée pendant deux semaines de nuit (jobs Messenger sur tranches d'un mois). Le code applicatif bascule en lecture sur la nouvelle table via un feature flag par cohorte (1 %, 10 %, 50 %, 100 %) piloté par Unleash. Une fois la cohérence validée (compteurs de différences à zéro sur 7 jours consécutifs), une dernière migration drop la colonne JSON et libère 80 Go d'espace disque. L'équipe utilise systématiquement --write-sql pour générer un patch SQL revu par le DBA avant exécution en production.
🛠️ Exemple end-to-end
Use case : ajout d'une colonne taux_majoration à convention_collective en zero-downtime, avec backfill batch idempotent dispatché par message Messenger.
<?php
// migrations/Version20260520120000.php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260520120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Ajoute convention_collective.taux_majoration nullable + index partiel';
}
public function up(Schema $schema): void
{
$this->addSql(<<<SQL
ALTER TABLE convention_collective
ADD COLUMN taux_majoration NUMERIC(5,2) NULL DEFAULT NULL
SQL);
$this->addSql(<<<SQL
CREATE INDEX CONCURRENTLY idx_cc_taux_null
ON convention_collective (idcc)
WHERE taux_majoration IS NULL
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX IF EXISTS idx_cc_taux_null');
$this->addSql('ALTER TABLE convention_collective DROP COLUMN taux_majoration');
}
public function isTransactional(): bool
{
return false; // CREATE INDEX CONCURRENTLY PostgreSQL hors tx
}
}
// src/Application/Backfill/Message/BackfillTauxMajoration.php
namespace App\Application\Backfill\Message;
final readonly class BackfillTauxMajoration
{
public function __construct(public string $idcc) {}
}
// src/Application/Backfill/Handler/BackfillTauxMajorationHandler.php
namespace App\Application\Backfill\Handler;
use App\Application\Backfill\Message\BackfillTauxMajoration;
use App\Domain\Reglementaire\IdccCatalog;
use Doctrine\DBAL\Connection;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final readonly class BackfillTauxMajorationHandler
{
public function __construct(
private Connection $db,
private IdccCatalog $catalog,
private LoggerInterface $logger,
) {}
public function __invoke(BackfillTauxMajoration $msg): void
{
$taux = $this->catalog->tauxLegalPour($msg->idcc);
if ($taux === null) {
$this->logger->warning('IDCC sans taux référentiel', ['idcc' => $msg->idcc]);
return; // idempotent : laisse NULL pour saisie manuelle backoffice
}
$updated = $this->db->executeStatement(
'UPDATE convention_collective SET taux_majoration = :t
WHERE idcc = :i AND taux_majoration IS NULL',
['t' => $taux, 'i' => $msg->idcc],
);
$this->logger->info('Backfill ok', ['idcc' => $msg->idcc, 'rows' => $updated]);
}
}
// src/Infrastructure/Console/PlanifierBackfillCommand.php
namespace App\Infrastructure\Console;
use App\Application\Backfill\Message\BackfillTauxMajoration;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsCommand(name: 'app:backfill:taux-majoration')]
final class PlanifierBackfillCommand extends Command
{
public function __construct(private Connection $db, private MessageBusInterface $bus)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$idccs = $this->db->fetchFirstColumn(
'SELECT DISTINCT idcc FROM convention_collective WHERE taux_majoration IS NULL'
);
foreach ($idccs as $idcc) {
$this->bus->dispatch(new BackfillTauxMajoration((string) $idcc));
}
$output->writeln(sprintf('<info>%d backfill(s) planifié(s)</info>', count($idccs)));
return Command::SUCCESS;
}
}Quand utiliser / éviter
Utiliser quand :
- Schéma versionné, équipes plurielles.
- Production multi-env (dev / staging / prod).
Éviter quand :
- Prototypes < 1 semaine :
schema:updateok temporairement. - Données analytiques pures gérées par dbt / Liquibase côté data engineering.
Zero-downtime deployment recipe (cheatsheet)
Pour un changement breaking (rename de colonne name → full_name) :
| Étape | Code déployé | Migration appliquée | Trafic |
|---|---|---|---|
| 1. Expand | ajoute full_name (nullable), continue à écrire dans name | ALTER TABLE ... ADD full_name VARCHAR(255) NULL | 100% |
| 2. Dual-write | écrit dans les deux colonnes, lit name | aucune | 100% |
| 3. Backfill | idem | UPDATE ... SET full_name = name WHERE full_name IS NULL (par lots) | 100% |
| 4. Switch read | lit full_name, écrit dans les deux | aucune | 100% |
| 5. Stop dual-write | écrit full_name seulement | aucune | 100% |
| 6. Contract | code ne référence plus name | ALTER TABLE ... DROP name | 100% |
Cela paraît verbeux, mais évite l'indisponibilité. Chaque étape est un déploiement, idéalement automatisé.
Advanced : advisory locks Postgres pour migrations concurrentes
En cluster multi-instances, deux instances pourraient lancer la même migration. Solution :
public function preUp(Schema $schema): void
{
$this->connection->executeStatement('SELECT pg_advisory_lock(2937429)');
}
public function postUp(Schema $schema): void
{
$this->connection->executeStatement('SELECT pg_advisory_unlock(2937429)');
}DoctrineMigrationsBundle 3.3+ fournit nativement un mécanisme de lock via storage.table_storage.locked_until.
Multi-EM / multi-DB migrations
doctrine:
dbal:
default_connection: default
connections:
default: { url: '%env(DATABASE_URL)%' }
audit: { url: '%env(AUDIT_DATABASE_URL)%' }
orm:
default_entity_manager: default
entity_managers:
default: { connection: default, mappings: { App: ~ } }
audit: { connection: audit, mappings: { Audit: ~ } }
doctrine_migrations:
migrations_paths:
'DoctrineMigrations\App': '%kernel.project_dir%/migrations/app'
'DoctrineMigrations\Audit': '%kernel.project_dir%/migrations/audit'php bin/console doctrine:migrations:migrate --em=default
php bin/console doctrine:migrations:migrate --em=auditChaque EM a sa propre table doctrine_migration_versions, son propre dossier de migrations.
CI/CD : checklist déploiement
- Build artefact + tests passent.
- Backup DB (snapshot RDS, dump pg).
- Deploy code en mode "maintenance" si breaking migration.
php bin/console doctrine:migrations:status --no-interaction.php bin/console doctrine:migrations:migrate --no-interaction --query-time --allow-no-migration.- Health check sur endpoint applicatif.
- Bascule trafic (canary / blue-green).
- Si OK depuis 10 min : commit ; sinon rollback code (et idéalement la migration).
🏋️ Exercices
Exercice 1 — Round-trip honnête (implement)
Objectif : écrire une migration add column + index dont la down() est réellement symétrique, et prouver la symétrie par un test up→down→up vert. Indice/Solution : ADD COLUMN ... NULL + CREATE INDEX dans up() ; DROP INDEX puis DROP COLUMN dans down() (ordre inverse). Reprendre le MigrationsRoundTripTest de la section Testing ; faire échouer le test volontairement en oubliant le DROP INDEX pour voir la troisième passe exploser sur « index déjà existant ».
Exercice 2 — Rename zero-downtime complet (production-grade)
Objectif : exécuter le rename name → full_name du cheatsheet de bout en bout, en simulant l'ancien et le nouveau code tournant en parallèle. Indice/Solution : 6 étapes / déploiements. Côté entité, encapsuler le dual-write dans un lifecycleCallback ou un mappeur dédié ; jamais dans le contrôleur. Le backfill se fait par lots (UPDATE ... WHERE full_name IS NULL LIMIT 10000 en boucle, ou message Messenger par tranche d'IDs). Critère de réussite : à aucune étape un SELECT name ou SELECT full_name ne renvoie une colonne absente pour l'une des deux versions de code.
Exercice 3 — NOT NULL sans lock sur Postgres (production-grade)
Objectif : ajouter une contrainte NOT NULL sur une colonne de 20M lignes existantes sans poser de lock long en écriture. Indice/Solution : trois migrations distinctes — (1) ADD CONSTRAINT chk CHECK (col IS NOT NULL) NOT VALID, (2) VALIDATE CONSTRAINT chk (scan concurrent, pas de lock écriture), (3) ALTER COLUMN col SET NOT NULL (Postgres 12+ réutilise la contrainte validée → pas de re-scan), puis DROP CONSTRAINT chk. Mesurer le lock avec pg_locks pendant chaque étape pour prouver l'absence de ACCESS EXCLUSIVE durable.
Exercice 4 — Backfill idempotent et reprenable (production-grade)
Objectif : transformer un UPDATE géant en backfill Messenger chunkifié, idempotent, et qui peut reprendre après crash sans double-traitement. Indice/Solution : message = une tranche (idMin, idMax). Le handler fait UPDATE ... WHERE id BETWEEN :min AND :max AND <col> IS NULL — la clause IS NULL garantit l'idempotence (rejouer n'écrit rien). Un Command planifie les tranches en lisant le MIN/MAX(id). Reprise = relancer le worker : les tranches déjà faites sont des no-op. Bonus : --query-time + log du nombre de lignes par tranche pour suivre la progression sur Grafana.
Exercice 5 — Break then fix : la migration qui gèle la prod (break → fix)
Objectif : reproduire un incident — un ADD COLUMN ... NOT NULL DEFAULT now() sur grosse table qui réécrit toute la table sous ACCESS EXCLUSIVE et fait timeout les requêtes applicatives — puis le corriger. Indice/Solution : sur une table volumineuse, la version naïve prend un lock exclusif le temps de la réécriture. Fix : (1) ADD COLUMN ... NULL (métadonnée, instantané), (2) backfill par lots de la valeur, (3) SET DEFAULT + SET NOT NULL via la technique CHECK NOT VALID/VALIDATE de l'exercice 3. Ajouter SET lock_timeout = '3s' pour que l'échec soit rapide et observable plutôt que silencieux.
Exercice 6 — Migrations concurrentes en cluster (break → fix)
Objectif : provoquer deux instances lançant la même migration au même instant (race au déploiement multi-pods) et garantir qu'une seule s'exécute. Indice/Solution : reproduire en lançant deux doctrine:migrations:migrate en parallèle pointant sur la même DB. Observer soit un crash, soit un double-exécution. Fix : activer le lock natif de DMB 3.3+ (storage.table_storage.locked_until via migrations:migrate qui prend un verrou de tracking), ou poser un pg_advisory_lock en preUp()/postUp(). Vérifier que la seconde instance attend puis voit la migration déjà appliquée et ne fait rien.
🎤 En entretien
Q : « Comment renommer une colonne en production sans downtime ? » R : On ne RENAME jamais directement (souvent interprété comme DROP+ADD, et incompatible avec le code N-1). On applique expand/contract : ajouter la nouvelle colonne nullable, dual-write dans les deux, backfill par lots, basculer la lecture, arrêter le dual-write, puis drop l'ancienne — chaque étape étant un déploiement rétro-compatible.
Q : « all_or_nothing: true te protège-t-il sur MySQL ? » R : Non, c'est un piège. MySQL auto-commit le DDL (ALTER TABLE), donc un ALTER déjà passé ne sera pas rollback si une instruction suivante échoue — la transaction ne couvre que le DML. Sur Postgres le DDL est transactionnel, donc là oui. C'est pourquoi on garde une migration = un changement atomique sur MySQL, et qu'on s'appuie sur les backups, pas sur le rollback transactionnel.
Q : « Pourquoi CREATE INDEX CONCURRENTLY doit-il sortir de la transaction de migration ? » R : Parce qu'il ne peut pas tourner dans un bloc transactionnel (il fait deux passes et a besoin de commit intermédiaire pour ne pas bloquer les écritures). Il faut donc isTransactional(): false sur cette migration, et la garder seule — sinon une autre instruction casse l'atomicité qu'on croyait avoir.
Q : « Faut-il toujours écrire le down() ? » R : Question de stratégie, pas de dogme. Dans une équipe mature on est souvent forward-only : le rollback passe par une migration corrective + restore de snapshot, pas par un down() qui détruit les données écrites depuis. Un down() non testé est un faux filet de sécurité — pire que pas de filet. On choisit explicitement : réversible et testé, ou irréversible documenté.
Liens
- Doctrine Migrations — https://www.doctrine-project.org/projects/migrations.html
- DoctrineMigrationsBundle — https://symfony.com/bundles/DoctrineMigrationsBundle/current/index.html
- "Zero downtime deployments" — Strong Migrations / Multiple-step DDL patterns.
- Article PrettyPrinted — Expand/Contract pattern.
- GitHub Doctrine Migrations issues :
transactional+ Postgres advisory locks. - "Online schema changes" — gh-ost, pt-online-schema-change pour MySQL.
- "Database migrations done right" — Martin Fowler.