Bienvenue dans mon guide pratique sur l’utilisation des migrations dans Symfony 7.
Lors du développement d’applications, la gestion des modifications apportées à la structure de la base de données est cruciale. Symfony nous simplifie ce processus en utilisant le composant Doctrine Migrations, qui génère des fichiers de migration pour représenter ces modifications.
Au cours de ce guide pratique, nous explorerons les subtilités des migrations Symfony, leurs manipulations, la navigation entre-elles et aussi leur personnalisation sur mesure.
Disclaimer
Dans ce guide, je présente des exemples et des mises en situation qui servent exclusivement à explorer les possibilités offertes par les migrations, en mettant l’accent sur les classes de migrations et le composant DoctrineMigrationsBundle. Il est important de noter que ces exemples ne constituent pas nécessairement des recommandations de bonnes pratiques pour tous les cas évoqués. Chaque projet est unique, et il peut exister d’autres approches adaptées à des besoins spécifiques. De plus, il existe de nombreux autres aspects des migrations que je ne peux pas couvrir intégralement dans ce guide. Il est recommandé d’explorer davantage et de consulter la documentation officielle pour une compréhension approfondie de toutes les fonctionnalités liées aux migrations.
Ce guide a été rédigé quelques temps avant l’intégration des mises à jour de l’ORM Doctrine en 3.0 et de la version 16 de PostgreSQL.
Bien entendu ce guide reste toujours valable dans sa très grande globalité, j’ai cependant ajouté quelques commentaires « Update » pour vous signifiez les légères différences que vous pourriez rencontrer.
Préparation du projet de démo
Dans ce guide nous utiliserons
- PHP >= 8.2
- Composer
- Symfony CLI
- Docker pour PostgreSQL
Installation du projet
L’installation du nouveau projet avec la Symfony CLI :
symfony new DemoMigration
Entrer dans le projet :
cd DemoMigration
Installation des composants
Installation de l’ORM Doctrine
composer require symfony/orm-pack
L’installation de ce composant, va également générer le fichier compose.yaml
& compose.override.yaml
et la configuration de base pour une connexion PostgreSQL dans votre fichier .env
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
Installation de Maker
composer require --dev symfony/maker-bundle
Création d’une entité simple
Créons notre première entité avec maker dans la console :
symfony console make:entity
Avec les paramètres suivants :
- Nom de l’entité :
Movie
- Propriété 1 :
title string 255 no nullable
- Propriété 2 :
description text nullable
En suivant le déroulé interactif dans la console :
Class name of the entity to create or update (e.g. AgreeableChef):
> Movie
created: src/Entity/Movie.php
created: src/Repository/MovieRepository.php
Entity generated! Now lets add some fields!
You can always add more fields later manually or by re-running this command.
New property name (press <return> to stop adding fields):
> title
Field type (enter ? to see all types) [string]:
> string
Field length [255]:
> 255
Can this field be null in the database (nullable) (yes/no) [no]:
> no
updated: src/Entity/Test.php
Add another property? Enter the property name (or press <return> to stop adding fields):
> description
Field type (enter ? to see all types) [string]:
> text
Can this field be null in the database (nullable) (yes/no) [no]:
> yes
updated: src/Entity/Test.php
Add another property? Enter the property name (or press <return> to stop adding fields):
> [Press <return> to stop]
Success!
Next: When you're ready, create a migration with symfony console make:migration
Démarrage de la base de données
Notre dernière étape pour rendre notre projet opérationnel dans le cadre de cette démonstration consiste à lancer notre conteneur PostgreSQL avec Docker en utilisant la commande suivante :
docker compose up -d
Parfait !
Notre projet est enfin prêt pour la suite.
Utilisation de base
Gestion de la Base de données
Lors de la création de notre container PostgreSQL, la base de données a été créée automatiquement.
Mais il est important de connaître les commandes permettant de gérer la base de données :
Pour la création :
symfony console doctrine:database:create
Vous pouvez exécuter la commande pour être certain que votre DB a bien été créé, si telle est le cas vous obtiendrez une erreur vous disant que votre base de données existe déjà.
Pour la suppression :
symfony console doctrine:database:drop --force
Le flag
--force
est obligatoire, il s’agit d’une commande irréversible avec perte de toutes les données, ceci est une petite sécurité contre les erreurs d’inattentions.
Création première Migration
Maintenant que nous avons les composants installés, notre première entité créée, et notre base de données prête à être utilisé, nous pouvons démarrer.
Création de notre première migration (au choix) :
Commande a partir du composant Doctrine :
symfony console doctrine:migrations:diff
Commande a partir du composant Maker :
symfony console make:migration
Pour ma part j’utilise celle de Maker car plus rapide à taper, mais sous le capot Maker fait appel à celle de Doctrine, cela revient au même.
Un fichier de migration a été généré dans le dossier /migrations
avec un format de nom de fichier VersionXXX
les XXX représentent la date et l’heure de la création pour conserver un ordre cohérent et chronologique des versions de migrations.
À l’intérieur de ce fichier, nous pouvons voir les 3 méthodes qui ont été générées :
public function getDescription(): string
{
return 'Create Movie'; // Ajout manuellement de la description
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SEQUENCE movie_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE movie (id INT NOT NULL, title VARCHAR(255) NOT NULL, description TEXT DEFAULT NULL, PRIMARY KEY(id))');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('DROP SEQUENCE movie_id_seq CASCADE');
$this->addSql('DROP TABLE movie');
}
getDescription()
: Je vous encourage à inclure « Create Movie » dans l’instructionreturn
, comme illustré dans l’exemple ci-dessus. Cela facilitera l’identification rapide de l’objectif de chaque migration lors de l’affichage du récapitulatif de vos migrations.
up()
: Cette méthode est appelée lors de l’application de la migration.
Dans notre cas, elle implique la création de la séquence pour l’identifiant de notre table Movie (équivalent de l’auto-incrément pour MySQL) et la création de la table Movie avec les champs correspondants aux propriétés de notre entité.down()
: Cette méthode est appelée lors de l’annulation de la migration.
Elle implique la création du schéma public, la suppression de la séquence pour notre identifiant (auto-incrément) et la suppression de la table Movie.
Update : Selon la version de l’ORM et de PostgreSQL que vous utilisez, les requêtes peuvent être légèrement différentes.
Application des Migrations
Nous sommes prêts à exécuter notre première migration avec la commande :
symfony console doctrine:migrations:migrate
Une confirmation vous sera demandée, pour éviter cette demande systématique vous pouvez ajouter le flag
--no-interaction
à la fin (très utile lors de l’utilisation de celle-ci dans un script automatisé)
Super nous venons de jouer notre première migration !
Explorons maintenant quelques commandes utiles pour la gestion des migrations dans un projet Symfony.
Prenez le temps de vous familiariser avec ces commandes essentielles :
symfony console doctrine:migrations:current // Afficher la version de la migration en cours
symfony console doctrine:migrations:latest // Afficher la version de la dernière migration
symfony console doctrine:migrations:list // Afficher la liste de toutes les migrations et leurs statuts
symfony console doctrine:migrations:status // Afficher des informations sur l'état actuel des migrations et autres
Ces commandes vous seront utiles pour suivre le progrès de vos migrations et gérer efficacement l’évolution de votre base de données. N’hésitez pas à les utiliser selon vos besoins !
Réversion des Migrations
Dans la vie d’un projet, il peut être nécessaire de revenir sur des migrations pour diverses raisons. Pour cela, vous pouvez utiliser la commande suivante :
symfony console doctrine:migrations:migrate prev
Lorsque vous lancez cette commande immédiatement, vous pouvez rencontrer une erreur indiquant que le schéma public existe déjà. C’est une erreur connue avec Doctrine et PostgreSQL. Pour résoudre cela temporairement, remplacez dans la méthode down()
la ligne problématique (nous verrons plus tard comment gérer cela plus efficacement) :
public function down(Schema $schema): void
{
$this->addSql('CREATE SCHEMA public'); // Ligne générée qui cause l'erreur
$this->addSql('CREATE SCHEMA IF NOT EXISTS public'); // À remplacer par celle-ci
}
Après cette modification, vous devriez être en mesure d’utiliser la commande de réversion sans rencontrer d’erreurs.
Il est important de noter que l’exemple précédent était simple et visait à illustrer le processus de réversion d’une migration. Vous pouvez réappliquer la migration en utilisant l’une des deux commandes suivantes :
symfony console doctrine:migrations:migrate
symfony console doctrine:migrations:migrate next
L’alias
next
permet d’appliquer la migration suivante dans la séquence des migrations en attente
Il existe 4 alias sur la commande doctrine:migration:migrate
symfony console doctrine:migrations:migrate // Appliquer toutes les migrations qui n'ont pas encore été appliquées
symfony console doctrine:migrations:migrate first // Revenir avant la première migration
symfony console doctrine:migrations:migrate prev // Revenir à la migration précédente dans la séquence des migrations déjà appliquées
symfony console doctrine:migrations:migrate next // Appliquer la migration suivante dans la séquence des migrations en attente
symfony console doctrine:migrations:migrate latest // Appliquer toutes les migrations qui n'ont pas encore été appliquées
Ces commandes vous seront utiles pour naviguer efficacement entre vos migrations lors de l’évolution de votre base de données en fonction des besoins de votre projet.
Personnalisation
Maintenant que nous avons acquis une compréhension globale des migrations, de leur utilisation et de la navigation entre elles, explorons en détail les différentes méthodes disponibles dans ces migrations ainsi que leur cycle de vie.
Méthodes disponibles
Nous avons déjà vu les méthodes getDescription()
(utile lors de l’affichage de la liste), up()
pour appliquer les modifications et down()
pour revenir en arrière.
Cependant, il existe plusieurs autres méthodes que vous pouvez utiliser et surcharger pour personnaliser davantage votre migration :
isTransactional()
: Retourneztrue
pour activer les transactions etfalse
pour les désactiver
Il est recommandé de conserver letrue
par défaut. Les transactions permettent d’appliquer un ensemble de requêtes ou d’annuler cet ensemble si une seule retourne une erreur.
Il est important de noter une précaution de Doctrine : “sur certaine plateforme de base de données
isTransactional()
ne garantit pas que les déclarations sont enveloppées dans une seule transaction” voir source
preUp()
Cette méthode est déclenchée avant la méthodeup()
. C’est l’endroit idéal pour effectuer des opérations préliminaires avant d’appliquer les modifications principales.postUp()
Contrairement àpreUp()
, cette méthode intervient après l’exécution deup()
. Elle offre une opportunité de réaliser des tâches postérieures à la migration.preDown()
Similaire àpreUp()
, mais pour la méthodedown()
. Vous pouvez effectuer des opérations spécifiques avant de revenir en arrière.postDown()
De même, cette méthode est déclenchée après l’exécution dedown()
. Utilisez-la pour des opérations de nettoyage ou de post-traitement.
Vous avez également d’autres méthodes utilitaires :
addSql()
Nous l’avons déjà rencontré. Elle permet d’ajouter des requêtes à exécuter.getSql()
Récupère l’ensemble des requêtes générées par la migration.write()
Ajoute un message de notification dans la console, utile pour communiquer des informations importantes pendant le processus.warnIf()
Cette méthode ajoute un message d’avertissement dans la console si une condition spécifique est remplie.abortIf()
Elle lance une exception de typeAbortMigration
avec un message défini si une condition est remplieskipIf()
Similaire àabortIf()
, cette méthode lance une exception de typeSkipMigration
si une condition particulière est remplie.throwIrreversibleMigrationException()
Cette méthode lance une exception de typeIrreversibleMigration
avec un message. Vous devez l’appeler dans la méthodedown()
pour les migrations non réversibles.
Avec ces outils à votre disposition, vous pouvez personnaliser et optimiser le processus de migration selon les besoins spécifiques de votre projet Symfony. Explorez-les et adaptez-les à votre flux de travail.
Cycle de vie
Parfois, un schéma vaut mille mots, c’est pourquoi j’ai choisi de vous en présenter un pour illustrer le cycle de vie des migrations.
Note importante : j’ai choisi volontairement de simplifier certains détails complexes pour rendre le sujet plus accessible. Pour les plus expérimentés d’entre vous et dans un souci de précisions je vous renvoi vers le fichier d’exécutions des migrations (voir fichier)
Doctrine va donc executer un bloc try/catch pour l’intégralité de votre up ou votre down incluant les pre et les post.
Le processus débute en ouvrant une connexion pour preUp et up (ou preDown et down) et la ferme ensuite.
Enfin, Doctrine finira par exécuter votre code se trouvant dans postUp (ou postDown)
Il est important de noter que la connexion est fermée avant l’appel des postUp et postDown, ce qui rend impossible l’utilisation de addSql()
dans ces étapes.
Bien que vous ayez la possibilité d’ouvrir votre propre connexion, cette pratique n’est pas encouragée.
Évolution d’une propriété d’une entité
Avant de plonger dans la mise en pratique de de certaines méthodes, nous devons prééalablement peupler notre base de données avec au moins une ligne de données pour illustrer notre propos.
Vous pouvez attribuer n’importe quelle valeur dans title
, mais pour notre exemple, nous laisserons une valeur null
pour la propriété description
.
Personnellement, j’ai inséré manuellement une ligne dans ma base de données pour gagner du temps, comme ici.
Dans notre entité nous allons modifier l’attribut ORM\Column
de la propriété description
pour que le champ ne soit plus nullable
#[ORM\Column(type: Types::TEXT)]
private ?string $description = null;
Une fois cela fait, générons notre nouvelle migration avec la commande maker :
symfony console make:migration
Ajoutons une description à notre migration pour la postérité, dans le return
de notre getDescription()
comme ceci
public function getDescription(): string
{
return 'Update movie description not null';
}
Et essayons d’appliquer notre migration avec la commande
symfony console doctrine:migrations:migrate
Évidement elle ne passe pas, car la colonne description
contient une ligne avec la valeur null
, et nous demandons avec notre nouvelle migration que cela ne soit pas possible.
Pour y remédier nous devons ajouter un traitement en amont pour rendre compatible les données avec notre nouvelle migration.
Ajoutons donc une description
par defaut à la place des valeurs NULL
dans notre preUp()
avant la modification de notre colonne en NOT NULL
public function preUp(Schema $schema): void
{
$this->addSql('UPDATE movie SET description = \'To be determined\' WHERE description IS NULL');
}
Mais aussi, pour rendre rétro-compatible notre migration nous allons également ajouter un traitement à la fin de notre down()
(et non pas dans le postDown()
) pour remettre nos valeurs à null
.
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE movie ALTER description DROP NOT NULL');
// Ligne à ajouter pour revenir sur le même modele de donnée que l'ancienne version
$this->addSql("UPDATE movie SET description = NULL WHERE description = 'To be determined'");
}
Appliquons notre migration à nouveau, et cette fois-ci elle fonctionne bien avec notre champ description
qui était à null
et a bien la valeur « To be determined » après la migration.
Tentons une réversion pour vérifier que notre champ revient bien à null
symfony console doctrine:migrations:migrate prev
Encore une fois, cette satanée erreur de création de schéma.
Contentons nous cette fois de supprimer la ligne qui pose problème dans le down()
, promis on s’occupera définitivement de ce problème plus tard
public function down(Schema $schema): void
{
$this->addSql('CREATE SCHEMA public'); // Supprimez ou commentez cette ligne
}
Ça y est ça fonctionne et notre description est bien revenue à null
si vous vérifiez les données de votre table.
Avant d’appliquer à nouveau notre migration, j’aimerais ajouter des vérifications telle qu’un abort
dans le cas où nous ne serions pas sur une base de données PostgreSQL par exemple.
Pour cela je crée une fonction privée checkDatabasePlatform()
que j’appellerai dans mon preUp()
et mon preDown()
.
public function preUp(Schema $schema): void
{
$this->checkDatabasePlatform();
}
public function preDown(Schema $schema): void
{
$this->checkDatabasePlatform();
}
private function checkDatabasePlatform(): void
{
$isPostgreSQL = PostgreSQL100Platform::class === get_class($this->connection->getDatabasePlatform());
$this->abortIf(!$isPostgreSQL, 'Migration can only be executed safely on \'PostgreSQL\'.');
}
Je vérifie que la classe utilisée par Doctrine pour communiquer avec la plateforme de base de données est bien PostgreSQL100Platform
(Cette classe est dépréciée et devrait être remplacée par PostgreSQLPlatform
dans une version ultérieure de Doctrine mais actuellement c’est celle-ci qui est utilisée). Dans le cas contraire j’aurais mon propre message d’erreur très explicite : « Migration can only be executed safely on ‘PostgreSQL’. »
Update : Si vous utilisez la version 3.0 de l’ORM Doctrine, la classe utilisée est
PostgreSQLPlatform::class
Vous imaginez bien que je n’ai pas envie d’ajouter manuellement cette vérification à chaque génération de migration, c’est pourquoi nous allons voir ensemble comment personnaliser son template de migration.
Template personnalisé
Créez un fichier custom_template.tpl
que vous placez directement dans le dossier migrations
. Vous êtes libre de choisir l’emplacement de ce fichier, mais veillez à rester cohérent dans l’organisation de vos fichiers. (Il n’y a aucune recommandation particulière sur l’emplacement du fichier.)
<?php
declare(strict_types=1);
namespace <namespace>;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class <className> extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function preUp(Schema $schema): void
{
$this->checkDatabasePlatform();
}
public function up(Schema $schema): void
{
<up>
}
public function preDown(Schema $schema): void
{
$this->checkDatabasePlatform();
}
public function down(Schema $schema): void
{
<down>
}
private function checkDatabasePlatform(): void
{
$isPostgreSQL = PostgreSQL100Platform::class === get_class($this->connection->getDatabasePlatform());
$this->abortIf(!$isPostgreSQL, 'Migration can only be executed safely on \'PostgreSQL\'.');
}
}
Les variables <up>
et <down>
sont intentionnellement misent sans indentation car elles seront injectées automatiquement à la génération.
Pour que le template soit pris en charge, ajoutez au fichier de configuration de doctrine_migrations.yaml
le chemin de votre fichier custom_template.tpl
comme suit : (adaptez votre chemin si vous l’avez placé ailleurs, ou nommé différemment)
doctrine_migrations:
migrations_paths:
# namespace is arbitrary but should be different from App\Migrations
# as migrations classes should NOT be autoloaded
'DoctrineMigrations': '%kernel.project_dir%/migrations'
enable_profiler: false
custom_template: '%kernel.project_dir%/migrations/custom_template.tpl' # ligne de configuration à ajouter
Générez une migration vierge pour vérifier que notre template soit pris en compte avec la commande :
symfony console doctrine:migrations:generate
Si le template n’est pas pris en compte, videz le cache Symfony en exécutant la commande
symfony console cache:clear
et relancez la génération
Assurez-vous que votre template a bien été pris en compte en vérifiant la présence des méthodes preUp()
et preDown()
ainsi que votre fonction privée checkDatabasePlatform()
dans votre dernière migration.
C’est le cas ? Parfait !
Vous pouvez supprimer le fichier, vos prochaines générations de migrations ajouteront cette vérification automatiquement sans que vous ayez à vous en soucier.
Bonus
Erreur create schema public
Comme promis revenons sur la génération de CREATE SCHEMA public
dans le down()
des migrations, provoquant des erreurs systématiques.
Il s’agit d’un problème connu et depuis longtemps, sans entrer dans les détails, PostgreSQL crée automatiquement le schéma public, et Doctrine lors du processus down()
, n’est pas informé que le schéma existe déjà. Cela est dû à la logique de fonctionnement du DBAL.
Si vous désirez en apprendre plus sur cette problématique voici des liens de discussions sur github évoquant le sujet
Bien que ce problème complexe ne soit pas résolu à la racine, nous pouvons mettre en place une solution proposée par vudalstov, pour éviter de nous en préoccuper tout au long de notre projet.
La solution consiste à informer Doctrine de l’existence du schéma public dans le gestionnaire de schémas de la base de données.
Pour cela, créez un Listener dans src/Doctrine/EventListener
<?php
namespace App\Doctrine\EventListener;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\DBAL\Schema\PostgreSQLSchemaManager;
use Doctrine\ORM\Tools\Event\GenerateSchemaEventArgs;
use Doctrine\ORM\Tools\ToolEvents;
#[AsDoctrineListener(event: ToolEvents::postGenerateSchema, connection: 'default')]
class FixPostgreSQLDefaultSchemaListener
{
public function postGenerateSchema(GenerateSchemaEventArgs $args): void
{
$schemaManager = $args
->getEntityManager()
->getConnection()
->createSchemaManager();
if (!$schemaManager instanceof PostgreSQLSchemaManager) {
return;
}
$schema = $args->getSchema();
foreach ($schemaManager->listSchemaNames() as $namespace) {
if (!$schema->hasNamespace($namespace)) {
$schema->createNamespace($namespace);
}
}
}
}
Dans ce listener, nous vérifions la liste des noms des schémas dans le gestionnaire de schémas de la base de données. Si le schéma actuel n’a pas le namespace associé, nous lui créons. Cela informe Doctrine que nous avons conscience de l’existence de celui-ci, évitant ainsi qu’il ne le recréer lors de certaines opérations en l’occurrence dans le down.
Ainsi, avec cette solution, le problème est contourné et vous la ligne $this->addSql('CREATE SCHEMA public');
ne sera plus ajoutée dans les down()
de vos migrations.
Cheatsheet
Rappel des commandes relatives à doctrine et principalement les migrations utilisées dans cette démonstration avec une brève description associée.
Commandes | Description |
---|---|
doctrine:database:create | Création base de données |
doctrine:database:drop --force | Suppression base de données |
doctrine:migrations:generate | Génération de fichier de migration vierge |
doctrine:migrations:diff | Génération migration (commande Doctrine) |
make:migrations | Génération migration (commande Maker) |
doctrine:migrations:migrate | Appliquer toutes les migrations qui n’ont pas encore été appliquées |
doctrine:migrations:migrate next | Appliquer la migration suivante dans la séquence des migrations en attente |
doctrine:migrations:migrate prev | Revenir à la migration précédente dans la séquence des migrations déjà appliquées |
doctrine:migrations:migrate first | Revenir avant la première migration |
doctrine:migrations:migrate latest | Appliquer toutes les migrations qui n’ont pas encore été appliquées |
doctrine:migrations:current | Afficher la version de la migration en cours |
doctrine:migrations:latest | Afficher la version de la dernière migration |
doctrine:migrations:list | Afficher la liste de toutes les migrations et leurs statuts |
doctrine:migrations:status | Afficher des informations sur l’état actuel des migrations et autres |
Conclusion
Félicitations, vous avez maintenant une compréhension approfondie de l’utilisation et manipulations des migrations dans Symfony 7.
En résumé, nous avons exploré la création, la modification, la navigation, et la personnalisation des migrations Symfony. J’espère vous avoir fourni les bases nécessaires pour maîtriser cette compétence.
N’hésitez pas à pousser vos connaissances plus loin en explorant les fonctionnalités avancées des migrations, telles que la personnalisation des méthodes et l’utilisation de templates sur mesure, pour adapter cet outil puissant à vos besoins spécifiques.
Merci de m’avoir suivi tout au long de cette démonstration, et je vous souhaite une excellente continuation dans votre exploration de Symfony et de ses fonctionnalités.