Migrating Field Groups from Features in Drupal 7 to Drupal 9

By Kevin, March 12th, 2021

I am in the midst of a Drupal 7 to Drupal 9 upgrade for a client. This is the first time I am using the Migrate Drupal and Migrate Drupal UI core modules to do an inline upgrade. So far everything has been great. I just need to drop in a data migration path for some Paragraphs and recreate a couple Views and we should be good.

However, when I was checking around on some content, I noticed I had no field groups in the new site. Why? Field Groups comes with a Field Groups Migrate submodule, which will pick up and migrate any field groups in a Drupal 7 site to Drupal 8 or 9.

None of the field groups were migrated, and there were no errors in the migration logs. What could be wrong?

I had overlooked one aspect of the Drupal 7 site. We had made extensive use of the Features module to control configuration as code as many people did on Drupal 7. We had made 40 or so field groups and captured them in over a dozen Features modules. This means they have no database record, and that is why the migration did not recreate them in Drupal 9.

Example field group defined in code by a Features export:

/**
 * Implements hook_field_group_info().
 */
function homepage_config_field_group_info() {
  $field_groups = array();

  $field_group = new stdClass();
  $field_group->disabled = FALSE; /* Edit this to true to make a default field_group disabled initially */
  $field_group->api_version = 1;
  $field_group->identifier = 'group_analytics|node|homepage|form';
  $field_group->group_name = 'group_analytics';
  $field_group->entity_type = 'node';
  $field_group->bundle = 'homepage';
  $field_group->mode = 'form';
  $field_group->parent_name = '';
  $field_group->data = array(
    'label' => 'Analytics Highlights',
    'weight' => '3',
    'children' => array(
      0 => 'field_highlight_title',
      1 => 'field_download_text',
      2 => 'field_featured_files',
    ),
    'format_type' => 'tab',
    'format_settings' => array(
      'formatter' => 'closed',
      'instance_settings' => array(
        'description' => '',
        'classes' => 'group-analytics field-group-tab',
        'required_fields' => 1,
      ),
    ),
  );
  $field_groups['group_analytics|node|homepage|form'] = $field_group;

  return $field_groups;
}

The obvious solution is to just migrate the site and recreate all the field groups. But with so many of them spread across a dozen content types, creating all of them with the same configuration, order and group type across the site would easily take someone a half day or more. I also want this to be a repeatable process as we iterate the migration to find gaps and spots we need to bridge some of the data. The less manual effort we need to do, the better.

This created an interesting challenge - how can I get these items that are defined in code into the site?

I did some digging. Every field group defined by Features like the above example has the same named hook (FEATURENAME_field_group_info) that return an array of group definitions. The array matches the fields from the database for the field_group table in Drupal 7. If I could get ahold of those arrays, I could insert them as database records into the Drupal 7 database. From there the Migrate UI migration would run and bring them in.

I started by copying all of my Features modules into my Drupal 9 project at "/modules/features" so they were present in the codebase. Then I created a new module that would be responsible for parsing the directory, and inserting database records into the old database:

/**
 * Implements hook_install().
 *
 * This will scan the old features directory for field_group.inc files. From
 * there we can recreate the structure and insert them into the legacy database.
 *
 * After that, we can run the migration from the UI.
 */
function mymodule_install() {
  $connection = \Drupal\Core\Database\Database::getConnection('default', 'migrate');

  $path = DRUPAL_ROOT . '/modules/features';
  $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path));

  $files = array_filter(iterator_to_array($iterator), function($file) {
    return preg_match('/(.*)\.field_group\.inc/i', $file->getFilename()) ? $file->isFile() : FALSE;
  });

  foreach ($files as $file) {
    $filename = $file->getFilename();
    $function_name = str_replace('.inc', '_info', $filename);
    $function_name = str_replace('.field_group', '_field_group', $function_name);

    // Need to require the .inc file so the function can be called.
    require $file->getPathname();

    $groups = $function_name();

    foreach ($groups as $group) {
      $connection->merge('field_group')
        ->key(['identifier' => $group->identifier])
        ->fields([
          'identifier' => $group->identifier,
          'group_name' => $group->group_name,
          'entity_type' => $group->entity_type,
          'bundle' => $group->bundle,
          'mode' => $group->mode,
          'parent_name' => $group->parent_name,
          'data' => serialize($group->data)
        ])
        ->execute();
    }
  }
}

When it installs, it grabs a $connection object that points at our old database (as defined in settings.php). The old Drupal 7 database is sitting next to our Drupal 9 one in MySQL.

Then we iterate over every file in the /modules/features directory, grabbing any file with (name).field_group.inc.

From there we loop this list of files, and construct the name of the info hook we need to invoke. A require statement is added because we need to have the .inc file loaded so the function is defined prior to calling it.

At that point we have one or more groups returned. Then we can loop that array and add them to the database with a merge query:

Field groups inserted to the database
All of the field groups are now added to the old database, ready for migration.

Wow! Now that the field groups are in the old database, Field Group Migrate module can do its thing. After running a migration, every single field group was faithfully migrated:

Field groups migrated to the new site
An example content type with its field groups present from the old site

I was able to get this done in a little over an hour and save the manual effort from other means of moving the data. 

I added this solution into a thread for Field Group for the community to discuss and iterate on. If you are trying to migrate a site to Drupal 9 and worried you won't have your field groups, definitely give this a try.