Migrate your Drupal 7 site to Drupal 9

Auteur(s) de l'article

I recently worked on the revamp project La Fonciere - which is a straightforward migration of a Drupal 7 website to Drupal 9. During that project I realised how few step-by-step use-case articles exist about Migrating a Drupal 7 project to Drupal 9.
And that’s a shame ! Indeed, Drupal 7 will only be supported until November 2022, meaning more and more projects will be required to be migrated from D7 to D9. 
In this blogpost, I will explain the how-to process I used to migrate La Fonciere, let’s dig in !One more thing before starting, when I use the word “migration”, I mean the process to re-create and move data from an existing source (Eg. an old Drupal 7 project) to the new location (Eg. the new Drupal 9 project). This process includes the creation of different content types, taxonomy terms, files and users, which are a big part of what's important in a website. Obviously, during a migration we still have to recreate all the configuration from scratch, which is good to get around legacy websites and revise what was done. The client needs may have evolved and we have to adapt the project architecture.

First step - Create the migration module

When your brand new D9 site is installed, we can start working on the migration. First, we'll need to add some dependencies to make the migration easier for us. You'll need drupal/migrate_plus and drupal/migrate_tools. These modules will add plugins on top of the ones available with the Migrate module from the Core.
When this is done, we can create a custom module that will held all the migration business logic. Create a new folder in web/modules/custom with the name your_project_name_migration and add the file your_project_name_migration.info.yml.
It should look like this :
## filename : web/modules/custom/your_project_name_migration/your_project_name_migration.info.yml


name: 'your_project_name_migration'
type: module
description: 'Store migration from previous D7 website'
core_version_requirement: ^8.8 || ^9
package: your_project_name
dependencies:
  - drupal:migrate_plus
  - drupal:migrate_tools
  - drupal:migrate
  - drupal:migrate_drupal
  - drupal:node
  - drupal:taxonomy
You may add dependencies for what you will migrate, like drupal:user or drupal:files if you need both.
When this is done, you can create a directory that will hold all the migrations files, let's call it migrations. All the migration files will be created there.
The first file to create is the group for the whole migration process, so we can run one command and all the migration will be run at once.
Create the file migrate_plus.migration_group.your_project_name_migration.yml and append this content :
## filename : web/modules/custom/your_project_name_migration/migrations/migrate_plus.migration_group.your_project_name_migration.yml

id: your_project_name_migration

label: 'Import from previous site (Drupal 7)'

description: 'Migrations from the previous Drupal 7 website.'

# Short description of the type of source, e.g. "Drupal 6" or "WordPress".
source_type: 'Drupal 7'

# Configure the source database
shared_configuration:
  source:
    # replace this by a databases key in settings.php or drush arguments.
    key: migrate

# By default, configuration entities (like this migration) are not automatically
# removed when the migration which installed them is uninstalled. To have your
# migrations uninstalled with your migration module, add an enforced dependency
# on your module.
dependencies:
  enforced:
    module:
      - your_project_name_migration
We now have a migration group named your_project_name_migration, we can start working on the migrations of Files (including both Images and Documents).
I prefer starting with Files, since most content type depend on Files because of referenced images or documents in the Content Type.

Migrate Files

Create a file migrate_plus.migration.files.yml. We'll go through all the important pieces of configuration.

Migration identification

The first thing is to add the config to identify the migration and add it to the migration group that was created before.
## filename : web/modules/custom/your_project_name_migration/migrations/migrate_plus.migration.files.yml

id: files
label: Files
migration_group: your_project_name_migration
migration_tags:
  - files
  • id : The id of the migration.
  • label : Name of the migration.
  • migration_group : Specify the group so that the migration will be run when the group is run.
  • migration_tags : List of tags if we want to run migration by tags instead of the whole group.

Migration source

We need to specify where we can get the information from the source database.
## filename : web/modules/custom/your_project_name_migration/migrations/migrate_plus.migration.files.yml

source:
  plugin: d7_file
  key: migrate
  scheme: public
  constants:
    uid_root: 1
    source_base_path: 'public://migrations'
  • plugin : We use the source plugin provided by the core Migrate module, d7_file.
  • scheme : We only want to get the public files.
  • constants : We can specify variables that will be later used in the migration file.
  • uid_root: If we don't migrate the users or the users are not important, we need to specify an uid and 1 is the admin which is always created by Drupal.
  • source_base_path : To migrate the files, we need to have the files in the local filesystem or the migration will fail. And we can specify here the path to the folder with all the files and use it later.

Migration process

This is the main part of the migration. It's in this part that we specify where the source data should be associated with in the new structure. Here is an example for files :
## filename : web/modules/custom/your_project_name_migration/migrations/migrate_plus.migration.files.yml

process:
  fid: fid
  filename: filename
  source_full_path:
    -
      plugin: concat
      delimiter: /
      source:
        - constants/source_base_path
        - filepath
  uri:
    plugin: file_copy
    source:
      - '@source_full_path'
      - uri
  filemime: filemime
  status: status
  # Drupal 7 didn't keep track of the file's creation or update time -- all it
  # had was the vague "timestamp" column. So we'll use it for both.
  created: timestamp
  changed: timestamp
  uid: constants/uid_root
  • fid : This is the identifier for a file and it didn't change in D9, so we just get the information from the source.
  • filename : Self-explanatory.
  • source_full_path : It is a variable that will be later used in the uri. We use the plugin concat to concatenate the constant that was set beforehand and the filepath that was set in the D7 database.
  • uri : We use the plugin file_copy to get the entire file path and add it to the new database.
  • filememe : Self-explanatory.
  • status : Get the status (published or unpublished)
  • created / changed : As said in the comment, D7 only had a timestamp unlike D9 that stores more accurate information.
  • uid : As said before, get the constant to specify the admin as the creator.

Migration destination

The last thing is to specify where all this processed data will be saved to
## filename : web/modules/custom/your_project_name_migration/migrations/migrate_plus.migration.files.yml

destination:
  plugin: entity:file
This part is to specify that we want to migrate the data processed to file entities.
With that, the migration for the files should be done and we can start with other content. We can continue with the taxonomy terms.

Migrate taxonomy terms

For each taxonomy you want to migrate, you'll need to create a migration file, but the file themselves will be very similar and the differences will be :
  • The source
  • The destination
  • The fields
Let's see how we created these migrations.

Migration source file

Unlike the files, we didn't use a provided source plugin for taxonomies, but we needed to create a source plugin ourselves. To do so, we're gonna create a class in the module we created in src/Plugin/migrate/source. We decided on this naming <NameOfYourTaxonomy>Term.php.
Here is an example of a generic source plugin class you can use :
// filename : /web/modules/custom/your_project_name_migration/src/Plugin/migrate/source/ArticleCategoryTerm.php

<?php

namespace Drupal\your_project_name_migration\Plugin\migrate\source;

use Drupal\migrate\Plugin\migrate\source\SqlBase;

/**
 * Migration class for article category taxonomy.
 *
 * @MigrateSource(
 *   id = "article_category_term",
 *   source_module = "your_project_name_migration"
 * )
 */
class ArticleCategoryTerm extends SqlBase {

  /**
   * {@inheritdoc}
   */
  public function query() {
    $query = $this->select('taxonomy_term_data', 'td')
      ->fields('td', [
        'tid',
        'vid',
        'language',
        'name',
        'description',
      ]);

    $query->condition('td.vid', 1);

    return $query;
  }

  public function fields() {
    return [
      'tid',
      'vid',
      'language',
      'name',
      'description'
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function getIds() {
    return [
      'tid' => [
        'type' => 'integer',
        'alias' => 'td',
      ],
    ];
  }
}
Some explanation :
The Annotation @MigrateSource : This is how we can specify that this is a source plugin for a migration and we can use it.
In the class, 3 methods are required :
  • query() : This is where you can build the query to the source database and get the information.
  • fields() : List of fields to get from the source.
  • getIds() : The ids for the source, here, it's only tid
With that done, we can create the migration configuration file. We'll use an article category taxonomy as an example, but it can be whatever you want. Create the file migrate_plus.migration.article_category.yml in the migrations directory.

Migration identification

Similar to the files, we need to add an identification to the migration :
## filename : web/modules/custom/your_project_name_migration/migrations/migrate_plus.migration.article_category.yml

id: article_category
label: Article category
migration_group: your_project_name_migration
migration_tags:
  - article
Everything is the same as the files migration.

Migration source

This is where we'll specify to use the source plugin we created :
## filename : web/modules/custom/your_project_name_migration/migrations/migrate_plus.migration.article_category.yml

source:
  plugin: article_category_term
  key: migrate
The key will be explained later, the important part is the plugin where we use the source plugin we created before.

Migration process

Again, we'll need to specify the process, or mapping, of files between the source and the destination.
## filename : web/modules/custom/your_project_name_migration/migrations/migrate_plus.migration.article_category.yml

process:
  langcode:
    plugin: get
    source: langcode
  vid:
    plugin: default_value
    default_value: article_category
  name:
    plugin: get
    source: name
  description:
    plugin: get
    source: description
  • langcode : We use the plugin get, which is the standard one and you don't need to specify it, but we prefer to be a bit more verbose and make sure that it's understood more easily.
  • vid : We use the plugin default_value which is self-explanatory and we set the vid to the machine_name of the new taxonomy term in the D9 website
  • name : Get the name from the source
  • description : Get the description from the source

Migration destination

Again, we need to specify the destination entity in D9 :
## filename : web/modules/custom/your_project_name_migration/migrations/migrate_plus.migration.article_category.yml

destination:
  plugin: entity:taxonomy_term
  default_bundle: article_category
The only difference from the files migration is the default_bundle, we need to specify it so the migration knows which taxonomy term should the data be migrated to.

Migrate nodes

Now that the files and the taxonomy terms were created, we can focus on the nodes or the content types. We'll continue with the article example since it's a content type that is created by default by Drupal and is present in a lot of websites.
We'll also explain how to migrate translated content, which is vital in many websites. We'll need to create 2 files :
  1. migrate_plus.migration.article.yml
  2. migrate_plus.migration.i18n.yml
The first one is for the migration in the original language, the second for the other languages.

Migration identification - Original language

Very similar than the other migrations :
## filename : web/modules/custom/your_project_name_migration/migrations/migrate_plus.migration.article.yml

id: article
label: Article - Node
migration_group: your_project_name_migration
migration_tags:
  - article

Migration source - Original language

Like the files, we have a source plugin provided by the core Migrate module :
## filename : web/modules/custom/your_project_name_migration/migrations/migrate_plus.migration.article.yml

source:
  plugin: d7_node
  key: migrate
  node_type: article
  constants:
    uid_root: 1
  • plugin : We use the d7_node module which provides everything to get the information from a D7 node.
  • node_type : The bundle name of the node in the D7 site

Migration dependencies

Since we'll have references to files and taxonomies that should be migrated before, we add dependencies to make sure that the migration is run after them :
## filename : web/modules/custom/your_project_name_migration/migrations/migrate_plus.migration.article.yml

migration_dependencies:
  required:
    - article_category
    - files

Migration process - Original language

The most important points are :
  • nid
  • langcode
  • body (or field you chose for the rich text)
  • category field
  • Image or file field(s)
  • Links
Here is an example for all of these fields :
## filename : web/modules/custom/your_project_name_migration/migrations/migrate_plus.migration.article.yml

process:
  nid: tnid
  langcode:
    plugin: default_value
    source: language
    default_value: 'und'
  body/value:
    - plugin: extract
      field: body/value
      index:
        - 0
  body/format: 'full_html'
  field_category:
    plugin: migration_lookup
    migration: article_category
    source: field_category
  field_external_link:
    plugin: field_link
    uri_scheme: 'https://'
    source: field_external_link
  field_cover_image:
    plugin: sub_process
    source: field_cover_image
    process:
      target_id: fid
      alt: alt
      title: title
      width: width
      height: height
  field_attachment:
    plugin: sub_process
    source: field_attachment
    process:
      target_id: fid
      alt: alt
      title: title
      width: width
      height: height
  • nid : We just need to make sure to get the tnid and not the nid.
  • langcode : We can combine the default_value plugin with the source, so we can put a default value if no value was found in the source data.
  • body : It is separated between the value and the format. For the value, we need to extract the value from the body in D7, that's why the plugin extract is used. The format is put directly and you can use whichever one you'd like. Just be careful that you won't use a format that doesn't have everything the D7 site had.
  • field_category : For references to other content (user, taxonomy term or nodes), we need to use the plugin migration_lookup where you'll specify from which migration the data comes from. In this case, the data will come from the article_category migration.
  • field_external_link : For links, we need to use the plugin field_link which will process the link. In this case, we want to handle an external link, so we specify a prefix so Drupal knows it's external.
  • field_cover_image / field_attachment : Both are similar since an image (if not from the Media module) is a file entity. For files, we need the sub_process plugin so we can specify the target_id being the id of the file itself as well as other informations.
And the last thing is the destination :
## filename : web/modules/custom/your_project_name_migration/migrations/migrate_plus.migration.article.yml

destination:
  plugin: 'entity:node'
  default_bundle: article
After creating the migration for the original language, we can create the migration for the translations.

Migration identification - Translations

Again, similar to other migrations :
## filename : web/modules/custom/your_project_name_migration/migrations/migrate_plus.migration.i18n.yml

id: article_i18n
label: Article - Node
migration_group: your_project_name_migration
migration_tags:
  - article

Migration source - Translations

This is where it differs from the original language, we're gonna use a specific source plugin :
## filename : web/modules/custom/your_project_name_migration/migrations/migrate_plus.migration.i18n.yml

source:
  plugin: d7_node_entity_translation
  key: migrate
  node_type: article

Migration destination - Translations

We also need to specify in the destination that the content is translated content :
## filename : web/modules/custom/your_project_name_migration/migrations/migrate_plus.migration.i18n.yml

destination:
  plugin: 'entity:node'
  translations: true
  destination_module: content_translation

Migration process - Translations

It is the same as the article in the original language article, but we only need to process the translated fields.

Migration dependencies - Translations

We need to depend on the original language migration :
## filename : web/modules/custom/your_project_name_migration/migrations/migrate_plus.migration.i18n.yml

migration_dependencies:
  required:
    - article
Now that all the migrations files are created, we can finalise the setup to run the migrations.

Second step - Database connection setup

Now that the migrations files are written, we want to test what we did. And for that, we need to setup the connection to the database where the source resides. We'll need to change the settings.php file which is located in web/sites/default. At the end of the file, add this :
// filename : web/sites/default/settings.php

$databases['migrate']['default'] = array (
  'database' => 'drupal_seven',
  'username' => 'drupal',
  'password' => 'drupal',
  'host' => 'db',
  'port' => '3306',
  'namespace' => 'Drupal\\Core\\Database\\Driver\\mysql',
  'driver' => 'mysql',
);
You just need to enter the correct information for your database, save the file and you should be good to go.

Third step - Run the migration !

Here we are, finally, after this hard work, we can run the migrations and make sure that everything works as expected.
First thing, enable the migration module created from the Drupal admin or with the drush command :
drush en your_project_name_migration
It should prompt you to accept also enabling the other migrate modules that we put as dependencies, answer yes to that.
When this is done, we can run the command to run our migration group :
drush mim --group="your_project_name_migration"
Watch as the migrations are run one after the other and if everything is correct, the command should finish without errors and you can go to your Drupal site and check that everything was migrated as expected.

Conclusion

While the documentation can be a bit scarce, Drupal has a lot of modules that will help you to migrate your data from D7 to a new D9 (or D8, it works exactly the same).
Here are all the different process plugins available from the core Migrate module and Migrate plus module.
Hope that this module was helpful ! We are always happy to get feedback and any correction if we explained something wrong.