Maîtriser les Migrations avec Symfony : Guide Pratique

Maîtriser les Migrations avec Symfony : Guide Pratique

Symfony, Doctrine, Migration - lundi 29 janvier 2024


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 simplifie ce processus en utilisant le composant Doctrine Migrations, qui génère des fichiers de migration pour représenter les changements de schéma.

Au cours de cette démonstration, nous explorerons les étapes clés, de la création d'une entité à l'application des migrations, en mettant l'accent sur l'utilité des fichiers de migration générés.

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.

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 Symfony : 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 : 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éé.

Mais il est important de connaitre les commandes permettant de gérer la base de données :

  • Pour la création : symfony console doctrine:database:create

  • Pour la suppression : symfony console doctrine:database:drop --force

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

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'instruction return, 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.

Application des Migrations

Nous sommes prêts à exécuter notre première migration avec la commande : symfony console doctrine:migrations:migrate

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 :

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

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() : Retournez true pour activer les transactions et false pour les désactiver Il est recommandé de conserver le true par défaut. Les transactions permettent d'appliquer un ensemble de requêtes ou d'annuler cet ensemble si une seule retourne une erreur.
  • preUp() : Cette méthode est déclenchée avant la méthode up(). 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 de up(). Elle offre une opportunité de réaliser des tâches postérieures à la migration.
  • preDown() : Similaire à preUp(), mais pour la méthode down(). 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 de down(). 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 type AbortMigration avec un message défini si une condition est remplie
  • skipIf() Similaire à abortIf(), cette méthode lance une exception de type SkipMigration si une condition particulière est remplie.
  • throwIrreversibleMigrationException() Cette méthode lance une exception de type IrreversibleMigration avec un message. Vous devez l'appeler dans la méthode down() 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'."

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 mise 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
symfony console doctrine:database:create Création base de données
symfony console doctrine:database:drop --force Suppression base de données
symfony console doctrine:migrations:generate Génération migration vierge
symfony console doctrine:migrations:diff Génération migration (commande Doctrine)
symfony console make:migrations Génération migration (commande Maker)
symfony console doctrine:migrations:migrate Appliquer toutes les migrations qui n'ont pas encore été 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 prev Revenir à la migration précédente dans la séquence des migrations déjà appliquées
symfony console doctrine:migrations:migrate first Revenir avant la première migration
symfony console doctrine:migrations:migrate latest Appliquer toutes les migrations qui n'ont pas encore été appliquées
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 le statut détaillé des migrations

Conclusion

Félicitations, vous avez maintenant une compréhension approfondie de l'utilisation des migrations dans Symfony 7.

Les fichiers de migration générés automatiquement sont des outils puissants pour gérer l'évolution de votre schéma de base de données de manière organisée.

En récapitulant, la création, l'application et la réversion des migrations sont des aspects clés du processus de développement Symfony.

Les fichiers de migration agissent comme des instantanés de vos changements de modèle de données, permettant un suivi clair et une gestion cohérente des versions de votre base de données.

N'hésitez pas à explorer davantage les fonctionnalités avancées des migrations, telles que la personnalisation des méthodes et le recours à des templates sur mesure, pour adapter cet outil puissant à vos besoins spécifiques.

Merci de nous avoir suivi tout au long de cette démonstration, et bonne continuation dans votre exploration de Symfony et de ses fonctionnalités robustes.

Sources