Project Overview
I recently migrated a Drupal 7 application to Drupal 9 that used Organic Groups to provide group role based access to content. The application provided an interface to submit and manage activity reports and required authentication for all operations. Users could belong to multiple groups, or no groups at all, and their permissions were based on both group membership and role.
Working Assumptions
All but two content types are group content, and each node may only belong to one group. There was a single content type that served as the organic group node in D7. This node contained a lot of fields, and was split into a group entity and a node entity in D9.
New content is added using the group content creation route which uses the Group API to add the new node to the group referenced in the route. We did not add any entity reference fields to the group entity on any content.
We did not reuse node/user IDs from the D7 application so we needed to perform migration lookups for all references. The content model was completely reworked for D9 and there was no 1:1 mapping from D7 content to D9 content in many cases.
High Level Strategy
Drupal 9 Setup
As this was a complete application rebuild, we did not utilize Migrate to import any of the D7 configuration. All group and content entity types were manually created in D9 according to a content model which was developed after reviewing the existing D7 site and identifying areas for improvement.
Migration Strategy
The content model is quite complex and contains many dependencies, so migrations were developed and run based on the dependency tree. The group entity migration and group content node migrations used a custom source plugin which extended the core D7 node source so that group membership creation did not require a separate migration. Users were assigned to groups after the group was created, while nodes were assigned to the proper group after the node was imported.
Migration Plan
- Taxonomy term migrations
- User/role migration
- D7 group node to group entity migration
- D7 group node to group content node
- Dependent paragraph/media entities
- Remaining group content nodes
Migration Examples
The following examples demonstrate using a Migrate “PostRowSave” event subscriber to assign group membership. For more information about creating event subscribers in Drupal 9, you can refer to the event system documentation.
Group Migration
Source Plugin
The group migration used a custom source plugin to extend the prepareRow
method to look up the user members. The D7 Organic Group module stores group membership for all entities in the og_membership table
, so we fetch all user IDs and add them to a source row property called group_users
which we can then reference later.
1 public function prepareRow(Row $row) {
2 $nid = $row->getSourceProperty('nid');
3
4 // Find all our user <-> group relationships.
5 $users = $this->select('og_membership', 'og')
6 ->fields('og', ['etid'])
7 ->condition('gid', $nid)
8 ->condition('entity_type', 'user')
9 ->execute()
10 ->fetchAll();
11
12 $uids = [];
13 foreach ($users as $user) {
14 $uids[] = $user['etid'];
15 }
16 $row->setSourceProperty('group_users', $uids);
17
18 return parent::prepareRow($row);
19 }
Process pipeline from the migration configuration
Next, since we did not re-use any IDs from Drupal 7, we need to find the D9 user which corresponds to that user. We can use the migration_lookup
process plugin to do the lookup. We use the no_stub
option as we do not want to create stub users in the case that any user id was not properly migrated.
1process:
2 group_uids:
3 plugin: migration_lookup
4 migration: my_d7_upgrade_user
5 source: group_users
6 no_stub: true
Event Subscriber
Now that we have the user IDs mapped properly, we can use an Event Subscriber to create the Group Membership relationship entity in D9. We responded to the “post row save” event which Migrate dispatches after each row has been saved to the database. Our application used group roles to control what kind of access a user had to the content in each group; we mapped the user’s roles to group roles and added the group role assignments when we created the group membership for each user.
1 public function addUsersToGroup(MigratePostRowSaveEvent $event) {
2 // Only respond to our group entity migration
3 if ($event->getMigration()->id() != 'my_d7_upgrade_group_entity') {
4 return;
5 }
6 // Get the ID of the group entity that was just created.
7 $id = $event->getDestinationIdValues();
8 $id = reset($id);
9 // Get our D9 user IDs that were looked up by the process plugin.
10 $uids = $event->getRow()->getDestinationProperty('group_uids');
11 $userStorage = $this->entityTypeManager->getStorage('user');
12 // Set up an array of relevant roles to be mapped.
13 $memberRoles = ['editor', 'manager'];
14 /** @var \Drupal\group\Entity\Group $group */
15 $group = $this->entityTypeManager->getStorage('group')->load($id);
16 foreach ($uids as $uid) {
17 /** @var \Drupal\user\UserInterface $user */
18 if ($uid && $user = $userStorage->load($uid)) {
19 // If this user is already a member of this group, remove them so we
20 // can re-add them with the proper roles.
21 if ($group->getMember($user)) {
22 $group->removeMember($user);
23 }
24 $groupRoles = [];
25 // Iterate through the user's roles and add the group role if it is one
26 // of the roles we are interested in. The format of the group roles is
27 // "group_entity_type-role_name".
28 foreach ($user->getRoles() as $role) {
29 if (in_array($role, $memberRoles)) {
30 $groupRoles[] = "mygroup-{$role}";
31 }
32 }
33 // Use the Group API to add the user to the group. This creates the Group
34 // Membership entity for us and sets the user's group roles.
35 $group->addMember($user, ['group_roles' => $groupRoles]);
36 }
37 }
38
Group Content Node Migrations
We used a similar strategy to assign group membership when a group content node was saved. In our case, a node could only belong to one group; if a node could have multiple group memberships, it would be straightforward to extend this to create multiple Group Membership entities for a single node.
Source Plugin
Any node from D7 which could be a group content node used a custom source plugin to retrieve the D7 Organic Group node in the prepareRow
method.
1 public function prepareRow(Row $row) {
2 $nid = $row->getSourceProperty('nid');
3
4 // Get the grant organic group reference.
5 $ogRef = $this->select('og_membership', 'og')
6 ->fields('og', ['gid'])
7 ->condition('etid', $nid)
8 ->condition('entity_type', 'node')
9 ->execute()
10 ->fetchField();
11 $row->setSourceProperty('group_ref_id', $ogRef);
12
13 return parent::prepareRow($row);
14 }
15
Process pipeline from the migration configuration
We need to look up the group entity id during our migration’s process pipeline from the D7 Organic Group node id.
1 process:
2 group_ref_id:
3 plugin: migration_lookup
4 migration: my_d7_upgrade_group_entity
5 source: group_ref_id
6 no_stub: true
7
Event Subscriber
Once again, we use an event subscriber to add the node to the proper group entity after the node has been saved. We check to see if the node is already a group member before adding it as the migrations would also update existing content as well as create new content. There was never a case where a node could be updated to belong to a different group and each node could only belong to a single group.
1 public function addNodeToGroup(MigratePostRowSaveEvent $event) {
2 $row = $event->getRow();
3 // We only need to add the node to the group if the destination property
4 // group_ref_id is present.
5 if (!$row->hasDestinationProperty('group_ref_id')) {
6 return;
7 }
8
9 // Get the ID of the node that was just saved.
10 $id = $event->getDestinationIdValues();
11 $id = reset($id);
12 $group_id = $row->getDestinationProperty('group_ref_id');
13 /** @var \Drupal\group\Entity\Group $group */
14 $group = $this->entityTypeManager->getStorage('group')->load($group_id);
15 $node = $this->entityTypeManager->getStorage('node')->load($id);
16 // Group module content plugins are of the form "group_node:bundle".
17 $pluginId = 'group_node:' . $node->bundle();
18 // Only add the node to the group if it hasn't been added yet.
19 $check = $group->getContentByEntityId($pluginId, $id);
20 if (empty($check)) {
21 $group->addContent($node, $pluginId);
22 }
23 }
24
Conclusion
This method of creating group membership entities worked very well for us and required the least amount of custom code. We successfully used this code to migrate approximately 1400 groups with over 4000 users and 35,000+ nodes.
The D9 Group module has a very robust API which made it easy to manage group membership during the migrations. Group membership is cleaned up automatically when a node is deleted, so there was nothing additional required when a migration was rolled back during testing.
Note that in the node group membership example above, we assume the node will only belong to one group. If that is not the case for your setup, you could instead return all ids in the prepareRow
and iterate through the group ids and add the node to each group as was done for users.