diff --git a/composer.json b/composer.json
index b17f66d9d4d79361a7590e875d00eaa78c97601d..1dd54db9eb890b4d6f3e84a36a3ca293f8ba8ab6 100644
--- a/composer.json
+++ b/composer.json
@@ -142,8 +142,8 @@
         "drupal/menu_breadcrumb": "1.13",
         "drupal/metatag": "1.13",
         "drupal/migrate_devel": "2.0-alpha2",
-        "drupal/migrate_plus": "4.0",
-        "drupal/migrate_tools": "4.0",
+        "drupal/migrate_plus": "5.1",
+        "drupal/migrate_tools": "5.0",
         "drupal/mobile_detect_twig_extensions": "1.4",
         "drupal/mobile_device_detection": "3.2",
         "drupal/module_filter": "3.1",
diff --git a/composer.lock b/composer.lock
index 313a39a027c083e4137fa667ecfd695aa38c128c..9aff2845f4d894886186ab9c3ac56f8957cff34e 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "1c62a5829fdd2dcd1c604e5105490765",
+    "content-hash": "4061d56ed0fe7f80e7e5e4a47a2ae8c5",
     "packages": [
         {
             "name": "alchemy/zippy",
@@ -6118,20 +6118,21 @@
         },
         {
             "name": "drupal/migrate_plus",
-            "version": "4.0.0",
+            "version": "5.1.0",
             "source": {
                 "type": "git",
                 "url": "https://git.drupalcode.org/project/migrate_plus.git",
-                "reference": "8.x-4.0"
+                "reference": "8.x-5.1"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://ftp.drupal.org/files/projects/migrate_plus-8.x-4.0.zip",
-                "reference": "8.x-4.0",
-                "shasum": "63dad289defe8298aa5ca5e30062fe9761d19eca"
+                "url": "https://ftp.drupal.org/files/projects/migrate_plus-8.x-5.1.zip",
+                "reference": "8.x-5.1",
+                "shasum": "1257427ab0c64459c3c1e42bb2a98d3114b77163"
             },
             "require": {
-                "drupal/core": "^8.3"
+                "drupal/core": "^8.8 || ^9",
+                "php": ">=7.1"
             },
             "require-dev": {
                 "drupal/migrate_example_advanced_setup": "*",
@@ -6143,12 +6144,9 @@
             },
             "type": "drupal-module",
             "extra": {
-                "branch-alias": {
-                    "dev-4.x": "4.x-dev"
-                },
                 "drupal": {
-                    "version": "8.x-4.0",
-                    "datestamp": "1536264180",
+                    "version": "8.x-5.1",
+                    "datestamp": "1588261060",
                     "security-coverage": {
                         "status": "covered",
                         "message": "Covered by Drupal's security advisory policy"
@@ -6157,7 +6155,7 @@
             },
             "notification-url": "https://packages.drupal.org/8/downloads",
             "license": [
-                "GPL-2.0+"
+                "GPL-2.0-or-later"
             ],
             "authors": [
                 {
@@ -6166,48 +6164,51 @@
                     "role": "Maintainer"
                 },
                 {
-                    "name": "mikeryan",
-                    "homepage": "https://www.drupal.org/user/4420"
+                    "name": "Lucas Hedding",
+                    "homepage": "https://www.drupal.org/u/heddn",
+                    "role": "Maintainer"
                 }
             ],
             "description": "Enhancements to core migration support.",
             "homepage": "https://www.drupal.org/project/migrate_plus",
             "support": {
-                "source": "https://cgit.drupalcode.org/migrate_plus",
+                "source": "https://git.drupalcode.org/project/migrate_plus",
                 "issues": "https://www.drupal.org/project/issues/migrate_plus",
-                "irc": "irc://irc.freenode.org/drupal-migrate"
+                "slack": "#migrate"
             }
         },
         {
             "name": "drupal/migrate_tools",
-            "version": "4.0.0",
+            "version": "5.0.0",
             "source": {
                 "type": "git",
                 "url": "https://git.drupalcode.org/project/migrate_tools.git",
-                "reference": "8.x-4.0"
+                "reference": "8.x-5.0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://ftp.drupal.org/files/projects/migrate_tools-8.x-4.0.zip",
-                "reference": "8.x-4.0",
-                "shasum": "016dfb010df76723c5a6a447921fdccd3c885237"
+                "url": "https://ftp.drupal.org/files/projects/migrate_tools-8.x-5.0.zip",
+                "reference": "8.x-5.0",
+                "shasum": "b7c91aa6f7de9d6d548f65f83c8736e47e5926a1"
             },
             "require": {
-                "drupal/core": "^8.3",
-                "drupal/migrate_plus": "^4"
+                "drupal/core": "^8.8 | ^9",
+                "drupal/migrate_plus": "^5",
+                "php": ">=7.1"
             },
             "require-dev": {
-                "drupal/coder": "^8",
-                "drupal/migrate_source_csv": "^2.2"
+                "drupal/migrate_plus": "^5",
+                "drupal/migrate_source_csv": "^3",
+                "drush/drush": "^10"
+            },
+            "suggest": {
+                "drush/drush": "^9 || ^10"
             },
             "type": "drupal-module",
             "extra": {
-                "branch-alias": {
-                    "dev-4.x": "4.x-dev"
-                },
                 "drupal": {
-                    "version": "8.x-4.0",
-                    "datestamp": "1535380084",
+                    "version": "8.x-5.0",
+                    "datestamp": "1588260531",
                     "security-coverage": {
                         "status": "covered",
                         "message": "Covered by Drupal's security advisory policy"
@@ -6215,34 +6216,32 @@
                 },
                 "drush": {
                     "services": {
-                        "drush.services.yml": "^9"
+                        "drush.services.yml": "^9 || ^10"
                     }
                 }
             },
             "notification-url": "https://packages.drupal.org/8/downloads",
             "license": [
-                "GPL-2.0+"
+                "GPL-2.0-or-later"
             ],
             "authors": [
                 {
-                    "name": "heddn",
-                    "homepage": "https://www.drupal.org/user/1463982"
-                },
-                {
-                    "name": "mikeryan",
-                    "homepage": "https://www.drupal.org/user/4420"
+                    "name": "Mike Ryan",
+                    "homepage": "https://www.drupal.org/u/mikeryan",
+                    "role": "Maintainer"
                 },
                 {
-                    "name": "moshe weitzman",
-                    "homepage": "https://www.drupal.org/user/23"
+                    "name": "Lucas Hedding",
+                    "homepage": "https://www.drupal.org/u/heddn",
+                    "role": "Maintainer"
                 }
             ],
             "description": "Tools to assist in developing and running migrations.",
             "homepage": "http://drupal.org/project/migrate_tools",
             "support": {
-                "source": "http://cgit.drupalcode.org/migrate_tools",
-                "issues": "http://drupal.org/project/migrate_tools",
-                "irc": "irc://irc.freenode.org/drupal-migrate"
+                "source": "https://git.drupalcode.org/project/migrate_tools",
+                "issues": "https://www.drupal.org/project/issues/migrate_tools",
+                "slack": "#migrate"
             }
         },
         {
diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json
index 1940910d4a232d4a658dfe7f5dee32bacb14daac..34351e0277447be10058a3ef9647c2180219c920 100644
--- a/vendor/composer/installed.json
+++ b/vendor/composer/installed.json
@@ -6304,21 +6304,22 @@
     },
     {
         "name": "drupal/migrate_plus",
-        "version": "4.0.0",
-        "version_normalized": "4.0.0.0",
+        "version": "5.1.0",
+        "version_normalized": "5.1.0.0",
         "source": {
             "type": "git",
             "url": "https://git.drupalcode.org/project/migrate_plus.git",
-            "reference": "8.x-4.0"
+            "reference": "8.x-5.1"
         },
         "dist": {
             "type": "zip",
-            "url": "https://ftp.drupal.org/files/projects/migrate_plus-8.x-4.0.zip",
-            "reference": "8.x-4.0",
-            "shasum": "63dad289defe8298aa5ca5e30062fe9761d19eca"
+            "url": "https://ftp.drupal.org/files/projects/migrate_plus-8.x-5.1.zip",
+            "reference": "8.x-5.1",
+            "shasum": "1257427ab0c64459c3c1e42bb2a98d3114b77163"
         },
         "require": {
-            "drupal/core": "^8.3"
+            "drupal/core": "^8.8 || ^9",
+            "php": ">=7.1"
         },
         "require-dev": {
             "drupal/migrate_example_advanced_setup": "*",
@@ -6330,12 +6331,9 @@
         },
         "type": "drupal-module",
         "extra": {
-            "branch-alias": {
-                "dev-4.x": "4.x-dev"
-            },
             "drupal": {
-                "version": "8.x-4.0",
-                "datestamp": "1536264180",
+                "version": "8.x-5.1",
+                "datestamp": "1588261060",
                 "security-coverage": {
                     "status": "covered",
                     "message": "Covered by Drupal's security advisory policy"
@@ -6345,7 +6343,7 @@
         "installation-source": "dist",
         "notification-url": "https://packages.drupal.org/8/downloads",
         "license": [
-            "GPL-2.0+"
+            "GPL-2.0-or-later"
         ],
         "authors": [
             {
@@ -6354,49 +6352,52 @@
                 "role": "Maintainer"
             },
             {
-                "name": "mikeryan",
-                "homepage": "https://www.drupal.org/user/4420"
+                "name": "Lucas Hedding",
+                "homepage": "https://www.drupal.org/u/heddn",
+                "role": "Maintainer"
             }
         ],
         "description": "Enhancements to core migration support.",
         "homepage": "https://www.drupal.org/project/migrate_plus",
         "support": {
-            "source": "https://cgit.drupalcode.org/migrate_plus",
+            "source": "https://git.drupalcode.org/project/migrate_plus",
             "issues": "https://www.drupal.org/project/issues/migrate_plus",
-            "irc": "irc://irc.freenode.org/drupal-migrate"
+            "slack": "#migrate"
         }
     },
     {
         "name": "drupal/migrate_tools",
-        "version": "4.0.0",
-        "version_normalized": "4.0.0.0",
+        "version": "5.0.0",
+        "version_normalized": "5.0.0.0",
         "source": {
             "type": "git",
             "url": "https://git.drupalcode.org/project/migrate_tools.git",
-            "reference": "8.x-4.0"
+            "reference": "8.x-5.0"
         },
         "dist": {
             "type": "zip",
-            "url": "https://ftp.drupal.org/files/projects/migrate_tools-8.x-4.0.zip",
-            "reference": "8.x-4.0",
-            "shasum": "016dfb010df76723c5a6a447921fdccd3c885237"
+            "url": "https://ftp.drupal.org/files/projects/migrate_tools-8.x-5.0.zip",
+            "reference": "8.x-5.0",
+            "shasum": "b7c91aa6f7de9d6d548f65f83c8736e47e5926a1"
         },
         "require": {
-            "drupal/core": "^8.3",
-            "drupal/migrate_plus": "^4"
+            "drupal/core": "^8.8 | ^9",
+            "drupal/migrate_plus": "^5",
+            "php": ">=7.1"
         },
         "require-dev": {
-            "drupal/coder": "^8",
-            "drupal/migrate_source_csv": "^2.2"
+            "drupal/migrate_plus": "^5",
+            "drupal/migrate_source_csv": "^3",
+            "drush/drush": "^10"
+        },
+        "suggest": {
+            "drush/drush": "^9 || ^10"
         },
         "type": "drupal-module",
         "extra": {
-            "branch-alias": {
-                "dev-4.x": "4.x-dev"
-            },
             "drupal": {
-                "version": "8.x-4.0",
-                "datestamp": "1535380084",
+                "version": "8.x-5.0",
+                "datestamp": "1588260531",
                 "security-coverage": {
                     "status": "covered",
                     "message": "Covered by Drupal's security advisory policy"
@@ -6404,35 +6405,33 @@
             },
             "drush": {
                 "services": {
-                    "drush.services.yml": "^9"
+                    "drush.services.yml": "^9 || ^10"
                 }
             }
         },
         "installation-source": "dist",
         "notification-url": "https://packages.drupal.org/8/downloads",
         "license": [
-            "GPL-2.0+"
+            "GPL-2.0-or-later"
         ],
         "authors": [
             {
-                "name": "heddn",
-                "homepage": "https://www.drupal.org/user/1463982"
-            },
-            {
-                "name": "mikeryan",
-                "homepage": "https://www.drupal.org/user/4420"
+                "name": "Mike Ryan",
+                "homepage": "https://www.drupal.org/u/mikeryan",
+                "role": "Maintainer"
             },
             {
-                "name": "moshe weitzman",
-                "homepage": "https://www.drupal.org/user/23"
+                "name": "Lucas Hedding",
+                "homepage": "https://www.drupal.org/u/heddn",
+                "role": "Maintainer"
             }
         ],
         "description": "Tools to assist in developing and running migrations.",
         "homepage": "http://drupal.org/project/migrate_tools",
         "support": {
-            "source": "http://cgit.drupalcode.org/migrate_tools",
-            "issues": "http://drupal.org/project/migrate_tools",
-            "irc": "irc://irc.freenode.org/drupal-migrate"
+            "source": "https://git.drupalcode.org/project/migrate_tools",
+            "issues": "https://www.drupal.org/project/issues/migrate_tools",
+            "slack": "#migrate"
         }
     },
     {
diff --git a/web/modules/migrate_plus/README.txt b/web/modules/migrate_plus/README.txt
index 797082e388e58656739e88d8611a6991f9bbadf0..9738be925077101ffeb42844aca02cd86a4d8324 100644
--- a/web/modules/migrate_plus/README.txt
+++ b/web/modules/migrate_plus/README.txt
@@ -7,8 +7,8 @@ Extensions to base API
   migration configuration.
 * A MigrationGroup configuration entity is provided, which enables migrations to
   be organized in groups, and to maintain shared configuration in one place.
-* A MigrateEvents::PREPARE_ROW event is provided to dispatch hook_prepare_row()
-  invocations as events.
+* A MigrateEvents::PREPARE_ROW event is provided to dispatch
+  hook_migrate_prepare_row() invocations as events.
 * A SourcePluginExtension class is provided, enabling one to define fields and
   IDs for a source plugin via configuration rather than requiring PHP code.
 
diff --git a/web/modules/migrate_plus/composer.json b/web/modules/migrate_plus/composer.json
index 54070eda9559c3bf94a7d5a29aa9ccf0a2214aaf..7dfce263ef156f728cf40a49fdbd2cc51218048b 100644
--- a/web/modules/migrate_plus/composer.json
+++ b/web/modules/migrate_plus/composer.json
@@ -1,24 +1,32 @@
 {
-  "name": "drupal/migrate_plus",
-  "description": "Enhancements to core migration support.",
-  "type": "drupal-module",
-  "license": "GPL-2.0+",
-  "homepage": "https://www.drupal.org/project/migrate_plus",
-  "authors": [
-    {
-      "name": "Mike Ryan",
-      "homepage":"https://www.drupal.org/u/mikeryan",
-      "role": "Maintainer"
+    "name": "drupal/migrate_plus",
+    "description": "Enhancements to core migration support.",
+    "type": "drupal-module",
+    "license": "GPL-2.0-or-later",
+    "homepage": "https://www.drupal.org/project/migrate_plus",
+    "authors": [
+        {
+            "name": "Mike Ryan",
+            "homepage":"https://www.drupal.org/u/mikeryan",
+            "role": "Maintainer"
+        },
+        {
+            "name": "Lucas Hedding",
+            "homepage": "https://www.drupal.org/u/heddn",
+            "role": "Maintainer"
+        }
+    ],
+    "support": {
+        "issues": "https://www.drupal.org/project/issues/migrate_plus",
+        "slack": "#migrate",
+        "source": "https://git.drupalcode.org/project/migrate_plus"
+    },
+    "minimum-stability": "dev",
+    "suggest": {
+        "sainsburys/guzzle-oauth2-plugin": "3.0 required for the OAuth2 authentication plugin",
+        "ext-soap": "*"
+    },
+    "require": {
+        "php": ">=7.1"
     }
-  ],
-  "support": {
-    "issues": "https://www.drupal.org/project/issues/migrate_plus",
-    "irc": "irc://irc.freenode.org/drupal-migrate",
-    "source": "https://cgit.drupalcode.org/migrate_plus"
-  },
-  "minimum-stability": "dev",
-  "suggest": {
-    "sainsburys/guzzle-oauth2-plugin": "3.0 required for the OAuth2 authentication plugin",
-    "ext-soap": "*"
-  }
 }
diff --git a/web/modules/migrate_plus/config/schema/migrate_plus.destination.schema.yml b/web/modules/migrate_plus/config/schema/migrate_plus.destination.schema.yml
index 58ad74e53834cd06968ef95a889478a731a0eb27..c76e59d5d3b9363a593ca6244bbcb860e392e412 100644
--- a/web/modules/migrate_plus/config/schema/migrate_plus.destination.schema.yml
+++ b/web/modules/migrate_plus/config/schema/migrate_plus.destination.schema.yml
@@ -8,6 +8,9 @@ migrate_plus.destination.*:
       type: boolean
       label: 'Whether stubbing is allowed.'
       default: false
+    default_bundle:
+      type: string
+      label: 'The default bundle for content entity destinations.'
 
 migrate_plus.destination.config:
   type: migrate_destination
diff --git a/web/modules/migrate_plus/config/schema/migrate_plus.schema.yml b/web/modules/migrate_plus/config/schema/migrate_plus.schema.yml
index 99aef5523869dcd4fbbaa9cf1173e9d1765481b3..33dc2e6e1d8cb2033bb1cb9a5500bf57a869e098 100644
--- a/web/modules/migrate_plus/config/schema/migrate_plus.schema.yml
+++ b/web/modules/migrate_plus/config/schema/migrate_plus.schema.yml
@@ -29,13 +29,13 @@ migrate_plus.migration.*:
       type: label
       label: 'Label'
     source:
-      type: migrate_plus.source.[plugin]
+      type: ignore
       label: 'Source'
     process:
       type: ignore
       label: 'Process'
     destination:
-      type: migrate_plus.destination.[plugin]
+      type: ignore
       label: 'Destination'
     migration_dependencies:
       type: mapping
diff --git a/web/modules/migrate_plus/drupalci.yml b/web/modules/migrate_plus/drupalci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..7286c3ba56786e1db97341e19cdba85a69fd139c
--- /dev/null
+++ b/web/modules/migrate_plus/drupalci.yml
@@ -0,0 +1,20 @@
+# Learn to make one for your own drupal.org project:
+# https://www.drupal.org/drupalorg/docs/drupal-ci/customizing-drupalci-testing
+build:
+  assessment:
+    validate_codebase:
+      phplint:
+      container_composer:
+      phpcs:
+        # phpcs will use core's specified version of Coder.
+        sniff-all-files: true
+        halt-on-fail: true
+    testing:
+      # run_tests task is executed several times in order of performance speeds.
+      # halt-on-fail can be set on the run_tests tasks in order to fail fast.
+      # suppress-deprecations is false in order to be alerted to usages of
+      # deprecated code.
+      run_tests.standard:
+        types: 'Simpletest,PHPUnit-Unit,PHPUnit-Kernel,PHPUnit-Functional'
+        testgroups: '--all'
+        suppress-deprecations: false
diff --git a/web/modules/migrate_plus/migrate_example/README.txt b/web/modules/migrate_plus/migrate_example/README.txt
index 431e22e9bac4ed3c4641b26d82a79185f11b6b91..1a616788a65b966cda2e21acbe6d3a9de0a4d5a6 100644
--- a/web/modules/migrate_plus/migrate_example/README.txt
+++ b/web/modules/migrate_plus/migrate_example/README.txt
@@ -1,7 +1,7 @@
 INTRODUCTION
 ------------
 The migrate_example module demonstrates how to implement custom migrations
-for Drupal 8. It includes a group of "beer" migrations demonstrating a complete
+for Drupal 8+. It includes a group of "beer" migrations demonstrating a complete
 simple migration scenario.
 
 THE BEER SITE
@@ -15,7 +15,7 @@ to the basic structure.
 To make the example as simple as to run as possible, the source data is placed
 in tables directly in your Drupal database - in most real-world scenarios, your
 source data will be in an external database. The migrate_example_setup submodule
-creates and populates these tables, as well as configuring your Drupal 8 site
+creates and populates these tables, as well as configuring your Drupal 8+ site
 (creating a node type, vocabulary, fields, etc.) to receive the data.
 
 STRUCTURE
diff --git a/web/modules/migrate_plus/migrate_example/migrate_example.info.yml b/web/modules/migrate_plus/migrate_example/migrate_example.info.yml
index 5ff995788d1aa13f41d87401bc6b3e2a1157deff..c2b711a05d1a3ad6c60983058619f1abd2fce221 100644
--- a/web/modules/migrate_plus/migrate_example/migrate_example.info.yml
+++ b/web/modules/migrate_plus/migrate_example/migrate_example.info.yml
@@ -1,8 +1,8 @@
 type: module
 name: Migrate Example
-description: 'Examples of how Drupal 8 migration compares to previous versions.'
+description: 'Examples of how Drupal 8+ migration compares to previous versions.'
 package: Examples
-# core: 8.x
+core_version_requirement: ^8.8 || ^9
 dependencies:
   - drupal:migrate
   - migrate_plus:migrate_example_setup
@@ -10,8 +10,7 @@ dependencies:
   - drupal:menu_ui
   - drupal:path
 
-# Information added by Drupal.org packaging script on 2018-09-06
-version: '8.x-4.0'
-core: '8.x'
+# Information added by Drupal.org packaging script on 2020-04-30
+version: '8.x-5.1'
 project: 'migrate_plus'
-datestamp: 1536264189
+datestamp: 1588261062
diff --git a/web/modules/migrate_plus/migrate_example/migrate_example_setup/config/install/core.entity_form_display.node.migrate_example_beer.default.yml b/web/modules/migrate_plus/migrate_example/migrate_example_setup/config/install/core.entity_form_display.node.migrate_example_beer.default.yml
index 306af0e3fe46e6da77aca91ffaf82ed13912b8cf..d3b47a9cef3bb8fc206a7479f8f878baa2ac9702 100644
--- a/web/modules/migrate_plus/migrate_example/migrate_example_setup/config/install/core.entity_form_display.node.migrate_example_beer.default.yml
+++ b/web/modules/migrate_plus/migrate_example/migrate_example_setup/config/install/core.entity_form_display.node.migrate_example_beer.default.yml
@@ -39,6 +39,7 @@ content:
     weight: 7
     settings:
       match_operator: CONTAINS
+      match_limit: 10
       size: 60
       placeholder: ''
     third_party_settings: {  }
@@ -81,6 +82,7 @@ content:
     weight: 1
     settings:
       match_operator: CONTAINS
+      match_limit: 10
       size: 60
       placeholder: ''
     third_party_settings: {  }
diff --git a/web/modules/migrate_plus/migrate_example/migrate_example_setup/migrate_example_setup.info.yml b/web/modules/migrate_plus/migrate_example/migrate_example_setup/migrate_example_setup.info.yml
index b9dd2672cdde2bded14b3ec6eadfa77699bc5b2e..2c4afb4340496ad6ffd92d1bbeca171915a6b151 100644
--- a/web/modules/migrate_plus/migrate_example/migrate_example_setup/migrate_example_setup.info.yml
+++ b/web/modules/migrate_plus/migrate_example/migrate_example_setup/migrate_example_setup.info.yml
@@ -2,8 +2,8 @@ type: module
 name: Migrate Example Setup
 description: 'Separate site configuration for the example from the actual migration.'
 package: Migration
-# core: 8.x
-hidden: 1
+core_version_requirement: ^8.8 || ^9
+hidden: true
 dependencies:
   - drupal:comment
   - drupal:image
@@ -11,8 +11,7 @@ dependencies:
   - drupal:options
   - drupal:taxonomy
 
-# Information added by Drupal.org packaging script on 2018-09-06
-version: '8.x-4.0'
-core: '8.x'
+# Information added by Drupal.org packaging script on 2020-04-30
+version: '8.x-5.1'
 project: 'migrate_plus'
-datestamp: 1536264189
+datestamp: 1588261062
diff --git a/web/modules/migrate_plus/migrate_example/migrate_example_setup/migrate_example_setup.install b/web/modules/migrate_plus/migrate_example/migrate_example_setup/migrate_example_setup.install
index 342bc2dcfc529ec24337ede726a6aaa90cf07f27..e0a5fe65f461072e8a39cbea01275d3c2bdaf8e6 100644
--- a/web/modules/migrate_plus/migrate_example/migrate_example_setup/migrate_example_setup.install
+++ b/web/modules/migrate_plus/migrate_example/migrate_example_setup/migrate_example_setup.install
@@ -316,7 +316,7 @@ function migrate_example_beer_data_node() {
     'image_title',
     'image_description',
   ];
-  $query = db_insert('migrate_example_beer_node')
+  $query = \Drupal::database()->insert('migrate_example_beer_node')
     ->fields($fields);
   // Use high bid numbers to avoid overwriting an existing node id.
   $data = [
@@ -381,7 +381,7 @@ function migrate_example_beer_data_account() {
     'sex',
     'beers',
   ];
-  $query = db_insert('migrate_example_beer_account')
+  $query = \Drupal::database()->insert('migrate_example_beer_account')
     ->fields($fields);
   $data = [
     [
@@ -436,7 +436,7 @@ function migrate_example_beer_data_account() {
  */
 function migrate_example_beer_data_comment() {
   $fields = ['bid', 'cid_parent', 'subject', 'body', 'name', 'mail', 'aid'];
-  $query = db_insert('migrate_example_beer_comment')
+  $query = \Drupal::database()->insert('migrate_example_beer_comment')
     ->fields($fields);
   $data = [
     [99999998, NULL, 'im first', 'full body', 'alice', 'alice@example.com', 0],
@@ -464,7 +464,7 @@ function migrate_example_beer_data_comment() {
  */
 function migrate_example_beer_data_topic() {
   $fields = ['style', 'details', 'style_parent', 'region', 'hoppiness'];
-  $query = db_insert('migrate_example_beer_topic')
+  $query = \Drupal::database()->insert('migrate_example_beer_topic')
     ->fields($fields);
   $data = [
     ['ale', 'traditional', NULL, 'Medieval British Isles', 'Medium'],
@@ -488,7 +488,7 @@ function migrate_example_beer_data_topic() {
  */
 function migrate_example_beer_data_topic_node() {
   $fields = ['bid', 'style'];
-  $query = db_insert('migrate_example_beer_topic_node')
+  $query = \Drupal::database()->insert('migrate_example_beer_topic_node')
     ->fields($fields);
   $data = [
     [99999999, 'pilsner'],
diff --git a/web/modules/migrate_plus/migrate_example_advanced/README.txt b/web/modules/migrate_plus/migrate_example_advanced/README.txt
index 8d5a25d807a250ed14fc38b95387f8aa8c1f01a0..a791c70a364e3099bf9d5cc30bf56c7dd018aeb1 100644
--- a/web/modules/migrate_plus/migrate_example_advanced/README.txt
+++ b/web/modules/migrate_plus/migrate_example_advanced/README.txt
@@ -1,15 +1,15 @@
 INTRODUCTION
 ------------
-The migrate_example_advanced module demonstrates some techniques for Drupal 8
+The migrate_example_advanced module demonstrates some techniques for Drupal 8+
 migrations beyond the basics in migrate_example. It includes a group of
 migrations with a wine theme.
 
 SETUP
 -----
 To demonstrate XML migrations as realistically as possible, the setup module
-provides the source data as REST services. So the migrations' references to these
-services can be set up accurately, if you install migrate_example_advanced via
-drush be sure to use the --uri parameter, e.g.
+provides the source data as REST services. So the migrations' references to
+these services can be set up accurately, if you install migrate_example_advanced
+via drush be sure to use the --uri parameter, e.g.
 
 drush en -y migrate_example_advanced --uri=http://d8.local:8083/
 
@@ -42,7 +42,7 @@ example:
 UNDERSTANDING THE MIGRATIONS
 ----------------------------
 Basic techniques demonstrated in the migrate_example module are not rehashed
-here - it is expected that if you are learning Drupal 8 migration, you will
+here - it is expected that if you are learning Drupal 8+ migration, you will
 study and understand those examples first, and use migrate_example_advanced to
 learn about specific techniques beyond those basics. This example doesn't have
 the narrative form of migrate_example - it's more of a grab-bag demonstrating
diff --git a/web/modules/migrate_plus/migrate_example_advanced/config/install/migrate_plus.migration.wine_variety_list.yml.txt b/web/modules/migrate_plus/migrate_example_advanced/config/install/migrate_plus.migration.wine_variety_list.yml.txt
deleted file mode 100644
index ff88862b2fac8c7af304ad6991acd5a7afa84d0f..0000000000000000000000000000000000000000
--- a/web/modules/migrate_plus/migrate_example_advanced/config/install/migrate_plus.migration.wine_variety_list.yml.txt
+++ /dev/null
@@ -1,59 +0,0 @@
-# This migration demonstrates importing from an endpoint listing other endpoints
-# containing individual item data.
-id: wine_variety_list
-label: XML feed of varieties
-migration_group: wine
-migration_tags:
-  - advanced example
-source:
-  # We use the XML source plugin.
-  plugin: xml
-  # Normally, this is one or more fully-qualified URLs or file paths. Because
-  # we can't hardcode your local URL, we provide a relative path here which
-  # hook_install() will rewrite to a full URL for the current site.
-  urls:
-    - /migrate_example_advanced_variety_list?_format=xml
-  item_url: /migrate_example_advanced_variety_list/:id?_format=xml
-  id_selector: /response/items
-  # Visit the URL above (relative to your site root) and look at it. You can see
-  # that <response> is the outer element, and each item we want to import is a
-  # <position> element. The item_xpath value is the xpath to use to query the
-  # desired elements.
-  item_selector: /response/variety
-  # Under 'fields', we list the data items to be imported. The first level keys
-  # are the source field names we want to populate (the names to be used as
-  # sources in the process configuration below). For each field we're importing,
-  # we provide a label (optional - this is for display in migration tools) and
-  # an xpath for retrieving that value. It's important to note that this xpath
-  # is relative to the elements retrieved by item_xpath.
-  fields:
-    category_name:
-      label:
-      selector: name
-    category_details:
-      label:
-      selector: details
-    category_parent:
-      label: 'Unique position identifier'
-      selector: parent
-  # Under 'ids', we identify source fields populated above which will uniquely
-  # identify each imported item. The 'type' makes sure the migration map table
-  # uses the proper schema type for stored the IDs.
-  ids:
-    category_name:
-      type: string
-process:
-  vid:
-    plugin: default_value
-    default_value: migrate_example_wine_varieties
-  name: category_name
-  description: category_details
-  parent:
-    plugin: migration_lookup
-    migration: wine_terms
-    source: category_parent
-destination:
-  plugin: entity:taxonomy_term
-migration_dependencies:
-  require:
-    - wine_terms
diff --git a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced.info.yml b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced.info.yml
index 1d396e34bc8382fd2da518eae5297ae11691a203..34b2bb57080726b8547fbf602848ad6a84ab6df8 100644
--- a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced.info.yml
+++ b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced.info.yml
@@ -1,15 +1,14 @@
 type: module
 name: Migrate Example (Advanced)
-description: 'Specialized examples of Drupal 8 migration.'
+description: 'Specialized examples of Drupal 8+ migration.'
 package: Examples
-# core: 8.x
+core_version_requirement: ^8.8 || ^9
 dependencies:
   - drupal:migrate
   - migrate_plus:migrate_example_advanced_setup
   - migrate_plus:migrate_plus
 
-# Information added by Drupal.org packaging script on 2018-09-06
-version: '8.x-4.0'
-core: '8.x'
+# Information added by Drupal.org packaging script on 2020-04-30
+version: '8.x-5.1'
 project: 'migrate_plus'
-datestamp: 1536264189
+datestamp: 1588261062
diff --git a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/core.entity_form_display.node.migrate_example_producer.default.yml b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/core.entity_form_display.node.migrate_example_producer.default.yml
index 89df7690e983cebcc3bf028bd32347912d3a7c10..2df3f91e1d2d1a00b0cd91435835d82b87a56f13 100644
--- a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/core.entity_form_display.node.migrate_example_producer.default.yml
+++ b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/core.entity_form_display.node.migrate_example_producer.default.yml
@@ -30,6 +30,7 @@ content:
     weight: 32
     settings:
       match_operator: CONTAINS
+      match_limit: 10
       size: 60
       placeholder: ''
     third_party_settings: {  }
@@ -63,6 +64,7 @@ content:
     weight: 5
     settings:
       match_operator: CONTAINS
+      match_limit: 10
       size: 60
       placeholder: ''
     third_party_settings: {  }
diff --git a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/core.entity_form_display.node.migrate_example_wine.default.yml b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/core.entity_form_display.node.migrate_example_wine.default.yml
index 0ef5edb66dc2204585ad25b60b8989c8e228fe0b..d9dff917a97c6d2224aa772574bba2977d388b81 100644
--- a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/core.entity_form_display.node.migrate_example_wine.default.yml
+++ b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/core.entity_form_display.node.migrate_example_wine.default.yml
@@ -56,6 +56,7 @@ content:
     weight: 34
     settings:
       match_operator: CONTAINS
+      match_limit: 10
       size: 60
       placeholder: ''
     third_party_settings: {  }
@@ -70,6 +71,7 @@ content:
     weight: 33
     settings:
       match_operator: CONTAINS
+      match_limit: 10
       size: 60
       placeholder: ''
     third_party_settings: {  }
@@ -78,6 +80,7 @@ content:
     weight: 32
     settings:
       match_operator: CONTAINS
+      match_limit: 10
       size: 60
       placeholder: ''
     third_party_settings: {  }
@@ -111,6 +114,7 @@ content:
     weight: 5
     settings:
       match_operator: CONTAINS
+      match_limit: 10
       size: 60
       placeholder: ''
     third_party_settings: {  }
diff --git a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/migrate_example_advanced_setup.info.yml b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/migrate_example_advanced_setup.info.yml
index 87058656836fededbc49a49bad261a323e8a84a6..d02a5afaf4219f2f26332a86ebcef7a98efeed4a 100644
--- a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/migrate_example_advanced_setup.info.yml
+++ b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/migrate_example_advanced_setup.info.yml
@@ -2,8 +2,8 @@ type: module
 name: Migrate Advanced Example Setup
 description: 'Separate site configuration for the example from the actual migration.'
 package: Migration
-# core: 8.x
-hidden: 1
+core_version_requirement: ^8.8 || ^9
+hidden: true
 dependencies:
   - drupal:comment
   - drupal:image
@@ -11,8 +11,7 @@ dependencies:
   - drupal:taxonomy
   - drupal:rest
 
-# Information added by Drupal.org packaging script on 2018-09-06
-version: '8.x-4.0'
-core: '8.x'
+# Information added by Drupal.org packaging script on 2020-04-30
+version: '8.x-5.1'
 project: 'migrate_plus'
-datestamp: 1536264189
+datestamp: 1588261062
diff --git a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/migrate_example_advanced_setup.install b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/migrate_example_advanced_setup.install
index be9031a6beef0910247f48012ad986417eb5f860..6fd5f7926c46cff2ede66e687dd4f1532cd1b62b 100644
--- a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/migrate_example_advanced_setup.install
+++ b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/migrate_example_advanced_setup.install
@@ -737,7 +737,7 @@ function migrate_example_advanced_data_wine() {
     'region',
     'rating',
   ];
-  $query = db_insert('migrate_example_wine')
+  $query = \Drupal::database()->insert('migrate_example_wine')
     ->fields($fields);
   $data = [
     [
@@ -776,7 +776,7 @@ function migrate_example_advanced_data_wine() {
  */
 function migrate_example_advanced_data_updates() {
   $fields = ['wineid', 'rating'];
-  $query = db_insert('migrate_example_advanced_updates')
+  $query = \Drupal::database()->insert('migrate_example_advanced_updates')
     ->fields($fields);
   $data = [
     [1, 93],
@@ -793,7 +793,7 @@ function migrate_example_advanced_data_updates() {
  */
 function migrate_example_advanced_data_producer() {
   $fields = ['producerid', 'name', 'body', 'excerpt', 'accountid'];
-  $query = db_insert('migrate_example_advanced_producer')
+  $query = \Drupal::database()->insert('migrate_example_advanced_producer')
     ->fields($fields);
   $data = [
     [1, 'Montes', 'Fine Chilean winery', 'Great!', 9],
@@ -824,7 +824,7 @@ function migrate_example_advanced_data_account() {
     'imageid',
     'positions',
   ];
-  $query = db_insert('migrate_example_advanced_account')
+  $query = \Drupal::database()->insert('migrate_example_advanced_account')
     ->fields($fields);
   $data = [
     [
@@ -884,7 +884,7 @@ function migrate_example_advanced_data_account() {
  */
 function migrate_example_advanced_data_account_updates() {
   $fields = ['accountid', 'sex'];
-  $query = db_insert('migrate_example_advanced_account_updates')
+  $query = \Drupal::database()->insert('migrate_example_advanced_account_updates')
     ->fields($fields);
   $data = [
     [1, NULL],
@@ -915,7 +915,7 @@ function migrate_example_advanced_data_comment() {
     'posted',
     'lastchanged',
   ];
-  $query = db_insert('migrate_example_advanced_comment')
+  $query = \Drupal::database()->insert('migrate_example_advanced_comment')
     ->fields($fields);
   $data = [
     [
@@ -1000,7 +1000,7 @@ function migrate_example_advanced_data_comment() {
  */
 function migrate_example_advanced_data_comment_updates() {
   $fields = ['commentid', 'subject'];
-  $query = db_insert('migrate_example_advanced_comment_updates')
+  $query = \Drupal::database()->insert('migrate_example_advanced_comment_updates')
     ->fields($fields);
   $data = [
     [1, 'I am first'],
@@ -1027,7 +1027,7 @@ function migrate_example_advanced_data_categories() {
     'details',
     'ordering',
   ];
-  $query = db_insert('migrate_example_advanced_categories')
+  $query = \Drupal::database()->insert('migrate_example_advanced_categories')
     ->fields($fields);
   $data = [
     [
@@ -1079,7 +1079,7 @@ function migrate_example_advanced_data_categories() {
  */
 function migrate_example_advanced_data_vintages() {
   $fields = ['wineid', 'vintage'];
-  $query = db_insert('migrate_example_advanced_vintages')
+  $query = \Drupal::database()->insert('migrate_example_advanced_vintages')
     ->fields($fields);
   $data = [
     [1, 2006],
@@ -1097,7 +1097,7 @@ function migrate_example_advanced_data_vintages() {
  */
 function migrate_example_advanced_data_variety_updates() {
   $fields = ['categoryid', 'details'];
-  $query = db_insert('migrate_example_advanced_variety_updates')
+  $query = \Drupal::database()->insert('migrate_example_advanced_variety_updates')
     ->fields($fields);
   $data = [
     [1, 'White wines are simpler and sweeter than red'],
@@ -1120,7 +1120,7 @@ function migrate_example_advanced_data_variety_updates() {
  */
 function migrate_example_advanced_data_category_wine() {
   $fields = ['wineid', 'categoryid'];
-  $query = db_insert('migrate_example_advanced_category_wine')
+  $query = \Drupal::database()->insert('migrate_example_advanced_category_wine')
     ->fields($fields);
   $data = [
     [1, 12],
@@ -1138,7 +1138,7 @@ function migrate_example_advanced_data_category_wine() {
  */
 function migrate_example_advanced_data_category_producer() {
   $fields = ['producerid', 'categoryid'];
-  $query = db_insert('migrate_example_advanced_category_producer')
+  $query = \Drupal::database()->insert('migrate_example_advanced_category_producer')
     ->fields($fields);
   $data = [
     [1, 17],
@@ -1154,7 +1154,7 @@ function migrate_example_advanced_data_category_producer() {
  */
 function migrate_example_advanced_data_files() {
   $fields = ['imageid', 'url', 'image_alt', 'image_title', 'wineid'];
-  $query = db_insert('migrate_example_advanced_files')
+  $query = \Drupal::database()->insert('migrate_example_advanced_files')
     ->fields($fields);
   $data = [
     [
@@ -1192,7 +1192,7 @@ function migrate_example_advanced_data_files() {
 function migrate_example_advanced_data_blobs() {
   $blob = file_get_contents('core/misc/druplicon.png');
   $fields = ['imageid', 'imageblob'];
-  $query = db_insert('migrate_example_advanced_blobs')
+  $query = \Drupal::database()->insert('migrate_example_advanced_blobs')
     ->fields($fields);
   $data = [
     [1, $blob],
@@ -1208,7 +1208,7 @@ function migrate_example_advanced_data_blobs() {
  */
 function migrate_example_advanced_data_table_source() {
   $fields = ['fooid', 'field1', 'field2'];
-  $query = db_insert('migrate_example_advanced_table_source')
+  $query = \Drupal::database()->insert('migrate_example_advanced_table_source')
     ->fields($fields);
   $data = [
     [3, 'Some sample data', 58],
diff --git a/web/modules/migrate_plus/migrate_json_example/README.md b/web/modules/migrate_plus/migrate_json_example/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..85f6f87c9118919722f8829406e191967158ac8b
--- /dev/null
+++ b/web/modules/migrate_plus/migrate_json_example/README.md
@@ -0,0 +1,23 @@
+A demonstration of a simple import of a JSON file.
+
+REQUIREMENTS
+============
+You need the contrib modules Migrate Plus and Migrate Tools.
+To make the products.json file available for import, the file will be copied
+from the artifacts folder to your sites/default/files folder.
+
+USAGE
+=====
+Enable the module, check status, import all products and rollback with Drush
+drush en migrate_json_example
+drush migrate-status
+drush migrate-import product
+drush migrate-rollback product
+
+See config/optional/migrate_plus.migration.product.yml for details about the
+migration.
+
+Thanks to Jeff Geerling and Christophe for the original code:
+https://www.jeffgeerling.com/blog/2016/migrate-custom-json-feed-drupal-8-migrate-source-json
+
+https://colorfield.be/blog/drupal-8-json-custom-migration
diff --git a/web/modules/migrate_plus/migrate_json_example/artifacts/products.json b/web/modules/migrate_plus/migrate_json_example/artifacts/products.json
new file mode 100644
index 0000000000000000000000000000000000000000..def2ddf29cc57af5c2bef162670ecf498c29060c
--- /dev/null
+++ b/web/modules/migrate_plus/migrate_json_example/artifacts/products.json
@@ -0,0 +1,16 @@
+{
+  "product": [
+    {
+      "upc": "11111",
+      "name": "Widget",
+      "description": "Helpful for many things.",
+      "price": "14.99"
+    },
+    {
+      "upc": "22222",
+      "name": "Sprocket",
+      "description": "Helpful for things needing sprockets.",
+      "price": "8.99"
+    }
+  ]
+}
diff --git a/web/modules/migrate_plus/migrate_json_example/config/optional/core.entity_form_display.node.product.default.yml b/web/modules/migrate_plus/migrate_json_example/config/optional/core.entity_form_display.node.product.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f907862075556fc1987508ef7d0d59cee2885b52
--- /dev/null
+++ b/web/modules/migrate_plus/migrate_json_example/config/optional/core.entity_form_display.node.product.default.yml
@@ -0,0 +1,89 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.field.node.product.field_description
+    - field.field.node.product.field_price
+    - field.field.node.product.field_upc
+    - node.type.product
+  module:
+    - path
+id: node.product.default
+targetEntityType: node
+bundle: product
+mode: default
+content:
+  created:
+    type: datetime_timestamp
+    weight: 10
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  field_description:
+    weight: 123
+    settings:
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+    type: string_textfield
+    region: content
+  field_price:
+    weight: 124
+    settings:
+      placeholder: ''
+    third_party_settings: {  }
+    type: number
+    region: content
+  field_upc:
+    weight: 122
+    settings:
+      placeholder: ''
+    third_party_settings: {  }
+    type: number
+    region: content
+  path:
+    type: path
+    weight: 30
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  promote:
+    type: boolean_checkbox
+    settings:
+      display_label: true
+    weight: 15
+    region: content
+    third_party_settings: {  }
+  status:
+    type: boolean_checkbox
+    settings:
+      display_label: true
+    weight: 120
+    region: content
+    third_party_settings: {  }
+  sticky:
+    type: boolean_checkbox
+    settings:
+      display_label: true
+    weight: 16
+    region: content
+    third_party_settings: {  }
+  title:
+    type: string_textfield
+    weight: -5
+    region: content
+    settings:
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+  uid:
+    type: entity_reference_autocomplete
+    weight: 5
+    settings:
+      match_operator: CONTAINS
+      match_limit: 10
+      size: 60
+      placeholder: ''
+    region: content
+    third_party_settings: {  }
+hidden: {  }
diff --git a/web/modules/migrate_plus/migrate_json_example/config/optional/core.entity_view_display.node.product.default.yml b/web/modules/migrate_plus/migrate_json_example/config/optional/core.entity_view_display.node.product.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..9e7a9f23cedc30e1bfc6c3fb887fca409143d255
--- /dev/null
+++ b/web/modules/migrate_plus/migrate_json_example/config/optional/core.entity_view_display.node.product.default.yml
@@ -0,0 +1,49 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.field.node.product.field_description
+    - field.field.node.product.field_price
+    - field.field.node.product.field_upc
+    - node.type.product
+  module:
+    - user
+id: node.product.default
+targetEntityType: node
+bundle: product
+mode: default
+content:
+  field_description:
+    weight: 103
+    label: above
+    settings:
+      link_to_entity: false
+    third_party_settings: {  }
+    type: string
+    region: content
+  field_price:
+    weight: 104
+    label: above
+    settings:
+      thousand_separator: ''
+      decimal_separator: .
+      scale: 2
+      prefix_suffix: true
+    third_party_settings: {  }
+    type: number_decimal
+    region: content
+  field_upc:
+    weight: 102
+    label: above
+    settings:
+      thousand_separator: ''
+      prefix_suffix: true
+    third_party_settings: {  }
+    type: number_integer
+    region: content
+  links:
+    weight: 100
+    settings: {  }
+    third_party_settings: {  }
+    region: content
+hidden: {  }
diff --git a/web/modules/migrate_plus/migrate_json_example/config/optional/field.field.node.product.field_description.yml b/web/modules/migrate_plus/migrate_json_example/config/optional/field.field.node.product.field_description.yml
new file mode 100644
index 0000000000000000000000000000000000000000..7bbbd8419a61e0c53f1a2217d469e13661b2b726
--- /dev/null
+++ b/web/modules/migrate_plus/migrate_json_example/config/optional/field.field.node.product.field_description.yml
@@ -0,0 +1,18 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.storage.node.field_description
+    - node.type.product
+id: node.product.field_description
+field_name: field_description
+entity_type: node
+bundle: product
+label: Description
+description: ''
+required: false
+translatable: false
+default_value: {  }
+default_value_callback: ''
+settings: {  }
+field_type: string
diff --git a/web/modules/migrate_plus/migrate_json_example/config/optional/field.field.node.product.field_price.yml b/web/modules/migrate_plus/migrate_json_example/config/optional/field.field.node.product.field_price.yml
new file mode 100644
index 0000000000000000000000000000000000000000..df23a84ae3433716ea53fde3c8b134cdd7149038
--- /dev/null
+++ b/web/modules/migrate_plus/migrate_json_example/config/optional/field.field.node.product.field_price.yml
@@ -0,0 +1,22 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.storage.node.field_price
+    - node.type.product
+id: node.product.field_price
+field_name: field_price
+entity_type: node
+bundle: product
+label: Price
+description: ''
+required: false
+translatable: false
+default_value: {  }
+default_value_callback: ''
+settings:
+  min: null
+  max: null
+  prefix: ''
+  suffix: ''
+field_type: float
diff --git a/web/modules/migrate_plus/migrate_json_example/config/optional/field.field.node.product.field_upc.yml b/web/modules/migrate_plus/migrate_json_example/config/optional/field.field.node.product.field_upc.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ef0a28ec4f463c2f3e4d7d14a1b4270b377c572f
--- /dev/null
+++ b/web/modules/migrate_plus/migrate_json_example/config/optional/field.field.node.product.field_upc.yml
@@ -0,0 +1,22 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.storage.node.field_upc
+    - node.type.product
+id: node.product.field_upc
+field_name: field_upc
+entity_type: node
+bundle: product
+label: UPC
+description: ''
+required: false
+translatable: false
+default_value: {  }
+default_value_callback: ''
+settings:
+  min: null
+  max: null
+  prefix: ''
+  suffix: ''
+field_type: integer
diff --git a/web/modules/migrate_plus/migrate_json_example/config/optional/field.storage.node.field_description.yml b/web/modules/migrate_plus/migrate_json_example/config/optional/field.storage.node.field_description.yml
new file mode 100644
index 0000000000000000000000000000000000000000..a5c39580deed6288f8bbf19b46a45411531546a8
--- /dev/null
+++ b/web/modules/migrate_plus/migrate_json_example/config/optional/field.storage.node.field_description.yml
@@ -0,0 +1,20 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - node
+id: node.field_description
+field_name: field_description
+entity_type: node
+type: string
+settings:
+  max_length: 255
+  is_ascii: false
+  case_sensitive: false
+module: core
+locked: false
+cardinality: 1
+translatable: true
+indexes: {  }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/web/modules/migrate_plus/migrate_json_example/config/optional/field.storage.node.field_price.yml b/web/modules/migrate_plus/migrate_json_example/config/optional/field.storage.node.field_price.yml
new file mode 100644
index 0000000000000000000000000000000000000000..090acad3d9c56acf9992803b94d3c2e6b56c7c01
--- /dev/null
+++ b/web/modules/migrate_plus/migrate_json_example/config/optional/field.storage.node.field_price.yml
@@ -0,0 +1,17 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - node
+id: node.field_price
+field_name: field_price
+entity_type: node
+type: float
+settings: {  }
+module: core
+locked: false
+cardinality: 1
+translatable: true
+indexes: {  }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/web/modules/migrate_plus/migrate_json_example/config/optional/field.storage.node.field_upc.yml b/web/modules/migrate_plus/migrate_json_example/config/optional/field.storage.node.field_upc.yml
new file mode 100644
index 0000000000000000000000000000000000000000..149b11da10109edb40ec787f0773d84092efffac
--- /dev/null
+++ b/web/modules/migrate_plus/migrate_json_example/config/optional/field.storage.node.field_upc.yml
@@ -0,0 +1,19 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - node
+id: node.field_upc
+field_name: field_upc
+entity_type: node
+type: integer
+settings:
+  unsigned: false
+  size: normal
+module: core
+locked: false
+cardinality: 1
+translatable: true
+indexes: {  }
+persist_with_no_fields: false
+custom_storage: false
diff --git a/web/modules/migrate_plus/migrate_json_example/config/optional/migrate_plus.migration.product.yml b/web/modules/migrate_plus/migrate_json_example/config/optional/migrate_plus.migration.product.yml
new file mode 100644
index 0000000000000000000000000000000000000000..16607249464ae612e819c7ab7b4ec01e18b5ce9f
--- /dev/null
+++ b/web/modules/migrate_plus/migrate_json_example/config/optional/migrate_plus.migration.product.yml
@@ -0,0 +1,81 @@
+# This migration demonstrates a simple import from a JSON file.
+id: product
+label: JSON feed of Products
+migration_group: Product
+migration_tags:
+  - json example
+source:
+  # We use the JSON source plugin.
+  plugin: url
+  # In this example we get data from a local file, to get data from a URL
+  # define http as data_fetcher_plugin.
+  # data_fetcher_plugin: http
+  data_fetcher_plugin: file
+  data_parser_plugin: json
+  # The data_parser normally limits the fields passed on to the source plugin
+  # to fields configured to be used as part of the migration. To support more
+  # dynamic migrations, the JSON data parser supports including the original
+  # data for the current row. Simply include the 'include_raw_data' flag set
+  # to `true` to enable this. This option is disabled by default to minimize
+  # memory footprint for migrations that do not need this capability.
+  # include_raw_data: true
+  # Flags whether to track changes to incoming data. If TRUE, we will maintain
+  # hashed source rows to determine whether incoming data has changed.
+  # track_changes: true
+  # Copy the example JSON file in artifacts folder to sites/default/files folder.
+  urls:
+    - 'public://migrate_json_example/products.json'
+  # An xpath-like selector corresponding to the items to be imported.
+  item_selector: product
+  # Under 'fields', we list the data items to be imported. The first level keys
+  # are the source field names we want to populate (the names to be used as
+  # sources in the process configuration below). For each field we're importing,
+  # we provide a label (optional - this is for display in migration tools) and
+  # an xpath for retrieving that value. It's important to note that this xpath
+  # is relative to the elements retrieved by item_selector.
+  fields:
+    -
+      name: upc
+      label: 'Unique product identifier'
+      selector: upc
+    -
+      name: name
+      label: 'Product name'
+      selector: name
+    -
+      name: description
+      label: 'Product description'
+      selector: description
+    -
+      name: price
+      label: 'Product price'
+      selector: price
+  # Under 'ids', we identify source fields populated above which will uniquely
+  # identify each imported item. The 'type' makes sure the migration map table
+  # uses the proper schema type for stored the IDs.
+  ids:
+    upc:
+      type: integer
+process:
+  # Note that the source field names here (name, description and price) were
+  # defined by the 'fields' configuration for the source plugin above.
+  type:
+    plugin: default_value
+    default_value: product
+  title: name
+  field_upc: upc
+  field_description: description
+  field_price: price
+  sticky:
+    plugin: default_value
+    default_value: 0
+  uid:
+    plugin: default_value
+    default_value: 0
+destination:
+  plugin: 'entity:node'
+migration_dependencies: {  }
+dependencies:
+  enforced:
+    module:
+      - migrate_json_example
diff --git a/web/modules/migrate_plus/migrate_json_example/config/optional/node.type.product.yml b/web/modules/migrate_plus/migrate_json_example/config/optional/node.type.product.yml
new file mode 100644
index 0000000000000000000000000000000000000000..18d8499de906378faa77aa9a97e963a493af162a
--- /dev/null
+++ b/web/modules/migrate_plus/migrate_json_example/config/optional/node.type.product.yml
@@ -0,0 +1,17 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - menu_ui
+third_party_settings:
+  menu_ui:
+    available_menus:
+      - main
+    parent: 'main:'
+name: Product
+type: product
+description: ''
+help: ''
+new_revision: true
+preview_mode: 1
+display_submitted: true
diff --git a/web/modules/migrate_plus/migrate_json_example/migrate_json_example.info.yml b/web/modules/migrate_plus/migrate_json_example/migrate_json_example.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..631788117ac086de800de425fec673bca6c4ad7b
--- /dev/null
+++ b/web/modules/migrate_plus/migrate_json_example/migrate_json_example.info.yml
@@ -0,0 +1,13 @@
+type: module
+name: Migrate JSON Example
+description: 'Simple JSON Migration example'
+package: Examples
+core_version_requirement: ^8.8 || ^9
+dependencies:
+  - drupal:migrate
+  - migrate_plus:migrate_plus
+
+# Information added by Drupal.org packaging script on 2020-04-30
+version: '8.x-5.1'
+project: 'migrate_plus'
+datestamp: 1588261062
diff --git a/web/modules/migrate_plus/migrate_json_example/migrate_json_example.install b/web/modules/migrate_plus/migrate_json_example/migrate_json_example.install
new file mode 100644
index 0000000000000000000000000000000000000000..a038a19113e56688d188020dd24b2854b1f24606
--- /dev/null
+++ b/web/modules/migrate_plus/migrate_json_example/migrate_json_example.install
@@ -0,0 +1,22 @@
+<?php
+
+/**
+ * @file
+ * Install, update, and uninstall functions for migrate_json_example.
+ */
+
+use Drupal\Core\File\FileSystemInterface;
+
+/**
+ * Copies the example file to the sites/default/files folder.
+ */
+function migrate_json_example_install() {
+  // Create the example file directory and ensure it's writable.
+  $directory = \Drupal::config('system.file')->get('default_scheme') . '://migrate_json_example';
+  \Drupal::service('file_system')->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
+
+  // Copy the example file to example directory.
+  $module_path = drupal_get_path('module', 'migrate_json_example');
+  $file_source = $module_path . '/artifacts/products.json';
+  \Drupal::service('file_system')->copy($file_source, $directory . '/products.json', FileSystemInterface::EXISTS_REPLACE);
+}
diff --git a/web/modules/migrate_plus/migrate_plus.info.yml b/web/modules/migrate_plus/migrate_plus.info.yml
index c5c0e29c67a6c8c57c7746586b8ec51434e16dd7..f23cac5e66e659697ccaeea8118309a8a59d1b4e 100644
--- a/web/modules/migrate_plus/migrate_plus.info.yml
+++ b/web/modules/migrate_plus/migrate_plus.info.yml
@@ -2,12 +2,11 @@ type: module
 name: Migrate Plus
 description: 'Enhancements to core migration support'
 package: Migration
-# core: 8.x
+core_version_requirement: ^8.8 || ^9
 dependencies:
-  - drupal:migrate (>=8.3)
+  - drupal:migrate
 
-# Information added by Drupal.org packaging script on 2018-09-06
-version: '8.x-4.0'
-core: '8.x'
+# Information added by Drupal.org packaging script on 2020-04-30
+version: '8.x-5.1'
 project: 'migrate_plus'
-datestamp: 1536264189
+datestamp: 1588261062
diff --git a/web/modules/migrate_plus/migrate_plus.module b/web/modules/migrate_plus/migrate_plus.module
index 0dbddc473585b414b9e32191b7f2164e098f9022..6cb976c4dd4b529501874fcdba9cabecc0ed65c4 100644
--- a/web/modules/migrate_plus/migrate_plus.module
+++ b/web/modules/migrate_plus/migrate_plus.module
@@ -5,8 +5,9 @@
  * Provides enhancements for implementing and managing migrations.
  */
 
-use Drupal\migrate\Plugin\MigrationInterface;
 use Drupal\migrate\Plugin\MigrateSourceInterface;
+use Drupal\migrate\Plugin\Migration;
+use Drupal\migrate\Plugin\MigrationInterface;
 use Drupal\migrate\Row;
 use Drupal\migrate_plus\Entity\MigrationGroup;
 use Drupal\migrate_plus\Event\MigrateEvents;
@@ -16,11 +17,10 @@
  * Implements hook_migration_plugins_alter().
  */
 function migrate_plus_migration_plugins_alter(array &$migrations) {
-  /** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
-  foreach ($migrations as $id => $migration) {
+  foreach (array_keys($migrations) as $id) {
     // Add the default class where empty.
-    if (empty($migration['class'])) {
-      $migrations[$id]['class'] = 'Drupal\migrate\Plugin\Migration';
+    if (empty($migrations[$id]['class'])) {
+      $migrations[$id]['class'] = Migration::class;
     }
 
     // For derived configuration entity-based migrations, strip the deriver
@@ -36,16 +36,16 @@ function migrate_plus_migration_plugins_alter(array &$migrations) {
     }
 
     // Integrate shared group configuration into the migration.
-    if (empty($migration['migration_group'])) {
-      $migration['migration_group'] = 'default';
+    if (empty($migrations[$id]['migration_group'])) {
+      $migrations[$id]['migration_group'] = 'default';
     }
-    $group = MigrationGroup::load($migration['migration_group']);
+    $group = MigrationGroup::load($migrations[$id]['migration_group']);
     if (empty($group)) {
       // If the specified group does not exist, create it. Provide a little more
       // for the 'default' group.
       $group_properties = [];
-      $group_properties['id'] = $migration['migration_group'];
-      if ($migration['migration_group'] == 'default') {
+      $group_properties['id'] = $migrations[$id]['migration_group'];
+      if ($migrations[$id]['migration_group'] == 'default') {
         $group_properties['label'] = 'Default';
         $group_properties['description'] = 'A container for any migrations not explicitly assigned to a group.';
       }
@@ -61,7 +61,7 @@ function migrate_plus_migration_plugins_alter(array &$migrations) {
       continue;
     }
     foreach ($shared_configuration as $key => $group_value) {
-      $migration_value = $migration[$key];
+      $migration_value = $migrations[$id][$key];
       // Where both the migration and the group provide arrays, replace
       // recursively (so each key collision is resolved in favor of the
       // migration).
diff --git a/web/modules/migrate_plus/phpcs.xml b/web/modules/migrate_plus/phpcs.xml
deleted file mode 100644
index a18054efbc2dc47c2595364bb13a992a0fffbb4a..0000000000000000000000000000000000000000
--- a/web/modules/migrate_plus/phpcs.xml
+++ /dev/null
@@ -1,207 +0,0 @@
-<?xml version="1.0"?>
-<ruleset name="Drupal coding standards">
-  <description>Drupal 8 coding standards</description>
-
-  <file>.</file>
-  <arg name="extensions" value="inc,install,module,php,profile,test,theme"/>
-
-  <!--Exclude third party code.-->
-  <exclude-pattern>./vendor/*</exclude-pattern>
-  <!--Run Drupal standards.-->
-  <rule ref="Drupal.Array"/>
-  <rule ref="Drupal.Classes"/>
-  <rule ref="Drupal.Commenting">
-    <!-- TagsNotGrouped and ParamGroup have false-positives.
-      @see https://www.drupal.org/node/2060925 -->
-    <exclude name="Drupal.Commenting.DocComment.TagsNotGrouped"/>
-    <exclude name="Drupal.Commenting.DocComment.ParamGroup"/>
-  </rule>
-  <rule ref="Drupal.ControlStructures"/>
-  <rule ref="Drupal.CSS"/>
-  <rule ref="Drupal.Files"/>
-  <rule ref="Drupal.Formatting"/>
-  <rule ref="Drupal.Functions"/>
-  <rule ref="Drupal.InfoFiles"/>
-  <rule ref="Drupal.Methods"/>
-  <rule ref="Drupal.NamingConventions"/>
-  <rule ref="Drupal.Scope"/>
-  <rule ref="Drupal.Semantics"/>
-  <rule ref="Drupal.Strings"/>
-  <rule ref="Drupal.WhiteSpace"/>
-
-  <!-- Drupal Practice sniffs -->
-  <rule ref="DrupalPractice.Commenting"/>
-
-  <!-- Generic sniffs -->
-  <rule ref="Generic.Arrays.DisallowLongArraySyntax"/>
-  <rule ref="Generic.Files.ByteOrderMark"/>
-  <rule ref="Generic.Files.LineEndings"/>
-  <rule ref="Generic.Formatting.SpaceAfterCast"/>
-  <rule ref="Generic.Functions.FunctionCallArgumentSpacing"/>
-  <rule ref="Generic.Functions.OpeningFunctionBraceKernighanRitchie">
-    <properties>
-      <property name="checkClosures" value="true"/>
-    </properties>
-  </rule>
-  <rule ref="Generic.NamingConventions.ConstructorName"/>
-  <rule ref="Generic.NamingConventions.UpperCaseConstantName"/>
-  <rule ref="Generic.PHP.DeprecatedFunctions"/>
-  <rule ref="Generic.PHP.DisallowShortOpenTag"/>
-  <rule ref="Generic.PHP.LowerCaseKeyword"/>
-  <rule ref="Generic.PHP.UpperCaseConstant"/>
-  <rule ref="Generic.WhiteSpace.DisallowTabIndent"/>
-
-  <!-- MySource sniffs -->
-  <rule ref="MySource.Debug.DebugCode"/>
-
-  <!-- PEAR sniffs -->
-  <rule ref="PEAR.Files.IncludingFile"/>
-  <!-- Disable some error messages that we do not want. -->
-  <rule ref="PEAR.Files.IncludingFile.UseIncludeOnce">
-    <severity>0</severity>
-  </rule>
-  <rule ref="PEAR.Files.IncludingFile.UseInclude">
-    <severity>0</severity>
-  </rule>
-  <rule ref="PEAR.Files.IncludingFile.UseRequireOnce">
-    <severity>0</severity>
-  </rule>
-  <rule ref="PEAR.Files.IncludingFile.UseRequire">
-    <severity>0</severity>
-  </rule>
-  <rule ref="PEAR.Functions.ValidDefaultValue"/>
-
-  <!-- PEAR sniffs -->
-  <rule ref="PEAR.Functions.FunctionCallSignature"/>
-  <!-- The sniffs inside PEAR.Functions.FunctionCallSignature silenced below are
-    also silenced in Drupal CS' ruleset.xml. The code below is a 1-on-1 copy
-    from that file. -->
-  <!-- Disable some error messages that we already cover. -->
-  <rule ref="PEAR.Functions.FunctionCallSignature.SpaceAfterOpenBracket">
-    <severity>0</severity>
-  </rule>
-  <rule ref="PEAR.Functions.FunctionCallSignature.SpaceBeforeCloseBracket">
-    <severity>0</severity>
-  </rule>
-  <!-- Disable some error messages that we do not want. -->
-  <rule ref="PEAR.Functions.FunctionCallSignature.Indent">
-    <severity>0</severity>
-  </rule>
-  <rule ref="PEAR.Functions.FunctionCallSignature.ContentAfterOpenBracket">
-    <severity>0</severity>
-  </rule>
-  <rule ref="PEAR.Functions.FunctionCallSignature.CloseBracketLine">
-    <severity>0</severity>
-  </rule>
-  <rule ref="PEAR.Functions.FunctionCallSignature.EmptyLine">
-    <severity>0</severity>
-  </rule>
-
-  <!-- PSR-2 sniffs -->
-  <rule ref="PSR2.Classes.PropertyDeclaration">
-    <exclude name="PSR2.Classes.PropertyDeclaration.Underscore"/>
-  </rule>
-  <rule ref="PSR2.Namespaces.NamespaceDeclaration"/>
-  <rule ref="PSR2.Namespaces.UseDeclaration">
-    <exclude name="PSR2.Namespaces.UseDeclaration.UseAfterNamespace"/>
-  </rule>
-
-  <!-- Squiz sniffs -->
-  <rule ref="Squiz.Arrays.ArrayBracketSpacing"/>
-  <rule ref="Squiz.Arrays.ArrayDeclaration">
-    <exclude name="Squiz.Arrays.ArrayDeclaration.NoKeySpecified"/>
-    <exclude name="Squiz.Arrays.ArrayDeclaration.KeySpecified"/>
-  </rule>
-  <!-- Disable some error messages that we do not want. -->
-  <rule ref="Squiz.Arrays.ArrayDeclaration.CloseBraceNotAligned">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Arrays.ArrayDeclaration.DoubleArrowNotAligned">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Arrays.ArrayDeclaration.FirstValueNoNewline">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Arrays.ArrayDeclaration.KeyNotAligned">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Arrays.ArrayDeclaration.MultiLineNotAllowed">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Arrays.ArrayDeclaration.NoComma">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Arrays.ArrayDeclaration.NoCommaAfterLast">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Arrays.ArrayDeclaration.NotLowerCase">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Arrays.ArrayDeclaration.SingleLineNotAllowed">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Arrays.ArrayDeclaration.ValueNotAligned">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Arrays.ArrayDeclaration.ValueNoNewline">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.ControlStructures.ForEachLoopDeclaration"/>
-  <!-- Disable some error messages that we already cover. -->
-  <rule ref="Squiz.ControlStructures.ForEachLoopDeclaration.AsNotLower">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.ControlStructures.ForEachLoopDeclaration.SpaceAfterOpen">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.ControlStructures.ForEachLoopDeclaration.SpaceBeforeClose">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.ControlStructures.ForLoopDeclaration"/>
-  <!-- Disable some error messages that we already cover. -->
-  <rule ref="Squiz.ControlStructures.ForLoopDeclaration.SpacingAfterOpen">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.ControlStructures.ForLoopDeclaration.SpacingBeforeClose">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Functions.MultiLineFunctionDeclaration"/>
-  <rule ref="Squiz.Functions.MultiLineFunctionDeclaration.BraceOnSameLine">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Functions.MultiLineFunctionDeclaration.ContentAfterBrace">
-    <severity>0</severity>
-  </rule>
-  <!-- Standard yet to be finalized on this (https://www.drupal.org/node/1539712). -->
-  <rule ref="Squiz.Functions.MultiLineFunctionDeclaration.FirstParamSpacing">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Functions.MultiLineFunctionDeclaration.Indent">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Functions.MultiLineFunctionDeclaration.CloseBracketLine">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Functions.FunctionDeclarationArgumentSpacing">
-    <properties>
-      <property name="equalsSpacing" value="1"/>
-    </properties>
-  </rule>
-  <rule ref="Squiz.Functions.FunctionDeclarationArgumentSpacing.NoSpaceBeforeArg">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.PHP.LowercasePHPFunctions"/>
-  <rule ref="Squiz.Strings.ConcatenationSpacing">
-    <properties>
-      <property name="spacing" value="1"/>
-      <property name="ignoreNewlines" value="true"/>
-    </properties>
-  </rule>
-  <rule ref="Squiz.WhiteSpace.LanguageConstructSpacing" />
-  <rule ref="Squiz.WhiteSpace.SemicolonSpacing"/>
-  <rule ref="Squiz.WhiteSpace.SuperfluousWhitespace"/>
-
-  <!-- Zend sniffs -->
-  <rule ref="Zend.Files.ClosingTag"/>
-
-</ruleset>
diff --git a/web/modules/migrate_plus/src/DataFetcherPluginBase.php b/web/modules/migrate_plus/src/DataFetcherPluginBase.php
index 9e413fe106a234a07846408f35fc1dfcbc2c84d6..eb21f301da43f74e05bf93dc8b00ca41627caad8 100644
--- a/web/modules/migrate_plus/src/DataFetcherPluginBase.php
+++ b/web/modules/migrate_plus/src/DataFetcherPluginBase.php
@@ -15,13 +15,6 @@
  */
 abstract class DataFetcherPluginBase extends PluginBase implements DataFetcherPluginInterface {
 
-  /**
-   * {@inheritdoc}
-   */
-  public function __construct(array $configuration, $plugin_id, $plugin_definition) {
-    parent::__construct($configuration, $plugin_id, $plugin_definition);
-  }
-
   /**
    * {@inheritdoc}
    */
diff --git a/web/modules/migrate_plus/src/DataParserPluginBase.php b/web/modules/migrate_plus/src/DataParserPluginBase.php
index c74c6c53acbefb03f4812335145229d00fc3cead..3e8fd47a2cd8d1895cdc7f42779243b5066b85cf 100644
--- a/web/modules/migrate_plus/src/DataParserPluginBase.php
+++ b/web/modules/migrate_plus/src/DataParserPluginBase.php
@@ -80,10 +80,10 @@ public static function create(ContainerInterface $container, array $configuratio
    *   The data fetcher plugin.
    */
   public function getDataFetcherPlugin() {
-    if (!isset($this->dataFetcherPlugin)) {
-      $this->dataFetcherPlugin = \Drupal::service('plugin.manager.migrate_plus.data_fetcher')->createInstance($this->configuration['data_fetcher_plugin'], $this->configuration);
+    if (!isset($this->dataFetcher)) {
+      $this->dataFetcher = \Drupal::service('plugin.manager.migrate_plus.data_fetcher')->createInstance($this->configuration['data_fetcher_plugin'], $this->configuration);
     }
-    return $this->dataFetcherPlugin;
+    return $this->dataFetcher;
   }
 
   /**
@@ -149,13 +149,17 @@ abstract protected function fetchNextRow();
    *   TRUE if a valid source URL was opened
    */
   protected function nextSource() {
+    if (empty($this->urls)) {
+      return FALSE;
+    }
+
     while ($this->activeUrl === NULL || (count($this->urls) - 1) > $this->activeUrl) {
       if (is_null($this->activeUrl)) {
         $this->activeUrl = 0;
       }
       else {
         // Increment the activeUrl so we try to load the next source.
-        $this->activeUrl = $this->activeUrl + 1;
+        ++$this->activeUrl;
         if ($this->activeUrl >= count($this->urls)) {
           return FALSE;
         }
diff --git a/web/modules/migrate_plus/src/Entity/Migration.php b/web/modules/migrate_plus/src/Entity/Migration.php
index 6359825cbecfa3977035b768a887a437003de912..c5a04d2ac80c388787b7c612efd43fd05837289f 100644
--- a/web/modules/migrate_plus/src/Entity/Migration.php
+++ b/web/modules/migrate_plus/src/Entity/Migration.php
@@ -18,8 +18,23 @@
  *   entity_keys = {
  *     "id" = "id",
  *     "label" = "label",
- *     "weight" = "weight"
- *   }
+ *     "weight" = "weight",
+ *     "status" = "status"
+ *   },
+ *   config_export = {
+ *     "id",
+ *     "class",
+ *     "field_plugin_method",
+ *     "cck_plugin_method",
+ *     "migration_tags",
+ *     "migration_group",
+ *     "status",
+ *     "label",
+ *     "source",
+ *     "process",
+ *     "destination",
+ *     "migration_dependencies",
+ *   },
  * )
  */
 class Migration extends ConfigEntityBase implements MigrationInterface {
@@ -57,6 +72,10 @@ protected static function invalidateTagsOnDelete(EntityTypeInterface $entity_typ
   /**
    * Create a configuration entity from a core migration plugin's configuration.
    *
+   * Note the list of properties being transplanted from the plugin instance or
+   * definition into the Migration config entity must remain in sync with the
+   * keys listed in the "config_export" annotation key of this class.
+   *
    * @param string $plugin_id
    *   ID of a migration plugin managed by MigrationPluginManager.
    * @param string $new_plugin_id
@@ -68,16 +87,18 @@ protected static function invalidateTagsOnDelete(EntityTypeInterface $entity_typ
   public static function createEntityFromPlugin($plugin_id, $new_plugin_id) {
     /** @var \Drupal\migrate\Plugin\MigrationPluginManagerInterface $plugin_manager */
     $plugin_manager = \Drupal::service('plugin.manager.migration');
+    /** @var \Drupal\migrate\Plugin\Migration $migration_plugin */
     $migration_plugin = $plugin_manager->createInstance($plugin_id);
     $entity_array['id'] = $new_plugin_id;
-    $entity_array['migration_tags'] = $migration_plugin->get('migration_tags');
+    $plugin_definition = $migration_plugin->getPluginDefinition();
+    $migration_details['class'] = $plugin_definition['class'];
+    $entity_array['migration_tags'] = $migration_plugin->getMigrationTags();
     $entity_array['label'] = $migration_plugin->label();
     $entity_array['source'] = $migration_plugin->getSourceConfiguration();
     $entity_array['destination'] = $migration_plugin->getDestinationConfiguration();
     $entity_array['process'] = $migration_plugin->getProcess();
     $entity_array['migration_dependencies'] = $migration_plugin->getMigrationDependencies();
-    $migration_entity = static::create($entity_array);
-    return $migration_entity;
+    return static::create($entity_array);
   }
 
 }
diff --git a/web/modules/migrate_plus/src/Entity/MigrationGroup.php b/web/modules/migrate_plus/src/Entity/MigrationGroup.php
index 519dbe24132da5b03436c281830a9a66d2325535..96845b0318db9694f97a4aa7fd867b8e1a5dbe2e 100644
--- a/web/modules/migrate_plus/src/Entity/MigrationGroup.php
+++ b/web/modules/migrate_plus/src/Entity/MigrationGroup.php
@@ -19,7 +19,15 @@
  *   entity_keys = {
  *     "id" = "id",
  *     "label" = "label"
- *   }
+ *   },
+ *   config_export = {
+ *     "id",
+ *     "label",
+ *     "description",
+ *     "source_type",
+ *     "module",
+ *     "shared_configuration",
+ *   },
  * )
  */
 class MigrationGroup extends ConfigEntityBase implements MigrationGroupInterface {
diff --git a/web/modules/migrate_plus/src/Event/MigrateEvents.php b/web/modules/migrate_plus/src/Event/MigrateEvents.php
index 6ce44ee62e7cd8384a11f8e6d98afefd8809edbf..4d9904bb6eb0ae0b875fa450a845fa00a43d54a8 100644
--- a/web/modules/migrate_plus/src/Event/MigrateEvents.php
+++ b/web/modules/migrate_plus/src/Event/MigrateEvents.php
@@ -27,4 +27,17 @@ final class MigrateEvents {
    */
   const PREPARE_ROW = 'migrate_plus.prepare_row';
 
+  /**
+   * Name of the event fired when a source item is missing.
+   *
+   * This event allows modules to perform an action whenever a specific item is
+   * missing from the source. The event listener method receives a
+   * \Drupal\migrate\Event\MigrateRowDeleteEvent instance.
+   *
+   * @Event
+   *
+   * @see \Drupal\migrate\Event\MigrateRowDeleteEvent
+   */
+  const MISSING_SOURCE_ITEM = 'migrate_plus.missing_source_item';
+
 }
diff --git a/web/modules/migrate_plus/src/Event/MigratePrepareRowEvent.php b/web/modules/migrate_plus/src/Event/MigratePrepareRowEvent.php
index 4727f3ee376ce4d6c7fcfde64a4e9e4e5ca09f8f..0534a20ae7a23a94450ee6365365324558943fce 100644
--- a/web/modules/migrate_plus/src/Event/MigratePrepareRowEvent.php
+++ b/web/modules/migrate_plus/src/Event/MigratePrepareRowEvent.php
@@ -2,8 +2,8 @@
 
 namespace Drupal\migrate_plus\Event;
 
-use Drupal\migrate\Plugin\MigrationInterface;
 use Drupal\migrate\Plugin\MigrateSourceInterface;
+use Drupal\migrate\Plugin\MigrationInterface;
 use Drupal\migrate\Row;
 use Symfony\Component\EventDispatcher\Event;
 
@@ -38,10 +38,8 @@ class MigratePrepareRowEvent extends Event {
    *
    * @param \Drupal\migrate\Row $row
    *   Row of source data to be analyzed/manipulated.
-   *
    * @param \Drupal\migrate\Plugin\MigrateSourceInterface $source
    *   Source plugin that is the source of the event.
-   *
    * @param \Drupal\migrate\Plugin\MigrationInterface $migration
    *   Migration entity.
    */
diff --git a/web/modules/migrate_plus/src/Plugin/MigrationConfigDeriver.php b/web/modules/migrate_plus/src/Plugin/MigrationConfigDeriver.php
index 0135444974f3da4f93e587d787563b8977e2c0e6..d66892128881e6b5db6124e22f2192938ce12185 100644
--- a/web/modules/migrate_plus/src/Plugin/MigrationConfigDeriver.php
+++ b/web/modules/migrate_plus/src/Plugin/MigrationConfigDeriver.php
@@ -20,6 +20,10 @@ public function getDerivativeDefinitions($base_plugin_definition) {
     $migrations = Migration::loadMultiple();
     /** @var \Drupal\migrate_plus\Entity\MigrationInterface $migration */
     foreach ($migrations as $id => $migration) {
+      if (!$migration->status()) {
+        continue;
+      }
+
       $this->derivatives[$id] = $migration->toArray();
     }
     return $this->derivatives;
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/destination/Table.php b/web/modules/migrate_plus/src/Plugin/migrate/destination/Table.php
index f04d9f66d75f9bcb5933445e8b25fdea3d1d28ac..00d24159e8f2a0379ef943aa465a48c693fafa85 100644
--- a/web/modules/migrate_plus/src/Plugin/migrate/destination/Table.php
+++ b/web/modules/migrate_plus/src/Plugin/migrate/destination/Table.php
@@ -5,10 +5,12 @@
 use Drupal\Core\Database\Connection;
 use Drupal\Core\Database\Database;
 use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\migrate\Event\ImportAwareInterface;
+use Drupal\migrate\Event\MigrateImportEvent;
 use Drupal\migrate\MigrateException;
 use Drupal\migrate\MigrateSkipProcessException;
-use Drupal\migrate\Plugin\MigrationInterface;
 use Drupal\migrate\Plugin\migrate\destination\DestinationBase;
+use Drupal\migrate\Plugin\MigrationInterface;
 use Drupal\migrate\Row;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
@@ -17,11 +19,58 @@
  *
  * Use this plugin for a table not registered with Drupal Schema API.
  *
+ * Examples:
+ *
+ * @code
+ *   destination:
+ *     plugin: table
+ *     # Key for the database connection to use for inserting records.
+ *     database_key: roads_db
+ *     # DB table for storage.
+ *     table_name: roads
+ *     # Maximum number of rows to insert in one query.
+ *     batch_size: 3
+ *     # Fields used by migrate to identify table rows uniquely. At least one
+ *     # field is required.
+ *     id_fields:
+ *       name:
+ *         type: string
+ *       suburb:
+ *         type: string
+ *       ward:
+ *         type: string
+ *     # Mapping of column names to values set in migrate process.
+ *     fields:
+ *       name: name
+ *       owner: owner
+ *       suburb: suburb
+ *       ward: ward
+ *       type: type
+ * @endcode
+ *
+ * For numeric id fields, migrate can generate the values on-the-fly, by
+ * enabling use_auto_increment; in such case, the id field may be ommitted from
+ * the 'fields' section:
+ *
+ * @code
+ *   destination:
+ *     plugin: table
+ *     # ...
+ *     id_fields:
+ *       my_id_field:
+ *         type: integer
+ *         use_auto_increment: true
+ *     # ...
+ *     fields:
+ *       non_my_id_field_1: non_my_id_field_1
+ *       non_my_id_field_2: non_my_id_field_2
+ * @endcode
+ *
  * @MigrateDestination(
  *   id = "table"
  * )
  */
-class Table extends DestinationBase implements ContainerFactoryPluginInterface {
+class Table extends DestinationBase implements ContainerFactoryPluginInterface, ImportAwareInterface {
 
   /**
    * The name of the destination table.
@@ -51,6 +100,27 @@ class Table extends DestinationBase implements ContainerFactoryPluginInterface {
    */
   protected $dbConnection;
 
+  /**
+   * Maximum number of rows to insert in one query.
+   *
+   * @var int
+   */
+  protected $batchSize;
+
+  /**
+   * The query object being built row-by-row.
+   *
+   * @var array
+   */
+  protected $rowsToInsert = [];
+
+  /**
+   * The highest ID seen or created so far on this table.
+   *
+   * @var int
+   */
+  protected $lastId = 0;
+
   /**
    * Constructs a new Table.
    *
@@ -71,6 +141,7 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition
     $this->tableName = $configuration['table_name'];
     $this->idFields = $configuration['id_fields'];
     $this->fields = isset($configuration['fields']) ? $configuration['fields'] : [];
+    $this->batchSize = isset($configuration['batch_size']) ? $configuration['batch_size'] : 1;
     $this->supportsRollback = TRUE;
   }
 
@@ -79,7 +150,6 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition
    */
   public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration = NULL) {
     $db_key = !empty($configuration['database_key']) ? $configuration['database_key'] : NULL;
-
     return new static(
       $configuration,
       $plugin_id,
@@ -110,23 +180,79 @@ public function fields(MigrationInterface $migration = NULL) {
    * {@inheritdoc}
    */
   public function import(Row $row, array $old_destination_id_values = []) {
-    $id = $row->getSourceIdValues();
-    if (count($id) != count($this->idFields)) {
-      throw new MigrateSkipProcessException('All the id fields are required for a table migration.');
+    // Skip batching (if configured) for updates.
+    $batch_inserts = ($this->batchSize > 1 && empty($old_destination_id_values));
+    $ids = [];
+    foreach ($this->idFields as $field => $fieldInfo) {
+      if ($row->hasDestinationProperty($field)) {
+        $ids[$field] = $row->getDestinationProperty($field);
+      }
+      elseif (!$row->hasDestinationProperty($field) && empty($fieldInfo['use_auto_increment'])) {
+        throw new MigrateSkipProcessException('All the id fields are required for a table migration.');
+      }
+      // When batching, we do the auto-incrementing ourselves.
+      elseif ($batch_inserts && $fieldInfo['use_auto_increment']) {
+        if (count($this->rowsToInsert) === 0) {
+          // Get the highest existing ID, so we will create IDs above it.
+          $this->lastId = $this->dbConnection->query("SELECT MAX($field) AS MaxId FROM {{$this->tableName}}")
+            ->fetchField();
+          if (!$this->lastId) {
+            $this->lastId = 0;
+          }
+        }
+        $id = ++$this->lastId;
+        $ids[$field] = $id;
+        $row->setDestinationProperty($field, $id);
+      }
     }
 
-    $values = $row->getDestination();
+    // When batching, make sure we have the same properties in the same order
+    // every time.
+    $values = [];
+    if ($batch_inserts) {
+      $destination_properties = array_keys($this->migration->getProcess());
+      $destination_properties = array_merge($destination_properties,
+        array_keys($this->idFields));
+      sort($destination_properties);
+      $destination_values = $row->getDestination();
+      foreach ($destination_properties as $property_name) {
+        $values[$property_name] = $destination_values[$property_name] ?? NULL;
+      }
+    }
+    else {
+      $values = $row->getDestination();
+    }
 
     if ($this->fields) {
       $values = array_intersect_key($values, $this->fields);
     }
 
-    $status = $this->dbConnection->merge($this->tableName)
-      ->key($id)
-      ->fields($values)
-      ->execute();
-
-    return $status ? $id : NULL;
+    if ($batch_inserts) {
+      $this->rowsToInsert[] = $values;
+      if (count($this->rowsToInsert) >= $this->batchSize) {
+        $this->flushInserts();
+      }
+      $status = TRUE;
+    }
+    // Row contains empty id field with use_auto_increment enabled.
+    elseif (count($ids) < count($this->idFields)) {
+      $status = $id = $this->dbConnection->insert($this->tableName)
+        ->fields($values)
+        ->execute();
+      foreach ($this->idFields as $field => $fieldInfo) {
+        if (isset($fieldInfo['use_auto_increment']) && $fieldInfo['use_auto_increment'] === TRUE && !$row->hasDestinationProperty($field)) {
+          $row->setDestinationProperty($field, $id);
+          $ids[$field] = $id;
+        }
+      }
+    }
+    else {
+      $status = $this->dbConnection->merge($this->tableName)
+        ->keys($ids)
+        ->fields($values)
+        ->execute();
+    }
+    return $status ? $ids : NULL;
   }
 
   /**
@@ -140,4 +266,43 @@ public function rollback(array $destination_identifier) {
     $delete->execute();
   }
 
+  /**
+   * Execute the insert query and reset everything.
+   */
+  public function flushInserts() {
+    if (count($this->rowsToInsert) > 0) {
+      $batch_query = $this->dbConnection->insert($this->tableName)
+        ->fields(array_keys($this->rowsToInsert[0]));
+      foreach ($this->rowsToInsert as $row) {
+        $batch_query->values(array_values($row));
+      }
+      // Empty the queue first, so if the statement throws an error we don't
+      // end up here trying to execute the same statement (plus one row).
+      $this->rowsToInsert = [];
+      $batch_query->execute();
+    }
+  }
+
+  /**
+   * {@inheritDoc}
+   */
+  public function preImport(MigrateImportEvent $event) {
+  }
+
+  /**
+   * {@inheritDoc}
+   */
+  public function postImport(MigrateImportEvent $event) {
+    // At the conclusion of a given migration, make sure batched inserts go in.
+    $this->flushInserts();
+  }
+
+  /**
+   * Make absolutely sure batched inserts are processed (especially for stubs).
+   */
+  public function __destruct() {
+    // At the conclusion of a given migration, make sure batched inserts go in.
+    $this->flushInserts();
+  }
+
 }
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/process/DefaultEntityValue.php b/web/modules/migrate_plus/src/Plugin/migrate/process/DefaultEntityValue.php
new file mode 100644
index 0000000000000000000000000000000000000000..78ef4cadd4682870074038db276e237c1b305392
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/DefaultEntityValue.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin\migrate\process;
+
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\Row;
+
+/**
+ * Returns EntityLookup for a given default value if input is empty.
+ *
+ * @see \Drupal\migrate_plus\Plugin\migrate\process\EntityLookup
+ *
+ * Example usage with full configuration:
+ * @code
+ * process:
+ *   uid:
+ *     -
+ *       plugin: migration_lookup
+ *       migration: users
+ *       source: author
+ *     -
+ *       plugin: default_entity_value
+ *       entity_type: user
+ *       value_key: name
+ *       ignore_case: true
+ *       default_value: editorial
+ * @endcode
+ *
+ * @MigrateProcessPlugin(
+ *   id = "default_entity_value",
+ *   handle_multiples = TRUE
+ * )
+ */
+class DefaultEntityValue extends EntityLookup {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
+    if (!empty($value)) {
+      return $value;
+    }
+    return parent::transform($this->configuration['default_value'], $migrate_executable, $row, $destination_property);
+  }
+
+}
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/process/Dom.php b/web/modules/migrate_plus/src/Plugin/migrate/process/Dom.php
new file mode 100644
index 0000000000000000000000000000000000000000..1d1c50fbb766d0b4e5e9cbae88b2bddb307eb7d1
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/Dom.php
@@ -0,0 +1,233 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin\migrate\process;
+
+use Drupal\Component\Utility\Html;
+use Drupal\migrate\MigrateException;
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\Plugin\MigrationInterface;
+use Drupal\migrate\ProcessPluginBase;
+use Drupal\migrate\Row;
+
+/**
+ * Handles string to DOM and back conversions.
+ *
+ * Available configuration keys:
+ * - method: Action to perform. Possible values:
+ *   - import: string to DomDocument.
+ *   - export: DomDocument to string.
+ * - non_root: (optional) Assume the passed HTML is not a complete hierarchy,
+ *   but only a subset inside body element. Defaults to true.
+ *
+ * The following keys are only used if the method is 'import':
+ * - log_messages: (optional) When parsing HTML, libxml may trigger
+ *   warnings. If this option is set to true, it will log them as migration
+ *   messages. Otherwise, it will not handle it in a special way. Defaults to
+ *   true.
+ * - version: (optional) The version number of the document as part of the XML
+ *   declaration. Defaults to '1.0'.
+ * - encoding: (optional) The encoding of the document as part of the XML
+ *   declaration. Defaults to 'UTF-8'.
+ *
+ * @codingStandardsIgnoreStart
+ *
+ * Examples:
+ * @code
+ * process:
+ *   'body/value':
+ *     -
+ *       plugin: dom
+ *       method: import
+ *       source: 'body/0/value'
+ *     -
+ *       plugin: dom
+ *       method: export
+ * @endcode
+ * This example above will convert the input string to a DOMDocument object and
+ * back, with no explicit processing. It should have few noticeable effects.
+ *
+ * @code
+ * process:
+ *   'body/value':
+ *     -
+ *       plugin: dom
+ *       method: import
+ *       source: 'body/0/value'
+ *       non_root: true
+ *       log_messages: true
+ *       version: '1.0'
+ *       encoding: UTF-8
+ *     -
+ *       plugin: dom
+ *       method: export
+ *       non_root: true
+ * @endcode
+ * This example above will have the same effect as the previous example, since
+ * it specifies the default values for all the optional parameters.
+ *
+ * @codingStandardsIgnoreEnd
+ *
+ * @MigrateProcessPlugin(
+ *   id = "dom"
+ * )
+ */
+class Dom extends ProcessPluginBase {
+
+  /**
+   * If parsing warnings should be logged as migrate messages.
+   *
+   * @var bool
+   */
+  protected $logMessages = TRUE;
+
+  /**
+   * The HTML contains only the piece inside the body element.
+   *
+   * @var bool
+   */
+  protected $nonRoot = TRUE;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition) {
+    if (!isset($configuration['method'])) {
+      throw new \InvalidArgumentException('The "method" must be set.');
+    }
+    if (!in_array($configuration['method'], ['import', 'export'])) {
+      throw new \InvalidArgumentException('The "method" must be "import" or "export".');
+    }
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->configuration += $this->defaultValues();
+    $this->logMessages = (bool) $this->configuration['log_messages'];
+    $this->nonRoot = (bool) $this->configuration['non_root'];
+  }
+
+  /**
+   * Supply default values of all optional parameters.
+   *
+   * @return array
+   *   An array with keys the optional parameters and values the corresponding
+   *   defaults.
+   */
+  protected function defaultValues() {
+    return [
+      'non_root' => TRUE,
+      'log_messages' => TRUE,
+      'version' => '1.0',
+      'encoding' => 'UTF-8',
+    ];
+  }
+
+  /**
+   * Converts a HTML string into a DOMDocument.
+   *
+   * It is not using \Drupal\Component\Utility\Html::load() because it ignores
+   * all errors on import, and therefore incompatible with log_messages
+   * option.
+   *
+   * @param mixed $value
+   *   The string to be imported.
+   * @param \Drupal\migrate\MigrateExecutableInterface $migrate_executable
+   *   The migration in which this process is being executed.
+   * @param \Drupal\migrate\Row $row
+   *   The row from the source to process. Normally, just transforming the value
+   *   is adequate but very rarely you might need to change two columns at the
+   *   same time or something like that.
+   * @param string $destination_property
+   *   The destination property currently worked on. This is only used together
+   *   with the $row above.
+   *
+   * @return \DOMDocument
+   *   The document object based on the provided string.
+   *
+   * @throws \Drupal\migrate\MigrateException
+   *   When the received $value is not a string.
+   */
+  public function import($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
+    if (!is_string($value)) {
+      throw new MigrateException('Cannot import a non-string value.');
+    }
+
+    if ($this->logMessages) {
+      set_error_handler(static function ($errno, $errstr) use ($migrate_executable) {
+        $migrate_executable->saveMessage($errstr, MigrationInterface::MESSAGE_WARNING);
+      });
+    }
+
+    if ($this->nonRoot) {
+      $html = $this->getNonRootHtml($value);
+    }
+    else {
+      $html = $value;
+    }
+
+    $document = new \DOMDocument($this->configuration['version'], $this->configuration['encoding']);
+    $document->loadHTML($html);
+
+    if ($this->logMessages) {
+      restore_error_handler();
+    }
+
+    return $document;
+  }
+
+  /**
+   * Converts a DOMDocument into a HTML string.
+   *
+   * @param mixed $value
+   *   The document to be exported.
+   * @param \Drupal\migrate\MigrateExecutableInterface $migrate_executable
+   *   The migration in which this process is being executed.
+   * @param \Drupal\migrate\Row $row
+   *   The row from the source to process. Normally, just transforming the value
+   *   is adequate but very rarely you might need to change two columns at the
+   *   same time or something like that.
+   * @param string $destination_property
+   *   The destination property currently worked on. This is only used together
+   *   with the $row above.
+   *
+   * @return string
+   *   The HTML string corresponding to the provided document object.
+   *
+   * @throws \Drupal\migrate\MigrateException
+   *   When the received $value is not a \DOMDocument.
+   */
+  public function export($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
+    if (!$value instanceof \DOMDocument) {
+      $value_description = (gettype($value) == 'object') ? get_class($value) : gettype($value);
+      throw new MigrateException(sprintf('Cannot export a "%s".', $value_description));
+    }
+
+    if ($this->nonRoot) {
+      return Html::serialize($value);
+    }
+    return $value->saveHTML();
+  }
+
+  /**
+   * Builds an full html string based on a partial.
+   *
+   * @param string $partial
+   *   A subset of a full html string. For instance the contents of the body
+   *   element.
+   */
+  protected function getNonRootHtml($partial) {
+    $replacements = [
+      "\n" => '',
+      '!encoding' => strtolower($this->configuration['encoding']),
+      '!value' => $partial,
+    ];
+    // Prepend the html with a header using the configured source encoding.
+    // By default, loadHTML() assumes ISO-8859-1.
+    $html_template = <<<EOD
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head><meta http-equiv="Content-Type" content="text/html; charset=!encoding" /></head>
+<body>!value</body>
+</html>
+EOD;
+    return strtr($html_template, $replacements);
+  }
+
+}
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/process/DomApplyStyles.php b/web/modules/migrate_plus/src/Plugin/migrate/process/DomApplyStyles.php
new file mode 100644
index 0000000000000000000000000000000000000000..995085c4427cf0ffe5face7ddb5e255de7d9c2ab
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/DomApplyStyles.php
@@ -0,0 +1,201 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin\migrate\process;
+
+use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
+use Drupal\Core\Config\ConfigFactory;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\Plugin\MigrationInterface;
+use Drupal\migrate\Row;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Apply Editor styles to configured elements.
+ *
+ * Replace HTML elements with elements and classes specified in the Styles menu
+ * of the WYSIWYG editor.
+ *
+ * Available configuration keys:
+ * - format: the text format to inspect for style options (optional,
+ *   defaults to 'basic_html').
+ * - rules: an array of keyed arrays, with the following keys:
+ *   - xpath: an XPath expression for the elements to replace.
+ *   - style: the label of the item in the Styles menu to use.
+ *   - depth: the number of parent elements to remove (optional, defaults to 0).
+ *
+ * Example:
+ *
+ * @code
+ * process:
+ *   'body/value':
+ *     -
+ *       plugin: dom
+ *       method: import
+ *       source: 'body/0/value'
+ *     -
+ *       plugin: dom_apply_styles
+ *       format: full_html
+ *       rules:
+ *         -
+ *           xpath: '//b'
+ *           style: Bold
+ *         -
+ *           xpath: '//span/i'
+ *           style: Italic
+ *           depth: 1
+ *     -
+ *       plugin: dom
+ *       method: export
+ * @endcode
+ *
+ * This will replace <b>...</b> with whatever style is labeled "Bold" in the
+ * Full HTML text format, perhaps <strong class="foo">...</strong>.
+ * It will also replace <span><i>...</i></span> with the style labeled "Italic"
+ * in that text format, perhaps <em class="foo bar">...</em>.
+ * You may get unexpected results if there is anything between the two opening
+ * tags or between the two closing tags. That is, the code assumes that
+ * '<span><i>' is closed with '</i></span>' exactly.
+ *
+ * @MigrateProcessPlugin(
+ *   id = "dom_apply_styles"
+ * )
+ */
+class DomApplyStyles extends DomProcessBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * The config factory.
+   *
+   * @var \Drupal\Core\Config\ConfigFactory
+   */
+  protected $configFactory;
+
+  /**
+   * Array of styles from the WYSIWYG editor.
+   *
+   * @var array
+   */
+  protected $styles = [];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, ConfigFactory $config_factory) {
+    $configuration += ['format' => 'basic_html'];
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->configFactory = $config_factory;
+    $this->setStyles($configuration['format']);
+    $this->validateRules();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration = NULL) {
+    return new static(
+      $configuration,
+      $plugin_id,
+      $plugin_definition,
+      $container->get('config.factory')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
+    $this->init($value, $destination_property);
+
+    foreach ($this->configuration['rules'] as $rule) {
+      $this->apply($rule);
+    }
+
+    return $this->document;
+  }
+
+  /**
+   * Retrieve the list of styles based on configuration.
+   *
+   * The styles configuration is a string: styles are separated by "\r\n", and
+   * each one has the format 'element(\.class)*|label'.
+   * Convert this to an array with 'label' => 'element.class', and save as
+   * $this->styles.
+   *
+   * @param string $format
+   *   The text format from which to get configured styles.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   */
+  protected function setStyles($format) {
+    if (empty($format) || !is_string($format)) {
+      $message = 'The "format" option must be a non-empty string.';
+      throw new InvalidPluginDefinitionException($this->getPluginId(), $message);
+    }
+    $editor_styles = $this->configFactory
+      ->get("editor.editor.$format")
+      ->get('settings.plugins.stylescombo.styles');
+    foreach (explode("\r\n", $editor_styles) as $rule) {
+      if (preg_match('/(.*)\|(.*)/', $rule, $matches)) {
+        $this->styles[$matches[2]] = $matches[1];
+      }
+    }
+  }
+
+  /**
+   * Validate the configured rules.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   */
+  protected function validateRules() {
+    if (!array_key_exists('rules', $this->configuration) || !is_array($this->configuration['rules'])) {
+      $message = 'The "rules" option must be an array.';
+      throw new InvalidPluginDefinitionException($this->getPluginId(), $message);
+    }
+    foreach ($this->configuration['rules'] as $rule) {
+      if (empty($rule['xpath']) || empty($rule['style'])) {
+        $message = 'The "xpath" and "style" options are required for each rule.';
+        throw new InvalidPluginDefinitionException($this->getPluginId(), $message);
+      }
+      if (empty($this->styles[$rule['style']])) {
+        $message = sprintf('The style "%s" is not defined.', $rule['style']);
+        throw new InvalidPluginDefinitionException($this->getPluginId(), $message);
+      }
+    }
+  }
+
+  /**
+   * Apply a rule to the document.
+   *
+   * Search $this->document for elements matching 'xpath' and replace them with
+   * the HTML elements and classes in $this->styles specified by 'style'.
+   * If 'depth' is positive, then replace additional parent elements as well.
+   *
+   * @param string[] $rule
+   *   An array with keys 'xpath', 'style', and (optional) 'depth'.
+   */
+  protected function apply(array $rule) {
+    // An entry in $this->styles has the format element(\.class)*: for example,
+    // 'p' or 'a.button' or 'div.col-xs-6.col-md-4'.
+    // @see setStyles()
+    [$element, $classes] = explode('.', $this->styles[$rule['style']] . '.', 2);
+    $classes = trim(str_replace('.', ' ', $classes));
+
+    foreach ($this->xpath->query($rule['xpath']) as $node) {
+      $new_node = $this->document->createElement($element);
+      foreach ($node->childNodes as $child) {
+        $new_node->appendChild($child->cloneNode(TRUE));
+      }
+      if ($classes) {
+        $new_node->setAttribute('class', $classes);
+      }
+      $old_node = $node;
+      if (!empty($rule['depth'])) {
+        for ($i = 0; $i < $rule['depth']; $i++) {
+          $old_node = $old_node->parentNode;
+        }
+      }
+      $old_node->parentNode->replaceChild($new_node, $old_node);
+    }
+  }
+
+}
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/process/DomMigrationLookup.php b/web/modules/migrate_plus/src/Plugin/migrate/process/DomMigrationLookup.php
new file mode 100644
index 0000000000000000000000000000000000000000..fa68d175f6e43da7fc4f5f46bdec267106f82e16
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/DomMigrationLookup.php
@@ -0,0 +1,225 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin\migrate\process;
+
+use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\Plugin\MigratePluginManagerInterface;
+use Drupal\migrate\Plugin\MigrationInterface;
+use Drupal\migrate\Row;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * String replacements on a source dom based on migration lookup.
+ *
+ * Meant to be used after dom process plugin.
+ *
+ * Available configuration keys:
+ * - mode: What to modify. Possible values:
+ *   - attribute: One element attribute.
+ * - xpath: XPath query expression that will produce the \DOMNodeList to walk.
+ * - attribute_options: A map of options related to the attribute mode. Required
+ *   when mode is attribute. The keys can be:
+ *   - name: Name of the attribute to match and modify.
+ * - search: Regular expression to use. It should contain at least one
+ *   parenthesized subpattern which will be used as the ID passed to
+ *   migration_lookup process plugin.
+ * - replace: Default value to use for replacements on migrations, if not
+ *   specified on the migration. It should contain the '[mapped-id]' string
+ *   where the looked-up migration value will be placed.
+ * - migrations: A map of options indexed by migration machine name. Possible
+ *   option values are:
+ *   - replace: See replace option lines above.
+ * - no_stub: If TRUE, then do not create stub entities during migration lookup.
+ *   Optional, defaults to TRUE.
+ *
+ * Example:
+ *
+ * @code
+ * process:
+ *   'body/value':
+ *     -
+ *       plugin: dom
+ *       method: import
+ *       source: 'body/0/value'
+ *     -
+ *       plugin: dom_migration_lookup
+ *       mode: attribute
+ *       xpath: '//a'
+ *       attribute_options:
+ *         name: href
+ *       search: '@/user/(\d+)@'
+ *       replace: '/user/[mapped-id]'
+ *       migrations:
+ *         users:
+ *           replace: '/user/[mapped-id]'
+ *         people:
+ *           replace: '/people/[mapped-id]'
+ *     -
+ *       plugin: dom
+ *       method: export
+ * @endcode
+ *
+ * @MigrateProcessPlugin(
+ *   id = "dom_migration_lookup"
+ * )
+ */
+class DomMigrationLookup extends DomStrReplace implements ContainerFactoryPluginInterface {
+
+  /**
+   * The migration to be executed.
+   *
+   * @var \Drupal\migrate\Plugin\MigrationInterface
+   */
+  protected $migration;
+
+  /**
+   * The process plugin manager.
+   *
+   * @var \Drupal\migrate\Plugin\MigratePluginManagerInterface
+   */
+  protected $processPluginManager;
+
+  /**
+   * Parameters passed to transform method, except the first, value.
+   *
+   * This helps to pass values to another process plugin.
+   *
+   * @var array
+   */
+  protected $transformParameters;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration, MigratePluginManagerInterface $process_plugin_manager) {
+    $configuration += ['no_stub' => TRUE];
+    $default_replace_missing = empty($configuration['replace']);
+    if ($default_replace_missing) {
+      $configuration['replace'] = 'prevent-requirement-fail';
+    }
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    if ($default_replace_missing) {
+      unset($this->configuration['replace']);
+    }
+    $this->migration = $migration;
+    $this->processPluginManager = $process_plugin_manager;
+    if (empty($this->configuration['migrations'])) {
+      throw new InvalidPluginDefinitionException(
+        $this->getPluginId(),
+        "Configuration option 'migration' is required."
+      );
+    }
+    if (!is_array($this->configuration['migrations'])) {
+      throw new InvalidPluginDefinitionException(
+        $this->getPluginId(),
+        "Configuration option 'migration' should be a keyed array."
+      );
+    }
+    // Add missing values if possible.
+    $default_replace = isset($this->configuration['replace']) ? $this->configuration['replace'] : NULL;
+    foreach ($this->configuration['migrations'] as $migration_name => $configuration_item) {
+      if (!empty($configuration_item['replace'])) {
+        continue;
+      }
+      if (is_null($default_replace)) {
+        throw new InvalidPluginDefinitionException(
+          $this->getPluginId(),
+          "Please define either a global replace for all migrations, or a specific one for 'migrations.$migration_name'."
+        );
+      }
+      $this->configuration['migrations'][$migration_name]['replace'] = $default_replace;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration = NULL) {
+    return new static(
+      $configuration,
+      $plugin_id,
+      $plugin_definition,
+      $migration,
+      $container->get('plugin.manager.migrate.process')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
+    $this->init($value, $destination_property);
+    $this->transformParameters = [
+      'migrate_executable' => $migrate_executable,
+      'row' => $row,
+      'destination_property' => $destination_property,
+    ];
+
+    foreach ($this->xpath->query($this->configuration['xpath']) as $html_node) {
+      $subject = $this->getSubject($html_node);
+      if (empty($subject)) {
+        // Could not find subject, skip processing.
+        continue;
+      }
+      $search = $this->getSearch();
+      if (!preg_match($search, $subject, $matches)) {
+        // No match found, skip processing.
+        continue;
+      }
+      $id = $matches[1];
+      // Walk through defined migrations looking for a map.
+      foreach ($this->configuration['migrations'] as $migration_name => $configuration) {
+        $mapped_id = $this->migrationLookup($id, $migration_name);
+        if (!is_null($mapped_id)) {
+          // Not using getReplace(), since this implementation depends on the
+          // migration.
+          $replace = str_replace('[mapped-id]', $mapped_id, $configuration['replace']);
+          $this->doReplace($html_node, $search, $replace, $subject);
+          break;
+        }
+      }
+    }
+
+    return $this->document;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function doReplace(\DOMElement $html_node, $search, $replace, $subject) {
+    $new_subject = preg_replace($search, $replace, $subject);
+    $this->postReplace($html_node, $new_subject);
+  }
+
+  /**
+   * Lookup the migration mapped ID on one migration.
+   *
+   * @param mixed $id
+   *   The ID to search with migration_lookup process plugin.
+   * @param string $migration_name
+   *   The migration to look into machine name.
+   *
+   * @return string|null
+   *   The found mapped ID, or NULL if not found on the provided migration.
+   */
+  protected function migrationLookup($id, $migration_name) {
+    $mapped_id = NULL;
+    $parameters = [
+      $id,
+      $this->transformParameters['migrate_executable'],
+      $this->transformParameters['row'],
+      $this->transformParameters['destination_property'],
+    ];
+    $plugin_configuration = [
+      'migration' => $migration_name,
+      'no_stub' => $this->configuration['no_stub'],
+    ];
+    $migration_lookup_plugin = $this->processPluginManager
+      ->createInstance('migration_lookup', $plugin_configuration, $this->migration);
+    $mapped_id = call_user_func_array([$migration_lookup_plugin, 'transform'], $parameters);
+    return $mapped_id;
+  }
+
+}
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/process/DomProcessBase.php b/web/modules/migrate_plus/src/Plugin/migrate/process/DomProcessBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..05981ba54a44e12a43a9ea2474bfa6332ddfa6df
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/DomProcessBase.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin\migrate\process;
+
+use Drupal\migrate\MigrateSkipRowException;
+use Drupal\migrate\ProcessPluginBase;
+
+/**
+ * Base class for process plugins that work with \DOMDocument objects.
+ *
+ * Use Dom::import() to convert a string to a \DOMDocument object, then plugins
+ * derived from this class to manipulate the object, then Dom::export() to
+ * convert back to a string.
+ */
+abstract class DomProcessBase extends ProcessPluginBase {
+
+  /**
+   * Document to use.
+   *
+   * @var \DOMDocument
+   */
+  protected $document;
+
+  /**
+   * Xpath query object.
+   *
+   * @var \DOMXPath
+   */
+  protected $xpath;
+
+  /**
+   * Initialize the class properties.
+   *
+   * @param mixed $value
+   *   Process plugin value.
+   * @param string $destination_property
+   *   The name of the destination being processed. Used to generate an error
+   *   message.
+   *
+   * @throws \Drupal\migrate\MigrateSkipRowException
+   *   If $value is not a \DOMDocument object.
+   */
+  protected function init($value, $destination_property) {
+    if (!($value instanceof \DOMDocument)) {
+      $message = sprintf(
+        'The %s plugin in the %s process pipeline requires a \DOMDocument object. You can use the dom plugin to convert a string to \DOMDocument.',
+        $this->getPluginId(),
+        $destination_property
+      );
+      throw new MigrateSkipRowException($message);
+    }
+    $this->document = $value;
+    $this->xpath = new \DOMXPath($this->document);
+  }
+
+}
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/process/DomStrReplace.php b/web/modules/migrate_plus/src/Plugin/migrate/process/DomStrReplace.php
new file mode 100644
index 0000000000000000000000000000000000000000..fd2a674a213d284b352c8ae4ca230ae0c1584ddd
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/DomStrReplace.php
@@ -0,0 +1,206 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin\migrate\process;
+
+use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\Row;
+
+/**
+ * String replacements on a source dom.
+ *
+ * Analogous to str_replace process plugin, but based on a \DOMDocument instead
+ * of a string.
+ * Meant to be used after dom process plugin.
+ *
+ * Available configuration keys:
+ * - mode: What to modify. Possible values:
+ *   - attribute: One element attribute.
+ * - xpath: XPath query expression that will produce the \DOMNodeList to walk.
+ * - attribute_options: A map of options related to the attribute mode. Required
+ *   when mode is attribute. The keys can be:
+ *   - name: Name of the attribute to match and modify.
+ * - search: pattern to match.
+ * - replace: value to replace the searched pattern with.
+ * - regex: Use regular expression replacement.
+ * - case_insensitive: Case insensitive search. Only valid when regex is false.
+ *
+ * Examples:
+ *
+ * @code
+ * process:
+ *   'body/value':
+ *     -
+ *       plugin: dom
+ *       method: import
+ *       source: 'body/0/value'
+ *     -
+ *       plugin: dom_str_replace
+ *       mode: attribute
+ *       xpath: '//a'
+ *       attribute_options:
+ *         name: href
+ *       search: 'foo'
+ *       replace: 'bar'
+ *     -
+ *       plugin: dom_str_replace
+ *       mode: attribute
+ *       xpath: '//a'
+ *       attribute_options:
+ *         name: href
+ *       regex: true
+ *       search: '/foo/'
+ *       replace: 'bar'
+ *     -
+ *       plugin: dom
+ *       method: export
+ * @endcode
+ *
+ * @MigrateProcessPlugin(
+ *   id = "dom_str_replace"
+ * )
+ */
+class DomStrReplace extends DomProcessBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->configuration += [
+      'case_insensitive' => FALSE,
+      'regex' => FALSE,
+    ];
+    $options_validation = [
+      'xpath' => NULL,
+      'mode' => ['attribute'],
+      // @todo Move out once another mode is supported.
+      // @see https://www.drupal.org/project/migrate_plus/issues/3042833
+      'attribute_options' => NULL,
+      'search' => NULL,
+      'replace' => NULL,
+    ];
+    foreach ($options_validation as $option_name => $possible_values) {
+      if (empty($this->configuration[$option_name])) {
+        throw new InvalidPluginDefinitionException(
+          $this->getPluginId(),
+          "Configuration option '$option_name' is required."
+        );
+      }
+      if (!is_null($possible_values) && !in_array($this->configuration[$option_name], $possible_values)) {
+        throw new InvalidPluginDefinitionException(
+          $this->getPluginId(),
+          sprintf(
+            'Configuration option "%s" only accepts the following values: %s.',
+            $option_name,
+            implode(', ', $possible_values)
+          )
+        );
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
+    $this->init($value, $destination_property);
+
+    foreach ($this->xpath->query($this->configuration['xpath']) as $html_node) {
+      $subject = $this->getSubject($html_node);
+      if (empty($subject)) {
+        // Could not find subject, skip processing.
+        continue;
+      }
+      $search = $this->getSearch();
+      $replace = $this->getReplace();
+      $this->doReplace($html_node, $search, $replace, $subject);
+    }
+
+    return $this->document;
+  }
+
+  /**
+   * Retrieves the right subject string.
+   *
+   * @param \DOMElement $node
+   *   The current element from iteration.
+   *
+   * @return string
+   *   The string to use a subject on search.
+   */
+  protected function getSubject(\DOMElement $node) {
+    switch ($this->configuration['mode']) {
+      case 'attribute':
+        return $node->getAttribute($this->configuration['attribute_options']['name']);
+    }
+  }
+
+  /**
+   * Retrieves the right search string based on configuration.
+   *
+   * @return string
+   *   The value to be searched.
+   */
+  protected function getSearch() {
+    switch ($this->configuration['mode']) {
+      case 'attribute':
+        return $this->configuration['search'];
+    }
+  }
+
+  /**
+   * Retrieves the right replace string based on configuration.
+   *
+   * @return string
+   *   The value to use for replacement.
+   */
+  protected function getReplace() {
+    switch ($this->configuration['mode']) {
+      case 'attribute':
+        return $this->configuration['replace'];
+    }
+  }
+
+  /**
+   * Retrieves the right replace string based on configuration.
+   *
+   * @param \DOMElement $html_node
+   *   The current element from iteration.
+   * @param string $search
+   *   The search string or pattern.
+   * @param string $replace
+   *   The replacement string.
+   * @param string $subject
+   *   The string on which to perform the substitution.
+   */
+  protected function doReplace(\DOMElement $html_node, $search, $replace, $subject) {
+    if ($this->configuration['regex']) {
+      $function = 'preg_replace';
+    }
+    elseif ($this->configuration['case_insensitive']) {
+      $function = 'str_ireplace';
+    }
+    else {
+      $function = 'str_replace';
+    }
+    $new_subject = $function($search, $replace, $subject);
+    $this->postReplace($html_node, $new_subject);
+  }
+
+  /**
+   * Performs post-replace actions.
+   *
+   * @param \DOMElement $html_node
+   *   The current element from iteration.
+   * @param string $new_subject
+   *   The new value to use.
+   */
+  protected function postReplace(\DOMElement $html_node, $new_subject) {
+    switch ($this->configuration['mode']) {
+      case 'attribute':
+        $html_node->setAttribute($this->configuration['attribute_options']['name'], $new_subject);
+    }
+  }
+
+}
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/process/EntityGenerate.php b/web/modules/migrate_plus/src/Plugin/migrate/process/EntityGenerate.php
index a749a6388cf42c80dd5939f8dd6c6e8fe2f6d749..7311687d6dba25a8a795ad6d6c9eba731dc1aae2 100644
--- a/web/modules/migrate_plus/src/Plugin/migrate/process/EntityGenerate.php
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/EntityGenerate.php
@@ -2,10 +2,8 @@
 
 namespace Drupal\migrate_plus\Plugin\migrate\process;
 
-use Drupal\Core\Entity\EntityManagerInterface;
-use Drupal\Core\Entity\EntityReferenceSelection\SelectionPluginManagerInterface;
+use Drupal\Component\Utility\NestedArray;
 use Drupal\migrate\MigrateExecutableInterface;
-use Drupal\migrate\Plugin\MigratePluginManager;
 use Drupal\migrate\Plugin\MigrationInterface;
 use Drupal\migrate\Row;
 use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -53,57 +51,33 @@ class EntityGenerate extends EntityLookup {
   protected $row;
 
   /**
-   * The MigrateExecutable instance.
+   * The migrate executable.
    *
-   * @var \Drupal\migrate\MigrateExecutable
+   * @var \Drupal\migrate\MigrateExecutableInterface
    */
   protected $migrateExecutable;
 
   /**
-   * The get process plugin instance.
+   * The MigratePluginManager instance.
    *
-   * @var \Drupal\migrate\Plugin\migrate\process\Get
+   * @var \Drupal\migrate\Plugin\MigratePluginManagerInterface
    */
-  protected $getProcessPlugin;
+  protected $processPluginManager;
 
   /**
-   * EntityGenerate constructor.
+   * The get process plugin instance.
    *
-   * @param array $configuration
-   *   A configuration array containing information about the plugin instance.
-   * @param string $pluginId
-   *   The plugin_id for the plugin instance.
-   * @param mixed $pluginDefinition
-   *   The plugin implementation definition.
-   * @param \Drupal\migrate\Plugin\MigrationInterface $migration
-   *   The migration.
-   * @param \Drupal\Core\Entity\EntityManagerInterface $entityManager
-   *   The $entityManager instance.
-   * @param \Drupal\Core\Entity\EntityReferenceSelection\SelectionPluginManagerInterface $selectionPluginManager
-   *   The $selectionPluginManager instance.
-   * @param \Drupal\migrate\Plugin\MigratePluginManager $migratePluginManager
-   *   The MigratePluginManager instance.
+   * @var \Drupal\migrate\Plugin\migrate\process\Get
    */
-  public function __construct(array $configuration, $pluginId, $pluginDefinition, MigrationInterface $migration, EntityManagerInterface $entityManager, SelectionPluginManagerInterface $selectionPluginManager, MigratePluginManager $migratePluginManager) {
-    parent::__construct($configuration, $pluginId, $pluginDefinition, $migration, $entityManager, $selectionPluginManager);
-    if (isset($configuration['values'])) {
-      $this->getProcessPlugin = $migratePluginManager->createInstance('get', ['source' => $configuration['values']]);
-    }
-  }
+  protected $getProcessPlugin;
 
   /**
    * {@inheritdoc}
    */
   public static function create(ContainerInterface $container, array $configuration, $pluginId, $pluginDefinition, MigrationInterface $migration = NULL) {
-    return new static(
-      $configuration,
-      $pluginId,
-      $pluginDefinition,
-      $migration,
-      $container->get('entity.manager'),
-      $container->get('plugin.manager.entity_reference_selection'),
-      $container->get('plugin.manager.migrate.process')
-    );
+    $instance = parent::create($container, $configuration, $pluginId, $pluginDefinition, $migration);
+    $instance->processPluginManager = $container->get('plugin.manager.migrate.process');
+    return $instance;
   }
 
   /**
@@ -131,7 +105,7 @@ public function transform($value, MigrateExecutableInterface $migrateExecutable,
    */
   protected function generateEntity($value) {
     if (!empty($value)) {
-      $entity = $this->entityManager
+      $entity = $this->entityTypeManager
         ->getStorage($this->lookupEntityType)
         ->create($this->entity($value));
       $entity->save();
@@ -168,8 +142,8 @@ protected function entity($value) {
     // Gather any additional properties/fields.
     if (isset($this->configuration['values']) && is_array($this->configuration['values'])) {
       foreach ($this->configuration['values'] as $key => $property) {
-        $source_value = $this->getProcessPlugin->transform(NULL, $this->migrateExecutable, $this->row, $property);
-        $entity_values[$key] = $source_value;
+        $source_value = $this->row->get($property);
+        NestedArray::setValue($entity_values, explode(Row::PROPERTY_SEPARATOR, $key), $source_value, TRUE);
       }
     }
 
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/process/EntityLookup.php b/web/modules/migrate_plus/src/Plugin/migrate/process/EntityLookup.php
index 2ae0d07c1de2a438c830da55412a9c6618b2e7da..7f9595f1aa2bf9bcda4e072b4c26239174d3de3f 100644
--- a/web/modules/migrate_plus/src/Plugin/migrate/process/EntityLookup.php
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/EntityLookup.php
@@ -3,12 +3,10 @@
 namespace Drupal\migrate_plus\Plugin\migrate\process;
 
 use Drupal\Core\Config\Entity\ConfigEntityInterface;
-use Drupal\Core\Entity\EntityManagerInterface;
-use Drupal\Core\Entity\EntityReferenceSelection\SelectionPluginManagerInterface;
 use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
-use Drupal\migrate\Plugin\MigrationInterface;
 use Drupal\migrate\MigrateException;
 use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\Plugin\MigrationInterface;
 use Drupal\migrate\ProcessPluginBase;
 use Drupal\migrate\Row;
 use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -16,15 +14,16 @@
 /**
  * This plugin looks for existing entities.
  *
- * @MigrateProcessPlugin(
- *   id = "entity_lookup",
- *   handle_multiples = TRUE
- * )
- *
  * In its most simple form, this plugin needs no configuration. However, if the
  * lookup properties cannot be determined through introspection, define them via
  * configuration.
  *
+ * Available configuration keys:
+ * - access_check: (optional) Indicates if access to the entity for this user
+ *   will be checked. Default is true.
+ *
+ * @codingStandardsIgnoreStart
+ *
  * Example usage with minimal configuration:
  * @code
  * destination:
@@ -35,8 +34,10 @@
  *     default_value: page
  *   field_tags:
  *     plugin: entity_lookup
+ *     access_check: false
  *     source: tags
  * @endcode
+ * In this example above, the access check is disabled.
  *
  * Example usage with full configuration:
  * @code
@@ -49,15 +50,29 @@
  *     entity_type: taxonomy_term
  *     ignore_case: true
  * @endcode
+ *
+ * @codingStandardsIgnoreEnd
+ *
+ * @MigrateProcessPlugin(
+ *   id = "entity_lookup",
+ *   handle_multiples = TRUE
+ * )
  */
 class EntityLookup extends ProcessPluginBase implements ContainerFactoryPluginInterface {
 
   /**
-   * The entity manager.
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The field manager.
    *
-   * @var \Drupal\Core\Entity\EntityManagerInterface
+   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
    */
-  protected $entityManager;
+  protected $entityFieldManager;
 
   /**
    * The migration.
@@ -123,30 +138,29 @@ class EntityLookup extends ProcessPluginBase implements ContainerFactoryPluginIn
   protected $destinationProperty;
 
   /**
-   * {@inheritdoc}
+   * The access check flag.
+   *
+   * @var string
    */
-  public function __construct(array $configuration, $pluginId, $pluginDefinition, MigrationInterface $migration, EntityManagerInterface $entityManager, SelectionPluginManagerInterface $selectionPluginManager) {
-    parent::__construct($configuration, $pluginId, $pluginDefinition);
-    $this->migration = $migration;
-    $this->entityManager = $entityManager;
-    $this->selectionPluginManager = $selectionPluginManager;
-    $pluginIdParts = explode(':', $this->migration->getDestinationPlugin()->getPluginId());
-    $this->destinationEntityType = empty($pluginIdParts[1]) ?: $pluginIdParts[1];
-    $this->destinationBundleKey = !$this->destinationEntityType ?: $this->entityManager->getDefinition($this->destinationEntityType)->getKey('bundle');
-  }
+  protected $accessCheck = TRUE;
 
   /**
    * {@inheritdoc}
    */
   public static function create(ContainerInterface $container, array $configuration, $pluginId, $pluginDefinition, MigrationInterface $migration = NULL) {
-    return new static(
+    $instance = new static(
       $configuration,
       $pluginId,
-      $pluginDefinition,
-      $migration,
-      $container->get('entity.manager'),
-      $container->get('plugin.manager.entity_reference_selection')
+      $pluginDefinition
     );
+    $instance->migration = $migration;
+    $instance->entityTypeManager = $container->get('entity_type.manager');
+    $instance->entityFieldManager = $container->get('entity_field.manager');
+    $instance->selectionPluginManager = $container->get('plugin.manager.entity_reference_selection');
+    $pluginIdParts = explode(':', $instance->migration->getDestinationPlugin()->getPluginId());
+    $instance->destinationEntityType = empty($pluginIdParts[1]) ? NULL : $pluginIdParts[1];
+    $instance->destinationBundleKey = $instance->destinationEntityType ? $instance->entityTypeManager->getDefinition($instance->destinationEntityType)->getKey('bundle') : NULL;
+    return $instance;
   }
 
   /**
@@ -177,6 +191,9 @@ public function transform($value, MigrateExecutableInterface $migrateExecutable,
    *   with the $row above.
    */
   protected function determineLookupProperties($destinationProperty) {
+    if (isset($this->configuration['access_check'])) {
+      $this->accessCheck = $this->configuration['access_check'];
+    }
     if (!empty($this->configuration['value_key'])) {
       $this->lookupValueKey = $this->configuration['value_key'];
     }
@@ -194,7 +211,7 @@ protected function determineLookupProperties($destinationProperty) {
       // See if we can introspect the lookup properties from destination field.
       if (!empty($this->migration->getProcess()[$this->destinationBundleKey][0]['default_value'])) {
         $destinationEntityBundle = $this->migration->getProcess()[$this->destinationBundleKey][0]['default_value'];
-        $fieldConfig = $this->entityManager->getFieldDefinitions($this->destinationEntityType, $destinationEntityBundle)[$destinationProperty]->getConfig($destinationEntityBundle);
+        $fieldConfig = $this->entityFieldManager->getFieldDefinitions($this->destinationEntityType, $destinationEntityBundle)[$destinationProperty]->getConfig($destinationEntityBundle);
         switch ($fieldConfig->getType()) {
           case 'entity_reference':
             if (empty($this->lookupBundle)) {
@@ -211,9 +228,11 @@ protected function determineLookupProperties($destinationProperty) {
 
             // Make an assumption that if the selection handler can target more
             // than one type of entity that we will use the first entity type.
-            $this->lookupEntityType = $this->lookupEntityType ?: reset($this->selectionPluginManager->createInstance($fieldConfig->getSetting('handler'))->getPluginDefinition()['entity_types']);
-            $this->lookupValueKey = $this->lookupValueKey ?: $this->entityManager->getDefinition($this->lookupEntityType)->getKey('label');
-            $this->lookupBundleKey = $this->lookupBundleKey ?: $this->entityManager->getDefinition($this->lookupEntityType)->getKey('bundle');
+            $fieldHandler = $fieldConfig->getSetting('handler');
+            $selection = $this->selectionPluginManager->createInstance($fieldHandler);
+            $this->lookupEntityType = $this->lookupEntityType ?: reset($selection->getPluginDefinition()['entity_types']);
+            $this->lookupValueKey = $this->lookupValueKey ?: $this->entityTypeManager->getDefinition($this->lookupEntityType)->getKey('label');
+            $this->lookupBundleKey = $this->lookupBundleKey ?: $this->entityTypeManager->getDefinition($this->lookupEntityType)->getKey('bundle');
             break;
 
           case 'file':
@@ -223,8 +242,7 @@ protected function determineLookupProperties($destinationProperty) {
             break;
 
           default:
-            throw new MigrateException('Destination field type ' .
-              $fieldConfig->getType() . 'is not a recognized reference type.');
+            throw new MigrateException(sprintf('Destination field type %s is not a recognized reference type.', $fieldConfig->getType()));
         }
       }
     }
@@ -259,9 +277,15 @@ protected function query($value) {
 
     $multiple = is_array($value);
 
-    $query = $this->entityManager->getStorage($this->lookupEntityType)
+    $query = $this->entityTypeManager->getStorage($this->lookupEntityType)
       ->getQuery()
+      ->accessCheck($this->accessCheck)
       ->condition($this->lookupValueKey, $value, $multiple ? 'IN' : NULL);
+    // Sqlite and possibly others returns data in a non-deterministic order.
+    // Make it deterministic.
+    if ($multiple) {
+      $query->sort($this->lookupValueKey, 'DESC');
+    }
 
     if ($this->lookupBundleKey) {
       $query->condition($this->lookupBundleKey, $this->lookupBundle);
@@ -276,7 +300,7 @@ protected function query($value) {
     if (!$ignoreCase) {
       // Returns the entity's identifier.
       foreach ($results as $k => $identifier) {
-        $entity = $this->entityManager->getStorage($this->lookupEntityType)->load($identifier);
+        $entity = $this->entityTypeManager->getStorage($this->lookupEntityType)->load($identifier);
         $result_value = $entity instanceof ConfigEntityInterface ? $entity->get($this->lookupValueKey) : $entity->get($this->lookupValueKey)->value;
         if (($multiple && !in_array($result_value, $value, TRUE)) || (!$multiple && $result_value !== $value)) {
           unset($results[$k]);
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/process/FileBlob.php b/web/modules/migrate_plus/src/Plugin/migrate/process/FileBlob.php
index 22341b6bc6ab6643fc11c0b1b3ef387079ff08d2..ed581682008f9104dad4cfce0b5903663f83dc4c 100644
--- a/web/modules/migrate_plus/src/Plugin/migrate/process/FileBlob.php
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/FileBlob.php
@@ -13,6 +13,61 @@
 /**
  * Copy a file from a blob into a file.
  *
+ * The source value is an indexed array of two values:
+ * - The destination URI, e.g. 'public://example.txt'.
+ * - The binary blob data.
+ *
+ * Available configuration keys:
+ * - reuse: true
+ *
+ * @codingStandardsIgnoreStart
+ *
+ * Examples:
+ * @code
+ * uri:
+ *   plugin: file_blob
+ *   source:
+ *     - 'public://example.txt'
+ *     - blob
+ * @endcode
+ * Above, a basic configuration.
+ *
+ * @code
+ * source:
+ *   constants:
+ *     destination: public://images
+ * process:
+ *   destination_blob:
+ *     plugin: callback
+ *     callable: base64_decode
+ *     source:
+ *       - blob
+ *   destination_basename:
+ *     plugin: callback
+ *     callable: basename
+ *     source: file_name
+ *   destination_path:
+ *     plugin: concat
+ *     source:
+ *       - constants/destination
+ *       - @destination_basename
+ *   uri:
+ *     plugin: file_blob
+ *     source:
+ *       - @destination_path
+ *       - @destination_blob
+ * @endcode
+   In the example above, it is necessary to manipulate  the values before they
+ * are processed by this plugin. This is because this plugin takes a binary blob
+ * and saves it as a file. In many cases, as in this example, the data is base64
+ * encoded and should be decoded first. In destination_blob, the incoming data
+ * is decoded from base64 to binary. The destination_path element is
+ * concatenating the base filename with the destination directory set in the
+ * constants to create the final path. The resulting values are then referenced
+ * as the source of the file_blob plugin.
+ *
+ * @codingStandardsIgnoreEnd
+ *
  * @MigrateProcessPlugin(
  *   id = "file_blob"
  * )
@@ -67,7 +122,7 @@ public function transform($value, MigrateExecutableInterface $migrate_executable
     if ($row->isStub()) {
       return NULL;
     }
-    list($destination, $blob) = $value;
+    [$destination, $blob] = $value;
 
     // Determine if we going to overwrite existing files or not touch them.
     $replace = $this->getOverwriteMode();
@@ -78,9 +133,11 @@ public function transform($value, MigrateExecutableInterface $migrate_executable
       return $destination;
     }
     $dir = $this->getDirectory($destination);
-    if (!file_prepare_directory($dir, FILE_CREATE_DIRECTORY)) {
+    $success = $this->fileSystem->prepareDirectory($dir, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
+    if (!$success) {
       throw new MigrateSkipProcessException("Could not create directory '$dir'");
     }
+
     if ($this->putFile($destination, $blob, $replace)) {
       return $destination;
     }
@@ -95,14 +152,15 @@ public function transform($value, MigrateExecutableInterface $migrate_executable
    * @param string $blob
    *   The base64 encoded file contents.
    * @param int $replace
-   *   (optional) FILE_EXISTS_REPLACE (default) or FILE_EXISTS_ERROR, depending
-   *   on the configuration.
+   *   (optional) either FileSystemInterface::EXISTS_REPLACE; (default) or
+   *   FileSystemInterface::EXISTS_ERROR, depending on the configuration.
    *
    * @return bool|string
    *   File path on success, FALSE on failure.
    */
-  protected function putFile($destination, $blob, $replace = FILE_EXISTS_REPLACE) {
-    if ($path = file_destination($destination, $replace)) {
+  protected function putFile($destination, $blob, $replace = FileSystemInterface::EXISTS_REPLACE) {
+    $path = $this->fileSystem->getDestinationFilename($destination, $replace);
+    if ($path) {
       if (file_put_contents($path, $blob)) {
         return $path;
       }
@@ -119,15 +177,14 @@ protected function putFile($destination, $blob, $replace = FILE_EXISTS_REPLACE)
    * Determines how to handle file conflicts.
    *
    * @return int
-   *   Either FILE_EXISTS_REPLACE (default) or FILE_EXISTS_ERROR, depending on
-   *   the configuration.
+   *   Either FileSystemInterface::EXISTS_REPLACE; (default) or
+   *   FileSystemInterface::EXISTS_ERROR, depending on the configuration.
    */
   protected function getOverwriteMode() {
-    if (!empty($this->configuration['reuse'])) {
-      return FILE_EXISTS_ERROR;
+    if (isset($this->configuration['reuse']) && !empty($this->configuration['reuse'])) {
+      return FileSystemInterface::EXISTS_ERROR;
     }
-
-    return FILE_EXISTS_REPLACE;
+    return FileSystemInterface::EXISTS_REPLACE;
   }
 
   /**
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/process/Merge.php b/web/modules/migrate_plus/src/Plugin/migrate/process/Merge.php
index 463595e9d7b31c13bd6e7e4dc3a799d28ac1dff4..9861bd65d4e961d4d195be2c22fdc73bd51ed510 100644
--- a/web/modules/migrate_plus/src/Plugin/migrate/process/Merge.php
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/Merge.php
@@ -2,9 +2,9 @@
 
 namespace Drupal\migrate_plus\Plugin\migrate\process;
 
-use Drupal\migrate\ProcessPluginBase;
 use Drupal\migrate\MigrateException;
 use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\ProcessPluginBase;
 use Drupal\migrate\Row;
 
 /**
@@ -33,7 +33,7 @@
  *    temp_images:
  *      plugin: iterator
  *      source: field_image
- *      process
+ *      process:
  *        target_id:
  *          plugin: migration_lookup
  *          migration: image_entities_to_paragraph
@@ -56,13 +56,14 @@ public function transform($value, MigrateExecutableInterface $migrate_executable
       throw new MigrateException(sprintf('Merge process failed for destination property (%s): input is not an array.', $destination_property));
     }
     $new_value = [];
-    foreach($value as $i => $item) {
+    foreach ($value as $i => $item) {
       if (!is_array($item)) {
         throw new MigrateException(sprintf('Merge process failed for destination property (%s): index (%s) in the source value is not an array that can be merged.', $destination_property, $i));
       }
-      $new_value = array_merge($new_value, $item);
+      $new_value[] = $item;
     }
-    return $new_value;
+
+    return array_merge(...$new_value);
   }
 
 }
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/process/SkipOnValue.php b/web/modules/migrate_plus/src/Plugin/migrate/process/SkipOnValue.php
index 1a0c8f545c5996f3c5708da619f918dd4ef60168..bf1632fefc4e23b8a17247ef2d251dff3e361426 100644
--- a/web/modules/migrate_plus/src/Plugin/migrate/process/SkipOnValue.php
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/SkipOnValue.php
@@ -20,10 +20,12 @@
  * - value: An single value or array of values against which the source value
  *   should be compared.
  * - not_equals: (optional) If set, skipping occurs when values are not equal.
- * - method: What to do if the input value is empty. Possible values:
- *   - row: Skips the entire row when an empty value is encountered.
- *   - process: Prevents further processing of the input property when the value
- *     is empty.
+ * - method: What to do if the input value equals to value given in
+ *   configuration key value. Possible values:
+ *   - row: Skips the entire row.
+ *   - process: Prevents further processing of the input property
+ *
+ * @codingStandardsIgnoreStart
  *
  * Examples:
  *
@@ -32,12 +34,11 @@
  *   type:
  *     plugin: skip_on_value
  *     source: content_type
- *     method: row
+ *     method: process
  *     value: blog
  * @endcode
- *
- * The above example will skip processing the input property if the content_type
- * source field equals "blog".
+ * The above example will skip further processing of the input property if
+ * the content_type source field equals "blog".
  *
  * Example usage with full configuration:
  * @code
@@ -50,9 +51,10 @@
  *       - article
  *       - testimonial
  * @endcode
- *
  * The above example will skip processing any row for which the source row's
  * content type field is not "article" or "testimonial".
+ *
+ * @codingStandardsIgnoreEnd
  */
 class SkipOnValue extends ProcessPluginBase {
 
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/process/StrReplace.php b/web/modules/migrate_plus/src/Plugin/migrate/process/StrReplace.php
index 2b033bebc3a9b5274415db8e69f26f84c83c7560..d131394148b94eb1549f9bf1dba33c4f6e7a7be9 100644
--- a/web/modules/migrate_plus/src/Plugin/migrate/process/StrReplace.php
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/StrReplace.php
@@ -14,8 +14,9 @@
  *   id = "str_replace"
  * )
  *
- * To do a simple hardcoded string replace use the following:
+ * @codingStandardsIgnoreStart
  *
+ * To do a simple hardcoded string replace, use the following:
  * @code
  * field_text:
  *   plugin: str_replace
@@ -23,7 +24,6 @@
  *   search: foo
  *   replace: bar
  * @endcode
- *
  * If the value of text is "vero eos et accusam et justo vero" in source, foo is
  * "et" in search and bar is "that" in replace, field_text will be "vero eos
  * that accusam that justo vero".
@@ -37,7 +37,6 @@
  *   search: foo
  *   replace: bar
  * @endcode
- *
  * If the value of text is "VERO eos et accusam et justo vero" in source, foo is
  * "vero" in search and bar is "that" in replace, field_text will be "that eos
  * et accusam et justo that".
@@ -51,7 +50,6 @@
  *   search: foo
  *   replace: bar
  * @endcode
- *
  * If the value of text is "vero eos et 123 accusam et justo 123 duo" in source,
  * foo is "/[0-9]{3}/" in search and bar is "the" in replace, field_text will be
  * "vero eos et the accusam et justo the duo".
@@ -59,6 +57,17 @@
  * All the rules for
  * @link http://php.net/manual/function.str-replace.php str_replace @endlink
  * apply. This means that you can provide arrays as values.
+ *
+ * Multiple values can be matched like this:
+ * @code
+ * field_text:
+ *   plugin: str_replace
+ *   source: text
+ *   search: ["AT", "CH", "DK"]
+ *   replace: ["Austria", "Switzerland", "Denmark"]
+ * @endcode
+ *
+ * @codingStandardsIgnoreEnd
  */
 class StrReplace extends ProcessPluginBase {
 
@@ -84,7 +93,7 @@ public function transform($value, MigrateExecutableInterface $migrate_executable
       'case_insensitive' => FALSE,
       'regex' => FALSE,
     ];
-    $function = "str_replace";
+    $function = 'str_replace';
     if ($this->configuration['case_insensitive']) {
       $function = 'str_ireplace';
     }
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/process/Transliteration.php b/web/modules/migrate_plus/src/Plugin/migrate/process/Transliteration.php
index 217869e205c6874ba7ed47930a0fac7310cc14a7..f6cd6f7d1353d52d4c0fe8ffadb32a83058008c5 100644
--- a/web/modules/migrate_plus/src/Plugin/migrate/process/Transliteration.php
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/Transliteration.php
@@ -5,8 +5,8 @@
 use Drupal\Component\Transliteration\TransliterationInterface;
 use Drupal\Core\Language\LanguageInterface;
 use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
-use Drupal\migrate\ProcessPluginBase;
 use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\ProcessPluginBase;
 use Drupal\migrate\Row;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/source/SourcePluginExtension.php b/web/modules/migrate_plus/src/Plugin/migrate/source/SourcePluginExtension.php
index 19ca6a9a2a7b2d70b12db4510ce7813fdc8d944a..5d5f5cb512c9f4bed0b8dd1f093291300faab222 100644
--- a/web/modules/migrate_plus/src/Plugin/migrate/source/SourcePluginExtension.php
+++ b/web/modules/migrate_plus/src/Plugin/migrate/source/SourcePluginExtension.php
@@ -2,8 +2,8 @@
 
 namespace Drupal\migrate_plus\Plugin\migrate\source;
 
-use Drupal\migrate\Plugin\MigrationInterface;
 use Drupal\migrate\Plugin\migrate\source\SourcePluginBase;
+use Drupal\migrate\Plugin\MigrationInterface;
 
 /**
  * Generally-useful extensions to the core SourcePluginBase.
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/source/Table.php b/web/modules/migrate_plus/src/Plugin/migrate/source/Table.php
new file mode 100644
index 0000000000000000000000000000000000000000..21c43eb7378d8dba3d3a1dc5a4624435b49f13ac
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/migrate/source/Table.php
@@ -0,0 +1,80 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin\migrate\source;
+
+use Drupal\Core\State\StateInterface;
+use Drupal\migrate\MigrateException;
+use Drupal\migrate\Plugin\migrate\source\SqlBase;
+use Drupal\migrate\Plugin\MigrationInterface;
+
+/**
+ * Source plugin for retrieving data via URLs.
+ *
+ * @MigrateSource(
+ *   id = "table"
+ * )
+ */
+class Table extends SqlBase {
+
+  const TABLE_ALIAS = 't';
+
+  /**
+   * The name of the destination table.
+   *
+   * @var string
+   */
+  protected $tableName;
+
+  /**
+   * IDMap compatible array of id fields.
+   *
+   * @var array
+   */
+  protected $idFields;
+
+  /**
+   * Array of fields present on the destination table.
+   *
+   * @var array
+   */
+  protected $fields;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration, StateInterface $state) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition, $migration, $state);
+    $this->tableName = $configuration['table_name'];
+    // Insert alias in id_fields.
+    foreach ($configuration['id_fields'] as &$field) {
+      $field['alias'] = static::TABLE_ALIAS;
+    }
+    $this->idFields = $configuration['id_fields'];
+    $this->fields = isset($configuration['fields']) ? $configuration['fields'] : [];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function query() {
+    return $this->select($this->tableName, static::TABLE_ALIAS)->fields(static::TABLE_ALIAS, $this->fields);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function fields() {
+    return $this->fields;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getIds() {
+    if (empty($this->idFields)) {
+      throw new MigrateException('Id fields are required for a table source');
+    }
+    return $this->idFields;
+  }
+
+}
diff --git a/web/modules/migrate_plus/src/Plugin/migrate_plus/authentication/OAuth2.php b/web/modules/migrate_plus/src/Plugin/migrate_plus/authentication/OAuth2.php
index 116314d6bcaa2c7e6da502211c8ab4b2c695b5cf..0be3f0810fa80bd167b07e7333fcf49a8b0336c4 100644
--- a/web/modules/migrate_plus/src/Plugin/migrate_plus/authentication/OAuth2.php
+++ b/web/modules/migrate_plus/src/Plugin/migrate_plus/authentication/OAuth2.php
@@ -41,21 +41,26 @@ public function getAuthenticationOptions() {
       case 'authorization_code':
         $grant_type = new AuthorizationCode($client, $this->configuration);
         break;
+
       case 'client_credentials':
         $grant_type = new ClientCredentials($client, $this->configuration);
         break;
+
       case 'urn:ietf:params:oauth:grant-type:jwt-bearer':
         $grant_type = new JwtBearer($client, $this->configuration);
         break;
+
       case 'password':
         $grant_type = new PasswordCredentials($client, $this->configuration);
         break;
+
       case 'refresh_token':
         $grant_type = new RefreshToken($client, $this->configuration);
         break;
+
       default:
         throw new MigrateException("Unrecognized grant_type {$this->configuration['grant_type']}.");
-      break;
+
     }
     $middleware = new OAuthMiddleware($client, $grant_type);
 
diff --git a/web/modules/migrate_plus/src/Plugin/migrate_plus/data_fetcher/File.php b/web/modules/migrate_plus/src/Plugin/migrate_plus/data_fetcher/File.php
index dfa02d3bc3d885e8e043c060f3190d5b602fb9fa..33e3cae5c5d665cb0f0e9cbca82bd8f9d8237d61 100644
--- a/web/modules/migrate_plus/src/Plugin/migrate_plus/data_fetcher/File.php
+++ b/web/modules/migrate_plus/src/Plugin/migrate_plus/data_fetcher/File.php
@@ -34,7 +34,7 @@ public function getRequestHeaders() {
    * {@inheritdoc}
    */
   public function getResponse($url) {
-    $response = file_get_contents($url);
+    $response = @file_get_contents($url);
     if ($response === FALSE) {
       throw new MigrateException('file parser plugin: could not retrieve data from ' . $url);
     }
diff --git a/web/modules/migrate_plus/src/Plugin/migrate_plus/data_parser/Json.php b/web/modules/migrate_plus/src/Plugin/migrate_plus/data_parser/Json.php
index c1220bc5f4e60d9836fbdaac4cabce4947e70d4f..7ef4467d00b1ac7e963b4d15472e143d4cbfc40b 100644
--- a/web/modules/migrate_plus/src/Plugin/migrate_plus/data_parser/Json.php
+++ b/web/modules/migrate_plus/src/Plugin/migrate_plus/data_parser/Json.php
@@ -114,8 +114,8 @@ protected function fetchNextRow() {
         foreach ($field_selectors as $field_selector) {
           if (is_array($field_data) && array_key_exists($field_selector, $field_data)) {
             $field_data = $field_data[$field_selector];
-	        }
-	        else {
+          }
+          else {
             $field_data = '';
           }
         }
diff --git a/web/modules/migrate_plus/src/Plugin/migrate_plus/data_parser/SimpleXml.php b/web/modules/migrate_plus/src/Plugin/migrate_plus/data_parser/SimpleXml.php
index 8a58d8b075c5d2bf77ed7a2ef634e54e0e1b0699..5e09822287d97f89faeccea88458bea22f6f6371 100644
--- a/web/modules/migrate_plus/src/Plugin/migrate_plus/data_parser/SimpleXml.php
+++ b/web/modules/migrate_plus/src/Plugin/migrate_plus/data_parser/SimpleXml.php
@@ -45,14 +45,14 @@ protected function openSourceUrl($url) {
     libxml_clear_errors();
 
     $xml_data = $this->getDataFetcherPlugin()->getResponseContent($url);
-    $xml = simplexml_load_string($xml_data);
-    $this->registerNamespaces($xml);
-    $xpath = $this->configuration['item_selector'];
-    $this->matches = $xml->xpath($xpath);
+    $xml = simplexml_load_string(trim($xml_data));
     foreach (libxml_get_errors() as $error) {
       $error_string = self::parseLibXmlError($error);
       throw new MigrateException($error_string);
     }
+    $this->registerNamespaces($xml);
+    $xpath = $this->configuration['item_selector'];
+    $this->matches = $xml->xpath($xpath);
     return TRUE;
   }
 
diff --git a/web/modules/migrate_plus/src/Plugin/migrate_plus/data_parser/Soap.php b/web/modules/migrate_plus/src/Plugin/migrate_plus/data_parser/Soap.php
index 7f8c694e2f5b7a6038ef08ebd96537e91b1c4999..3f8a75421a9e42ba7eefff18e7f31b6bf6f9c63c 100644
--- a/web/modules/migrate_plus/src/Plugin/migrate_plus/data_parser/Soap.php
+++ b/web/modules/migrate_plus/src/Plugin/migrate_plus/data_parser/Soap.php
@@ -104,12 +104,15 @@ protected function openSourceUrl($url) {
         $xml = simplexml_load_string($response_value);
         $this->iterator = new \ArrayIterator($xml->xpath($this->itemSelector));
         break;
+
       case 'object':
         $this->iterator = new \ArrayIterator($response_value->{$this->itemSelector});
         break;
+
       case 'array':
         $this->iterator = new \ArrayIterator($response_value[$this->itemSelector]);
         break;
+
     }
     return TRUE;
   }
diff --git a/web/modules/migrate_plus/src/Plugin/migrate_plus/data_parser/Xml.php b/web/modules/migrate_plus/src/Plugin/migrate_plus/data_parser/Xml.php
index 6c08069276e886dd3907c0cc22ec3a2e18f2b78b..2737850aa365960a1e8824046885d7cc5ef09c4f 100644
--- a/web/modules/migrate_plus/src/Plugin/migrate_plus/data_parser/Xml.php
+++ b/web/modules/migrate_plus/src/Plugin/migrate_plus/data_parser/Xml.php
@@ -135,7 +135,7 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition
    *   A \SimpleXmlElement when the document is parseable, or false if a
    *   parsing error occurred.
    *
-   * @throws MigrateException
+   * @throws \Drupal\migrate\MigrateException
    */
   protected function getSimpleXml() {
     $node = $this->reader->expand();
diff --git a/web/modules/migrate_plus/tests/data/simple_xml_broken_missing_tag.xml b/web/modules/migrate_plus/tests/data/simple_xml_broken_missing_tag.xml
new file mode 100644
index 0000000000000000000000000000000000000000..75fe95fb79040c5395ce9c2ae5b9e9e96480d705
--- /dev/null
+++ b/web/modules/migrate_plus/tests/data/simple_xml_broken_missing_tag.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<items>
+  <item id="1">
+    <values title="Values">
+      <value>Value 1</value>
+      <value>Value 2</value>
+    </values>
+  <!-- item -->
+  <item id="2">
+    <values title="Values">
+      <value>Value 1 (single)</value>
+    </values>
+  </item>
+</items>
diff --git a/web/modules/migrate_plus/tests/data/simple_xml_broken_tag_mismatch.xml b/web/modules/migrate_plus/tests/data/simple_xml_broken_tag_mismatch.xml
new file mode 100644
index 0000000000000000000000000000000000000000..579540925dbb83420134dde3bc98306c83722c5c
--- /dev/null
+++ b/web/modules/migrate_plus/tests/data/simple_xml_broken_tag_mismatch.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- unmatched tags -->
+<ietems> <!-- wrong tag -->
+  <item id="1">
+    <values title="Values">
+      <value>Value 1</value>
+      <value>Value 2</value>
+    </values>
+  </item>
+  <item id="2">
+    <values title="Values">
+      <value>Value 1 (single)</value>
+    </values>
+  </item>
+</items>
diff --git a/web/modules/migrate_plus/tests/data/simple_xml_invalid_multi_whitespace.xml b/web/modules/migrate_plus/tests/data/simple_xml_invalid_multi_whitespace.xml
new file mode 100644
index 0000000000000000000000000000000000000000..a063482ca716569ee17fbf73c766e5a931904f75
--- /dev/null
+++ b/web/modules/migrate_plus/tests/data/simple_xml_invalid_multi_whitespace.xml
@@ -0,0 +1,17 @@
+
+
+
+<?xml version="1.0" encoding="utf-8"?>
+<items>
+  <item id="1">
+    <values title="Values">
+      <value>Value 1</value>
+      <value>Value 2</value>
+    </values>
+  </item>
+  <item id="2">
+    <values title="Values">
+      <value>Value 1 (single)</value>
+    </values>
+  </item>
+</items>
diff --git a/web/modules/migrate_plus/tests/data/simple_xml_invalid_single_line.xml b/web/modules/migrate_plus/tests/data/simple_xml_invalid_single_line.xml
new file mode 100644
index 0000000000000000000000000000000000000000..86ec1961a50834163a9be7d884090d7b2b5c3327
--- /dev/null
+++ b/web/modules/migrate_plus/tests/data/simple_xml_invalid_single_line.xml
@@ -0,0 +1,15 @@
+
+<?xml version="1.0" encoding="utf-8"?>
+<items>
+  <item id="1">
+    <values title="Values">
+      <value>Value 1</value>
+      <value>Value 2</value>
+    </values>
+  </item>
+  <item id="2">
+    <values title="Values">
+      <value>Value 1 (single)</value>
+    </values>
+  </item>
+</items>
diff --git a/web/modules/migrate_plus/tests/data/simple_xml_non_xml.xml b/web/modules/migrate_plus/tests/data/simple_xml_non_xml.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d7447b68393f618d3fd33494f5e83f1f61e8749f
--- /dev/null
+++ b/web/modules/migrate_plus/tests/data/simple_xml_non_xml.xml
@@ -0,0 +1,17 @@
+
+
+
+<? xml version="1.0" encoding="utf-8"?>
+<items>
+  <item id="1">
+    <values title="Values">
+      <value>Value 1</value>
+      <value>Value 2</value>
+    </values>
+  </item>
+  <item id="2">
+    <values title="Values">
+      <value>Value 1 (single)</value>
+    </values>
+  </item>
+</items>
diff --git a/web/modules/migrate_plus/tests/modules/migrate_plus_test/config/install/migrate_plus.migration.dummy.yml b/web/modules/migrate_plus/tests/modules/migrate_plus_test/config/install/migrate_plus.migration.dummy.yml
new file mode 100644
index 0000000000000000000000000000000000000000..3a01054ce7a323ea56edbd0978952299c880526c
--- /dev/null
+++ b/web/modules/migrate_plus/tests/modules/migrate_plus_test/config/install/migrate_plus.migration.dummy.yml
@@ -0,0 +1,21 @@
+langcode: en
+status: true
+dependencies: {  }
+id: dummy
+label: Dummy migration
+migration_tags: {  }
+source:
+  plugin: embedded_data
+  data_rows:
+    -
+      name: Dummy
+  ids:
+    name:
+      type: string
+process:
+  name: name
+destination:
+  plugin: null
+migration_dependencies:
+  required: {  }
+  optional: {  }
diff --git a/web/modules/migrate_plus/tests/modules/migrate_plus_test/config/install/migrate_plus.migration.fruit_terms.yml b/web/modules/migrate_plus/tests/modules/migrate_plus_test/config/install/migrate_plus.migration.fruit_terms.yml
new file mode 100644
index 0000000000000000000000000000000000000000..7f606d127f01412455ceb8dc28c4b75e0456d454
--- /dev/null
+++ b/web/modules/migrate_plus/tests/modules/migrate_plus_test/config/install/migrate_plus.migration.fruit_terms.yml
@@ -0,0 +1,32 @@
+langcode: en
+status: true
+dependencies: {  }
+id: fruit_terms
+label: Fruit Terms
+class: null
+field_plugin_method: null
+cck_plugin_method: null
+migration_tags: {  }
+migration_group: default
+source:
+  plugin: embedded_data
+  data_rows:
+    -
+      name: Apple
+    -
+      name: Banana
+    -
+      name: Orange
+  ids:
+    name:
+      type: string
+  constants:
+    vocabulary: fruit
+process:
+  name: name
+  vid: constants/vocabulary
+destination:
+  plugin: entity:taxonomy_term
+migration_dependencies:
+  required: {  }
+  optional: {  }
diff --git a/web/modules/migrate_plus/tests/modules/migrate_plus_test/config/install/migrate_plus.migration_group.default.yml b/web/modules/migrate_plus/tests/modules/migrate_plus_test/config/install/migrate_plus.migration_group.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..0f35a218958af5e973b980f989daeb17b3da19f4
--- /dev/null
+++ b/web/modules/migrate_plus/tests/modules/migrate_plus_test/config/install/migrate_plus.migration_group.default.yml
@@ -0,0 +1,9 @@
+langcode: en
+status: true
+dependencies: {  }
+id: default
+label: Default
+description: ''
+source_type: ''
+module: null
+shared_configuration: null
diff --git a/web/modules/migrate_plus/tests/modules/migrate_plus_test/migrate_plus_test.info.yml b/web/modules/migrate_plus/tests/modules/migrate_plus_test/migrate_plus_test.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..cec43eaa1425117f8e53d37415bbf18b8179c68a
--- /dev/null
+++ b/web/modules/migrate_plus/tests/modules/migrate_plus_test/migrate_plus_test.info.yml
@@ -0,0 +1,13 @@
+type: module
+name: Migrate Plus Test
+description: 'Test module to test Migrate Plus.'
+package: Testing
+core_version_requirement: ^8.8 || ^9
+dependencies:
+  - drupal:migrate
+  - migrate_plus:migrate_plus
+
+# Information added by Drupal.org packaging script on 2020-04-30
+version: '8.x-5.1'
+project: 'migrate_plus'
+datestamp: 1588261062
diff --git a/web/modules/migrate_plus/tests/src/Functional/LoadTest.php b/web/modules/migrate_plus/tests/src/Functional/LoadTest.php
index c9aceda72053b8cc05087edf6a12f57e9a560abc..02edda0ee98c3a5c252de00adf4f00ba0ea19661 100644
--- a/web/modules/migrate_plus/tests/src/Functional/LoadTest.php
+++ b/web/modules/migrate_plus/tests/src/Functional/LoadTest.php
@@ -13,14 +13,15 @@
 class LoadTest extends BrowserTestBase {
 
   /**
-   * Modules to enable.
-   *
-   * @var array
+   * {@inheritdoc}
    */
-  public static $modules = [
+  protected static $modules = [
     'migrate_plus',
     'migrate_example',
+    'migrate_example_setup',
     'migrate_example_advanced',
+    'migrate_example_advanced_setup',
+    'migrate_json_example',
   ];
 
   /**
@@ -33,7 +34,12 @@ class LoadTest extends BrowserTestBase {
   /**
    * {@inheritdoc}
    */
-  protected function setUp() {
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
     parent::setUp();
     $this->user = $this->drupalCreateUser(['administer site configuration']);
     $this->drupalLogin($this->user);
@@ -42,7 +48,7 @@ protected function setUp() {
   /**
    * Tests that the home page loads with a 200 response.
    */
-  public function testLoad() {
+  public function testLoad(): void {
     $this->drupalGet(Url::fromRoute('<front>'));
     $this->assertSession()->statusCodeEquals(200);
   }
diff --git a/web/modules/migrate_plus/tests/src/Kernel/EntityLookupAccessTest.php b/web/modules/migrate_plus/tests/src/Kernel/EntityLookupAccessTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..794484f99371d4192bc7b2bc5c1ed47638a558c1
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Kernel/EntityLookupAccessTest.php
@@ -0,0 +1,121 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Kernel;
+
+use Drupal\entity_test\Entity\EntityTest;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\migrate\MigrateExecutable;
+use Drupal\migrate\Row;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+use Drupal\user\Entity\User;
+
+/**
+ * Tests entity lookup access check.
+ *
+ * @group migrate_plus
+ */
+class EntityLookupAccessTest extends KernelTestBase {
+  use UserCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'entity_test',
+    'migrate',
+    'migrate_plus',
+    'system',
+    'user',
+  ];
+
+  /**
+   * A user.
+   *
+   * @var \Drupal\user\Entity\User
+   */
+  protected $user;
+
+  /**
+   * A test entity.
+   *
+   * @var \Drupal\entity_test\Entity\EntityTest
+   */
+  protected $entity;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->installSchema('system', ['sequences']);
+    $this->installEntitySchema('entity_test');
+    $this->installEntitySchema('user');
+    $this->installConfig('user');
+
+    $this->user = $this->createUser(['view all entity_test_query_access entities']);
+    $this->entity = EntityTest::create(['name' => $this->randomMachineName(8)]);
+  }
+
+  /**
+   * Tests that access is honored for entity lookups.
+   */
+  public function testEntityLookupAccessCheck(): void {
+    $definition = [
+      'source' => [
+        'plugin' => 'embedded_data',
+        'data_rows' => [
+          ['id' => 1],
+        ],
+        'ids' => [
+          'id' => ['type' => 'integer'],
+        ],
+      ],
+      'process' => [],
+      'destination' => [
+        'plugin' => 'entity:entity_test',
+      ],
+    ];
+    $migration = \Drupal::service('plugin.manager.migration')->createStubMigration($definition);
+    $executable = new MigrateExecutable($migration);
+    $row = new Row();
+    $configuration_base = [
+      'entity_type' => 'entity_test',
+      'value_key' => 'id',
+    ];
+
+    // Set access_check true.
+    $configuration = $configuration_base + ['access_check' => TRUE];
+
+    // Test as anonymous.
+    $anonymous = User::getAnonymousUser();
+    $this->setCurrentUser($anonymous);
+    $plugin = \Drupal::service('plugin.manager.migrate.process')
+      ->createInstance('entity_lookup', $configuration, $migration);
+    // Check the entity is not found.
+    $value = $plugin->transform($this->entity->id(), $executable, $row, 'id');
+    $this->assertNull($value);
+
+    // Test as authenticated user.
+    $this->setCurrentUser($this->user);
+    $plugin = \Drupal::service('plugin.manager.migrate.process')
+      ->createInstance('entity_lookup', $configuration, $migration);
+    // Check the entity is found.
+    $value = $plugin->transform($this->entity->id(), $executable, $row, 'id');
+    $this->assertSame($this->entity->id(), $value);
+
+    // Retest with access check false.
+    $configuration = $configuration_base + ['access_check' => FALSE];
+    $plugin = \Drupal::service('plugin.manager.migrate.process')
+      ->createInstance('entity_lookup', $configuration, $migration);
+
+    // Check the entity is found.
+    $value = $plugin->transform($this->entity->id(), $executable, $row, 'id');
+    $this->assertSame($this->entity->id(), $value);
+  }
+
+}
diff --git a/web/modules/migrate_plus/tests/src/Kernel/MigrateTableBatchTest.php b/web/modules/migrate_plus/tests/src/Kernel/MigrateTableBatchTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..4458b23667eb97ca8a933164e2a07a1f7307e5c6
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Kernel/MigrateTableBatchTest.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Kernel;
+
+/**
+ * Verifies all tests pass with batching enabled, uneven batches.
+ *
+ * @group migrate
+ */
+class MigrateTableBatchTest extends MigrateTableTest {
+
+  /**
+   * The batch size to configure (a size of 1 disables batching).
+   *
+   * @var int
+   */
+  protected $batchSize = 2;
+
+}
diff --git a/web/modules/migrate_plus/tests/src/Kernel/MigrateTableEvenBatchTest.php b/web/modules/migrate_plus/tests/src/Kernel/MigrateTableEvenBatchTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..1c060e4ff3228c7021436f384ccc31cd49414fa6
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Kernel/MigrateTableEvenBatchTest.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Kernel;
+
+/**
+ * Verifies all tests pass with batching enabled, even batches.
+ *
+ * @group migrate
+ */
+class MigrateTableEvenBatchTest extends MigrateTableTest {
+
+  /**
+   * The batch size to configure (a size of 1 disables batching).
+   *
+   * @var int
+   */
+  protected $batchSize = 3;
+
+}
diff --git a/web/modules/migrate_plus/tests/src/Kernel/MigrateTableIncrementBatchTest.php b/web/modules/migrate_plus/tests/src/Kernel/MigrateTableIncrementBatchTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..32edb2a8f4784bab40f27d4245ddc7928f88ddb8
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Kernel/MigrateTableIncrementBatchTest.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Kernel;
+
+/**
+ * Verifies all tests pass with batching enabled, uneven batches.
+ *
+ * @group migrate
+ */
+class MigrateTableIncrementBatchTest extends MigrateTableIncrementTest {
+
+  /**
+   * The batch size to configure.
+   *
+   * @var int
+   */
+  protected $batchSize = 2;
+
+}
diff --git a/web/modules/migrate_plus/tests/src/Kernel/MigrateTableIncrementEvenBatchTest.php b/web/modules/migrate_plus/tests/src/Kernel/MigrateTableIncrementEvenBatchTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..66a4879ba81dde932ab07b1df3b133a3e3e23075
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Kernel/MigrateTableIncrementEvenBatchTest.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Kernel;
+
+/**
+ * Verifies all tests pass with batching enabled, even batches.
+ *
+ * @group migrate
+ */
+class MigrateTableIncrementEvenBatchTest extends MigrateTableIncrementTest {
+
+  /**
+   * The batch size to configure.
+   *
+   * @var int
+   */
+  protected $batchSize = 3;
+
+}
diff --git a/web/modules/migrate_plus/tests/src/Kernel/MigrateTableIncrementTest.php b/web/modules/migrate_plus/tests/src/Kernel/MigrateTableIncrementTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..c0db133e21136eee550d83f9c63401c59db0fd90
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Kernel/MigrateTableIncrementTest.php
@@ -0,0 +1,144 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Kernel;
+
+use Drupal\migrate\MigrateExecutable;
+use Drupal\Tests\migrate\Kernel\MigrateTestBase;
+
+/**
+ * Tests migration destination table with auto-increment keys.
+ *
+ * @group migrate
+ */
+class MigrateTableIncrementTest extends MigrateTestBase {
+
+  const TABLE_NAME = 'migrate_test_destination_table';
+
+  /**
+   * The database connection.
+   *
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $connection;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['migrate_plus'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->connection = $this->container->get('database');
+    $this->connection->schema()->createTable(static::TABLE_NAME, [
+      'description' => 'Test table',
+      'fields' => [
+        'id' => [
+          'type' => 'serial',
+          'not null' => TRUE,
+        ],
+        'data1' => [
+          'type' => 'varchar',
+          'length' => '32',
+          'not null' => TRUE,
+        ],
+        'data2' => [
+          'type' => 'varchar',
+          'length' => '32',
+          'not null' => TRUE,
+        ],
+      ],
+      'primary key' => ['id'],
+    ]);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function tearDown(): void {
+    $this->connection->schema()->dropTable(static::TABLE_NAME);
+    parent::tearDown();
+  }
+
+  /**
+   * Create a minimally valid migration with some source data.
+   *
+   * @return array
+   *   The migration definition.
+   */
+  public function tableDestinationMigration(): array {
+    return [
+      'dummy table' => [
+        [
+          'id' => 'migration_table_test',
+          'migration_tags' => ['Testing'],
+          'source' => [
+            'plugin' => 'embedded_data',
+            'data_rows' => [
+              [
+                'data1' => 'dummy1 value1',
+                'data2' => 'dummy2 value1',
+              ],
+              [
+                'data1' => 'dummy1 value2',
+                'data2' => 'dummy2 value2',
+              ],
+              [
+                'data1' => 'dummy1 value3',
+                'data2' => 'dummy2 value3',
+              ],
+            ],
+            'ids' => [
+              'data1' => ['type' => 'string'],
+            ],
+          ],
+          'destination' => [
+            'plugin' => 'table',
+            'table_name' => static::TABLE_NAME,
+            'id_fields' => [
+              'id' => [
+                'type' => 'integer',
+                'use_auto_increment' => TRUE,
+              ],
+            ],
+          ],
+          'process' => [
+            'data1' => 'data1',
+            'data2' => 'data2',
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Tests table destination.
+   *
+   * @param array $definition
+   *   The migration definition.
+   *
+   * @dataProvider tableDestinationMigration
+   *
+   * @throws \Drupal\migrate\MigrateException
+   */
+  public function testTableDestination(array $definition) {
+    $migration = \Drupal::service('plugin.manager.migration')->createStubMigration($definition);
+
+    $executable = new MigrateExecutable($migration, $this);
+    $executable->import();
+
+    $values = $this->connection->select(static::TABLE_NAME)
+      ->fields(static::TABLE_NAME)
+      ->execute()
+      ->fetchAllAssoc('data1');
+
+    $this->assertEquals(1, $values['dummy1 value1']->id);
+    $this->assertEquals(2, $values['dummy1 value2']->id);
+    $this->assertEquals(3, $values['dummy1 value3']->id);
+    $this->assertEquals('dummy2 value3', $values['dummy1 value3']->data2);
+    $this->assertCount(3, $values);
+  }
+
+}
diff --git a/web/modules/migrate_plus/tests/src/Kernel/MigrateTableTest.php b/web/modules/migrate_plus/tests/src/Kernel/MigrateTableTest.php
index dc471770d711668f07e7e8fe2b136388e753b9f2..94d75b8913fe41158152f8790df2ed1a50c21832 100644
--- a/web/modules/migrate_plus/tests/src/Kernel/MigrateTableTest.php
+++ b/web/modules/migrate_plus/tests/src/Kernel/MigrateTableTest.php
@@ -2,8 +2,9 @@
 
 namespace Drupal\Tests\migrate_plus\Kernel;
 
-use Drupal\Core\Database\Database;
 use Drupal\migrate\MigrateExecutable;
+use Drupal\migrate\Plugin\MigrateIdMapInterface;
+use Drupal\migrate\Row;
 use Drupal\Tests\migrate\Kernel\MigrateTestBase;
 
 /**
@@ -13,7 +14,8 @@
  */
 class MigrateTableTest extends MigrateTestBase {
 
-  const TABLE_NAME = 'migrate_test_destination_table';
+  const SOURCE_TABLE_NAME = 'migrate_test_source_table';
+  const DEST_TABLE_NAME = 'migrate_test_destination_table';
 
   /**
    * The database connection.
@@ -22,44 +24,83 @@ class MigrateTableTest extends MigrateTestBase {
    */
   protected $connection;
 
+  /**
+   * The batch size to configure (a size of 1 disables batching).
+   *
+   * @var int
+   */
+  protected $batchSize = 1;
+
+  /**
+   * {@inheritdoc}
+   */
   public static $modules = ['migrate_plus'];
 
   /**
    * {@inheritdoc}
    */
-  protected function setUp() {
+  protected function setUp(): void {
     parent::setUp();
 
-    $this->connection = Database::getConnection();
-
-    $this->connection->schema()->createTable(static::TABLE_NAME, [
-      'description' => 'Test table',
-      'fields' => [
-        'data' => [
-          'type' => 'varchar',
-          'length' => '32',
-          'not null' => TRUE,
-        ],
-        'data2' => [
-          'type' => 'varchar',
-          'length' => '32',
-          'not null' => TRUE,
-        ],
-        'data3' => [
-          'type' => 'varchar',
-          'length' => '32',
-          'not null' => TRUE,
+    $this->connection = $this->container->get('database');
+    $connections = [
+      static::SOURCE_TABLE_NAME => $this->sourceDatabase,
+      static::DEST_TABLE_NAME => $this->connection,
+    ];
+    foreach ($connections as $table => $connection) {
+      $connection->schema()->createTable($table, [
+        'description' => 'Test table',
+        'fields' => [
+          'data' => [
+            'type' => 'varchar',
+            'length' => '32',
+            'not null' => TRUE,
+          ],
+          'data2' => [
+            'type' => 'varchar',
+            'length' => '32',
+            'not null' => TRUE,
+          ],
+          'data3' => [
+            'type' => 'varchar',
+            'length' => '32',
+            'not null' => TRUE,
+          ],
         ],
+        'primary key' => ['data'],
+      ]);
+    }
+    $query = $this->sourceDatabase->insert(static::SOURCE_TABLE_NAME)
+      ->fields(['data', 'data2', 'data3']);
+    $values = [
+      [
+        'data' => 'dummy value',
+        'data2' => 'dummy2 value',
+        'data3' => 'dummy3 value',
+      ],
+      [
+        'data' => 'dummy value2',
+        'data2' => 'dummy2 value2',
+        'data3' => 'dummy3 value2',
       ],
-      'primary key' => ['data'],
-    ]);
+      [
+        'data' => 'dummy value3',
+        'data2' => 'dummy2 value3',
+        'data3' => 'dummy3 value3',
+      ],
+    ];
+    foreach ($values as $record) {
+      $query->values($record);
+    }
+    $query->execute();
   }
 
   /**
    * {@inheritdoc}
    */
-  protected function tearDown() {
-    $this->connection->schema()->dropTable(static::TABLE_NAME);
+  protected function tearDown(): void {
+    $this->sourceDatabase->schema()->dropTable(static::SOURCE_TABLE_NAME);
+    $this->connection->schema()->dropTable(static::DEST_TABLE_NAME);
     parent::tearDown();
   }
 
@@ -69,37 +110,72 @@ protected function tearDown() {
    * @return array
    *   The migration definition.
    */
-  protected function getTableDestinationMigration() {
-    $definition = [
-      'id' => 'migration_table_test',
-      'migration_tags' => ['Testing'],
-      'source' => [
-        'plugin' => 'embedded_data',
-        'data_rows' => [
-          [
-            'data' => 'dummy value',
-            'data2' => 'dummy2 value',
-            'data3' => 'dummy3 value',
+  public function tableDestinationMigration() {
+    return [
+      'dummy table' => [
+        [
+          'id' => 'migration_table_test',
+          'migration_tags' => ['Testing'],
+          'source' => [
+            'plugin' => 'embedded_data',
+            'data_rows' => [
+              [
+                'data' => 'dummy1 value1',
+                'data2' => 'dummy2 value1',
+              ],
+              [
+                'data' => 'dummy1 value2',
+                'data2' => 'dummy2 value2',
+              ],
+              [
+                'data' => 'dummy1 value3',
+                'data2' => 'dummy2 value3',
+              ],
+            ],
+            'ids' => [
+              'data' => ['type' => 'string'],
+            ],
           ],
-          [
-            'data' => 'dummy value2',
-            'data2' => 'dummy2 value2',
-            'data3' => 'dummy3 value2',
+          'destination' => [
+            'plugin' => 'table',
+            'table_name' => static::DEST_TABLE_NAME,
+            'id_fields' => [
+              'data' => [
+                'type' => 'string',
+              ],
+            ],
           ],
-          [
-            'data' => 'dummy value3',
-            'data2' => 'dummy2 value3',
-            'data3' => 'dummy3 value3',
+          'process' => [
+            'data' => 'data',
+            'data2' => 'data2',
           ],
         ],
-        'ids' => [
+      ],
+    ];
+  }
+
+  /**
+   * Tests table migration.
+   */
+  public function testTableMigration(): void {
+    $definition = [
+      'id' => 'migration_table_test',
+      'migration_tags' => ['Testing'],
+      'source' => [
+        'plugin' => 'table',
+        'table_name' => static::SOURCE_TABLE_NAME,
+        'id_fields' => [
           'data' => ['type' => 'string'],
         ],
+        'ignore_map' => TRUE,
       ],
       'destination' => [
         'plugin' => 'table',
-        'table_name' => static::TABLE_NAME,
-        'id_fields' => ['data' => ['type' => 'string']],
+        'table_name' => static::DEST_TABLE_NAME,
+        'id_fields' => [
+          'data' => ['type' => 'string'],
+        ],
+        'batch_size' => $this->batchSize,
       ],
       'process' => [
         'data' => 'data',
@@ -107,20 +183,13 @@ protected function getTableDestinationMigration() {
         'data3' => 'data3',
       ],
     ];
-    return $definition;
-  }
-
-  /**
-   * Tests table destination.
-   */
-  public function testTableDestination() {
-    $migration = \Drupal::service('plugin.manager.migration')->createStubMigration($this->getTableDestinationMigration());
+    $migration = \Drupal::service('plugin.manager.migration')->createStubMigration($definition);
 
     $executable = new MigrateExecutable($migration, $this);
     $executable->import();
 
-    $values = $this->connection->select(static::TABLE_NAME)
-      ->fields(static::TABLE_NAME)
+    $values = $this->connection->select(static::DEST_TABLE_NAME)
+      ->fields(static::DEST_TABLE_NAME)
       ->execute()
       ->fetchAllAssoc('data');
 
@@ -129,35 +198,39 @@ public function testTableDestination() {
     $this->assertEquals('dummy2 value2', $values['dummy value2']->data2);
     $this->assertEquals('dummy3 value3', $values['dummy value3']->data3);
     $this->assertEquals(3, count($values));
-  }
-
-  /**
-   * Tests table rollback.
-   */
-  public function testTableRollback() {
-    $this->testTableDestination();
-
-    /** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
-    $migration = \Drupal::service('plugin.manager.migration')->createStubMigration($this->getTableDestinationMigration());
-    $executable = new MigrateExecutable($migration, $this);
-    $executable->import();
-
-    $values = $this->connection->select(static::TABLE_NAME)
-      ->fields(static::TABLE_NAME)
-      ->execute()
-      ->fetchAllAssoc('data');
-
-    $this->assertEquals('dummy value', $values['dummy value']->data);
-    $this->assertEquals(3, count($values));
 
     // Now rollback.
     $executable->rollback();
-    $values = $this->connection->select(static::TABLE_NAME)
-      ->fields(static::TABLE_NAME)
+    $values = $this->connection->select(static::DEST_TABLE_NAME)
+      ->fields(static::DEST_TABLE_NAME)
       ->execute()
       ->fetchAllAssoc('data');
 
     $this->assertEquals(0, count($values));
   }
 
+  /**
+   * Tests table update.
+   *
+   * @dataProvider tableDestinationMigration
+   */
+  public function testTableUpdate(array $definition): void {
+    // Make sure migration overwrites the original data for the first row.
+    $original_values = [
+      'data' => 'dummy value',
+      'data2' => 'original value 2',
+      'data3' => 'original value 3',
+    ];
+    $this->connection->insert(static::DEST_TABLE_NAME)
+      ->fields($original_values)
+      ->execute();
+
+    /** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
+    $migration = \Drupal::service('plugin.manager.migration')
+      ->createStubMigration($definition);
+    $migration->getIdMap()->saveIdMapping(new Row($original_values,
+      ['data' => 'dummy value']), ['data' => 'dummy value'], MigrateIdMapInterface::STATUS_NEEDS_UPDATE);
+    $this->testTableMigration();
+  }
+
 }
diff --git a/web/modules/migrate_plus/tests/src/Kernel/MigrationConfigEntityTest.php b/web/modules/migrate_plus/tests/src/Kernel/MigrationConfigEntityTest.php
index 354da7628af33b165db8bce8239e59661d942b14..b6e2b9b696a4e86eb6bdc9190e43cc0bae41a3e4 100644
--- a/web/modules/migrate_plus/tests/src/Kernel/MigrationConfigEntityTest.php
+++ b/web/modules/migrate_plus/tests/src/Kernel/MigrationConfigEntityTest.php
@@ -2,17 +2,30 @@
 
 namespace Drupal\Tests\migrate_plus\Kernel;
 
-use Drupal\KernelTests\KernelTestBase;
+use Drupal\Component\Plugin\Exception\PluginNotFoundException;
+use Drupal\migrate\MigrateExecutable;
 use Drupal\migrate_plus\Entity\Migration;
+use Drupal\Tests\migrate\Kernel\MigrateTestBase;
 
 /**
  * Test migration config entity discovery.
  *
  * @group migrate_plus
  */
-class MigrationConfigEntityTest extends KernelTestBase {
+class MigrationConfigEntityTest extends MigrateTestBase {
 
-  public static $modules = ['migrate', 'migrate_plus'];
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'migrate',
+    'migrate_plus',
+    'migrate_plus_test',
+    'taxonomy',
+    'text',
+    'system',
+    'user',
+  ];
 
   /**
    * The plugin manager.
@@ -24,17 +37,21 @@ class MigrationConfigEntityTest extends KernelTestBase {
   /**
    * {@inheritdoc}
    */
-  protected function setUp() {
+  protected function setUp(): void {
     parent::setUp();
     $this->pluginManager = \Drupal::service('plugin.manager.migration');
+    $this->installConfig('migrate_plus');
+    $this->installEntitySchema('taxonomy_term');
+    $this->installSchema('system', ['key_value', 'key_value_expire']);
   }
 
   /**
    * Tests cache invalidation.
    */
-  public function testCacheInvalidation() {
+  public function testCacheInvalidation(): void {
     $config = Migration::create([
       'id' => 'test',
+      'status' => TRUE,
       'label' => 'Label A',
       'migration_tags' => [],
       'source' => [],
@@ -43,7 +60,7 @@ public function testCacheInvalidation() {
     ]);
     $config->save();
 
-    $this->assertTrue($this->pluginManager->getDefinition('test'));
+    $this->assertNotEmpty($this->pluginManager->getDefinition('test'));
     $this->assertSame('Label A', $this->pluginManager->getDefinition('test')['label']);
 
     // Clear static cache in the plugin manager, the cache tag take care of the
@@ -57,4 +74,55 @@ public function testCacheInvalidation() {
     $this->assertSame('Label B', $this->pluginManager->getDefinition('test')['label']);
   }
 
+  /**
+   * Tests migration status.
+   */
+  public function testMigrationStatus(): void {
+    $configs = [
+      [
+        'id' => 'test_active',
+        'status' => TRUE,
+        'label' => 'Label Active',
+        'migration_tags' => [],
+        'source' => [],
+        'destination' => [],
+        'migration_dependencies' => [],
+      ],
+      [
+        'id' => 'test_inactive',
+        'status' => FALSE,
+        'label' => 'Label Inactive',
+        'migration_tags' => [],
+        'source' => [],
+        'destination' => [],
+        'migration_dependencies' => [],
+      ],
+    ];
+
+    foreach ($configs as $config) {
+      Migration::create($config)->save();
+    }
+
+    $definitions = $this->pluginManager->getDefinitions();
+    $this->assertCount(1, $definitions);
+    $this->assertArrayHasKey('test_active', $definitions);
+
+    $this->expectException(PluginNotFoundException::class);
+    $this->expectExceptionMessage('The "test_inactive" plugin does not exist.');
+    $this->pluginManager->getDefinition('test_inactive');
+  }
+
+  /**
+   * Tests migration from configuration.
+   */
+  public function testImport(): void {
+    $this->installConfig('migrate_plus_test');
+    /** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
+    $migration = $this->pluginManager->createInstance('fruit_terms');
+    $id_map = $migration->getIdMap();
+    $executable = new MigrateExecutable($migration, $this);
+    $executable->import();
+    $this->assertSame(3, $id_map->importedCount());
+  }
+
 }
diff --git a/web/modules/migrate_plus/tests/src/Kernel/MigrationGroupTest.php b/web/modules/migrate_plus/tests/src/Kernel/MigrationGroupTest.php
index 53d3ad467d52104f626c74b256e539a0c4a5d087..138cca38fcf0e12f8b1e57d4493f805fb656f708 100644
--- a/web/modules/migrate_plus/tests/src/Kernel/MigrationGroupTest.php
+++ b/web/modules/migrate_plus/tests/src/Kernel/MigrationGroupTest.php
@@ -13,12 +13,15 @@
  */
 class MigrationGroupTest extends KernelTestBase {
 
-  public static $modules = ['migrate', 'migrate_plus'];
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['migrate', 'migrate_plus', 'migrate_plus_test'];
 
   /**
    * Test that group configuration is properly merged into specific migrations.
    */
-  public function testConfigurationMerge() {
+  public function testConfigurationMerge(): void {
     $group_id = 'test_group';
 
     /** @var \Drupal\migrate_plus\Entity\MigrationGroupInterface $migration_group */
@@ -69,10 +72,9 @@ public function testConfigurationMerge() {
     $migration->save();
 
     $expected_config = [
-      'migration_group' => $group_id,
       'label' => 'Unaffected by the group',
-      'migration_tags' => ['Drupal 7'],
-      'source' => [
+      'getMigrationTags' => ['Drupal 7'],
+      'getSourceConfiguration' => [
         'plugin' => 'empty',
         'constants' => [
           'entity_type' => 'user',
@@ -80,13 +82,13 @@ public function testConfigurationMerge() {
           'cardinality' => '3',
         ],
       ],
-      'destination' => ['plugin' => 'field_storage_config'],
+      'getDestinationConfiguration' => ['plugin' => 'field_storage_config'],
     ];
-    /** @var \Drupal\migrate\Plugin\MigrationInterface $loaded_migration */
+    /** @var \Drupal\migrate_plus\Plugin\MigrationInterface $loaded_migration */
     $loaded_migration = $this->container->get('plugin.manager.migration')
       ->createInstance('specific_migration');
-    foreach ($expected_config as $key => $expected_value) {
-      $actual_value = $loaded_migration->get($key);
+    foreach ($expected_config as $method => $expected_value) {
+      $actual_value = $loaded_migration->$method();
       $this->assertEquals($expected_value, $actual_value);
     }
   }
@@ -94,7 +96,7 @@ public function testConfigurationMerge() {
   /**
    * Test that deleting a group deletes its migrations.
    */
-  public function testDelete() {
+  public function testDelete(): void {
     /** @var \Drupal\migrate_plus\Entity\MigrationGroupInterface $migration_group */
     $group_configuration = [
       'id' => 'test_group',
@@ -125,4 +127,16 @@ public function testDelete() {
     $this->assertNull($loaded_migration);
   }
 
+  /**
+   * Test that migrations without a group are assigned to the default group.
+   */
+  public function testDefaultGroup(): void {
+    $this->installConfig('migrate_plus_test');
+
+    /** @var \Drupal\migrate\Plugin\MigrationPluginManagerInterface $pluginManager */
+    $pluginManager = \Drupal::service('plugin.manager.migration');
+    $migration = $pluginManager->getDefinition('dummy');
+    $this->assertEqual($migration['migration_group'], 'default', 'Migrations without an explicit group are assigned the default group.');
+  }
+
 }
diff --git a/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate/process/DefaultEntityValueTest.php b/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate/process/DefaultEntityValueTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..d3ee124ddf9809297ea1e116708db67ee277fcc7
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate/process/DefaultEntityValueTest.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Kernel\Plugin\migrate\process;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\Plugin\MigrateDestinationInterface;
+use Drupal\migrate\Plugin\MigrationInterface;
+use Drupal\migrate\Row;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+
+/**
+ * Tests the default_entity_value plugin.
+ *
+ * @coversDefaultClass \Drupal\migrate_plus\Plugin\migrate\process\DefaultEntityValue
+ * @group migrate_plus
+ */
+class DefaultEntityValueTest extends KernelTestBase {
+
+  use UserCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'migrate_plus',
+    'migrate',
+    'user',
+    'system',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->installSchema('system', ['sequences']);
+    $this->installEntitySchema('user');
+  }
+
+  /**
+   * Tests the lookup when the value is empty.
+   *
+   * @covers ::transform
+   */
+  public function testDefaultEntityValue(): void {
+    // Create a user.
+    $editorial_user = $this->createUser([], 'editorial');
+    $journalist_user = $this->createUser([], 'journalist');
+    // Setup test migration objects.
+    $migration_prophecy = $this->prophesize(MigrationInterface::class);
+    $migrate_destination_prophecy = $this->prophesize(MigrateDestinationInterface::class);
+    $migrate_destination_prophecy->getPluginId()->willReturn('user');
+    $migrate_destination = $migrate_destination_prophecy->reveal();
+    $migration_prophecy->getDestinationPlugin()->willReturn($migrate_destination);
+    $migration_prophecy->getProcess()->willReturn([]);
+    $migration = $migration_prophecy->reveal();
+    $configuration = [
+      'entity_type' => 'user',
+      'value_key' => 'name',
+      'ignore_case' => TRUE,
+      'default_value' => 'editorial',
+    ];
+    $plugin = \Drupal::service('plugin.manager.migrate.process')
+      ->createInstance('default_entity_value', $configuration, $migration);
+    $executable = $this->prophesize(MigrateExecutableInterface::class)->reveal();
+    $row = new Row();
+    // Check the case default value is not used.
+    $value = $plugin->transform($journalist_user->id(), $executable, $row, 'name');
+    $this->assertSame($journalist_user->id(), $value);
+    // Check the default value is found.
+    $value = $plugin->transform('', $executable, $row, 'name');
+    $this->assertSame($editorial_user->id(), $value);
+  }
+
+}
diff --git a/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate/process/EntityGenerateTest.php b/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate/process/EntityGenerateTest.php
index 3d46496de479d92ba2507ca6506dcbad65733551..2b3fb728e709353f30d1a79eb9e03e7192df29de 100644
--- a/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate/process/EntityGenerateTest.php
+++ b/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate/process/EntityGenerateTest.php
@@ -5,12 +5,15 @@
 use Drupal\Core\Config\Entity\ConfigEntityInterface;
 use Drupal\Core\Field\FieldStorageDefinitionInterface;
 use Drupal\Core\Language\LanguageInterface;
-use Drupal\field\Tests\EntityReference\EntityReferenceTestTrait;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
 use Drupal\KernelTests\KernelTestBase;
 use Drupal\migrate\MigrateExecutable;
 use Drupal\migrate\MigrateMessageInterface;
 use Drupal\node\Entity\NodeType;
+use Drupal\taxonomy\Entity\Term;
 use Drupal\taxonomy\Entity\Vocabulary;
+use Drupal\Tests\field\Traits\EntityReferenceTestTrait;
 
 /**
  * Tests the migration plugin.
@@ -25,7 +28,7 @@ class EntityGenerateTest extends KernelTestBase implements MigrateMessageInterfa
   /**
    * {@inheritdoc}
    */
-  public static $modules = [
+  protected static $modules = [
     'migrate_plus',
     'migrate',
     'user',
@@ -68,7 +71,7 @@ class EntityGenerateTest extends KernelTestBase implements MigrateMessageInterfa
   /**
    * {@inheritdoc}
    */
-  protected function setUp() {
+  protected function setUp(): void {
     parent::setUp();
     // Create article content type.
     $values = [
@@ -84,7 +87,7 @@ protected function setUp() {
     $this->installEntitySchema('user');
     $this->installSchema('system', ['sequences']);
     $this->installSchema('user', 'users_data');
-    $this->installConfig($this->modules);
+    $this->installConfig(self::$modules);
 
     // Create a vocabulary.
     $vocabulary = Vocabulary::create([
@@ -107,6 +110,18 @@ protected function setUp() {
       FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED
     );
 
+    // Create a non-reference field.
+    FieldStorageConfig::create([
+      'field_name' => 'field_integer',
+      'type' => 'integer',
+      'entity_type' => 'node',
+    ])->save();
+    FieldConfig::create([
+      'field_name' => 'field_integer',
+      'entity_type' => 'node',
+      'bundle' => $this->bundle,
+    ])->save();
+
     $this->migrationPluginManager = \Drupal::service('plugin.manager.migration');
   }
 
@@ -117,7 +132,7 @@ protected function setUp() {
    *
    * @covers ::transform
    */
-  public function testTransform(array $definition, array $expected, array $preSeed = []) {
+  public function testTransform(array $definition, array $expected, array $preSeed = []): void {
     // Pre seed some test data.
     foreach ($preSeed as $storageName => $values) {
       // If the first element of $values is a non-empty array, create multiple
@@ -134,8 +149,11 @@ public function testTransform(array $definition, array $expected, array $preSeed
 
     /** @var \Drupal\migrate\Plugin\Migration $migration */
     $migration = $this->migrationPluginManager->createStubMigration($definition);
-    /** @var EntityStorageBase $storage */
-    $storage = $this->readAttribute($migration->getDestinationPlugin(), 'storage');
+    $reflector = new \ReflectionObject($migration->getDestinationPlugin());
+    $attribute = $reflector->getProperty('storage');
+    $attribute->setAccessible(true);
+    /** @var \Drupal\Core\Entity\EntityStorageBase $storage */
+    $storage = $attribute->getValue($migration->getDestinationPlugin());
     $migrationExecutable = (new MigrateExecutable($migration, $this));
     $migrationExecutable->import();
 
@@ -156,14 +174,14 @@ public function testTransform(array $definition, array $expected, array $preSeed
                 foreach ($valueToCheck as $key => $expectedValue) {
                   if (empty($expectedValue)) {
                     if (!$entity->{$property}->isEmpty()) {
-                      $this->assertTrue($entity->{$property}[0]->entity->$key->isEmpty(), "Expected value is empty but field $property.$key is not empty.");
+                      $this->assertTrue($entity->{$property}[0]->entity->{$key}->isEmpty(), "Expected value is empty but field $property.$key is not empty.");
                     }
                     else {
-                      $this->assertTrue($entity->{$property}->isEmpty(), "FOOBAR Expected value is empty but field $property is not empty.");
+                      $this->assertTrue($entity->{$property}->isEmpty(), "Expected value is empty but field $property is not empty.");
                     }
                   }
                   elseif ($entity->{$property}->getValue()) {
-                    $this->assertEquals($expectedValue, $entity->{$property}[$valueID]->entity->$key->value);
+                    $this->assertEquals($expectedValue, $entity->get($property)->offsetGet($valueID)->entity->{$key}->value);
                   }
                   else {
                     $this->fail("Expected value: $expectedValue does not exist in $property.");
@@ -177,7 +195,7 @@ public function testTransform(array $definition, array $expected, array $preSeed
               foreach ($value as $key => $expectedValue) {
                 if (empty($expectedValue)) {
                   if (!$entity->{$property}->isEmpty()) {
-                    $this->assertTrue($entity->{$property}[0]->entity->$key->isEmpty(), "Expected value is empty but field $property.$key is not empty.");
+                    $this->assertTrue($entity->{$property}[0]->entity->{$key}->isEmpty(), "Expected value is empty but field $property.$key is not empty.");
                   }
                   else {
                     $this->assertTrue($entity->{$property}->isEmpty(), "BINBAZ Expected value is empty but field $property is not empty.");
@@ -203,6 +221,99 @@ public function testTransform(array $definition, array $expected, array $preSeed
     }
   }
 
+  /**
+   * Test lookup without a reference field.
+   */
+  public function testNonReferenceField() {
+    $values = [
+      'name' => 'Apples',
+      'vid' => $this->vocabulary,
+      'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED,
+    ];
+    $this->createTestData('taxonomy_term', $values);
+
+    // Not enough context is provided for a non reference field, so error out.
+    $definition = [
+      'source' => [
+        'plugin' => 'embedded_data',
+        'data_rows' => [
+          [
+            'id' => 1,
+            'title' => 'content item 1',
+            'term' => 'Apples',
+          ],
+        ],
+        'ids' => [
+          'id' => ['type' => 'integer'],
+        ],
+      ],
+      'process' => [
+        'id' => 'id',
+        'type' => [
+          'plugin' => 'default_value',
+          'default_value' => $this->bundle,
+        ],
+        'title' => 'title',
+        'field_integer' => [
+          'plugin' => 'entity_generate',
+          'source' => 'term',
+        ],
+      ],
+      'destination' => [
+        'plugin' => 'entity:node',
+      ],
+    ];
+    /** @var \Drupal\migrate\Plugin\Migration $migration */
+    $migration = $this->migrationPluginManager->createStubMigration($definition);
+    $migrationExecutable = (new MigrateExecutable($migration, $this));
+    $migrationExecutable->import();
+    $this->assertEquals('Destination field type integer is not a recognized reference type.', $migration->getIdMap()->getMessages()->fetch()->message);
+    $this->assertSame(1, $migration->getIdMap()->messageCount());
+
+    // Enough context is provided so this should work.
+    $definition = [
+      'source' => [
+        'plugin' => 'embedded_data',
+        'data_rows' => [
+          [
+            'id' => 1,
+            'title' => 'content item 1',
+            'term' => 'Apples',
+          ],
+        ],
+        'ids' => [
+          'id' => ['type' => 'integer'],
+        ],
+      ],
+      'process' => [
+        'id' => 'id',
+        'type' => [
+          'plugin' => 'default_value',
+          'default_value' => $this->bundle,
+        ],
+        'title' => 'title',
+        'field_integer' => [
+          'plugin' => 'entity_generate',
+          'source' => 'term',
+          'value_key' => 'name',
+          'bundle_key' => 'vid',
+          'bundle' => $this->vocabulary,
+          'entity_type' => 'taxonomy_term',
+        ],
+      ],
+      'destination' => [
+        'plugin' => 'entity:node',
+      ],
+    ];
+    /** @var \Drupal\migrate\Plugin\Migration $migration */
+    $migration = $this->migrationPluginManager->createStubMigration($definition);
+    $migrationExecutable = (new MigrateExecutable($migration, $this));
+    $migrationExecutable->import();
+    $this->assertEmpty($migration->getIdMap()->messageCount());
+    $term = Term::load(1);
+    $this->assertEquals('Apples', $term->label());
+  }
+
   /**
    * Provides multiple migration definitions for "transform" test.
    */
@@ -444,6 +555,96 @@ public function transformDataProvider() {
           ],
         ],
       ],
+      'provide multiple values' => [
+        'definition' => [
+          'source' => [
+            'plugin' => 'embedded_data',
+            'data_rows' => [
+              [
+                'id' => 1,
+                'title' => 'content item 1',
+                'term' => 'Apples',
+              ],
+              [
+                'id' => 2,
+                'title' => 'content item 2',
+                'term' => 'Bananas',
+              ],
+              [
+                'id' => 3,
+                'title' => 'content item 3',
+                'term' => 'Grapes',
+              ],
+            ],
+            'ids' => [
+              'id' => ['type' => 'integer'],
+            ],
+            'constants' => [
+              'foo' => 'bar',
+            ],
+          ],
+          'process' => [
+            'id' => 'id',
+            'type' => [
+              'plugin' => 'default_value',
+              'default_value' => $this->bundle,
+            ],
+            'title' => 'title',
+            'term_upper' => [
+              'plugin' => 'callback',
+              'source' => 'term',
+              'callable' => 'strtoupper',
+            ],
+            $this->fieldName => [
+              'plugin' => 'entity_generate',
+              'source' => 'term',
+              'values' => [
+                'name' => '@term_upper',
+                'description' => 'constants/foo',
+              ],
+            ],
+          ],
+          'destination' => [
+            'plugin' => 'entity:node',
+          ],
+        ],
+        'expected' => [
+          'row 1' => [
+            'id' => 1,
+            'title' => 'content item 1',
+            $this->fieldName => [
+              'tid' => 2,
+              'name' => 'APPLES',
+              'description' => 'bar',
+            ],
+          ],
+          'row 2' => [
+            'id' => 2,
+            'title' => 'content item 2',
+            $this->fieldName => [
+              'tid' => 3,
+              'name' => 'BANANAS',
+              'description' => 'bar',
+            ],
+          ],
+          'row 3' => [
+            'id' => 3,
+            'title' => 'content item 3',
+            $this->fieldName => [
+              'tid' => 1,
+              'name' => 'Grapes',
+              'description' => NULL,
+            ],
+          ],
+        ],
+        'pre seed' => [
+          'taxonomy_term' => [
+            'name' => 'Grapes',
+            'vid' => $this->vocabulary,
+            'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED,
+          ],
+        ],
+      ],
       'lookup single existing term returns correct term' => [
         'definition' => [
           'source' => [
@@ -775,6 +976,9 @@ public function display($message, $type = 'status') {
    *   The storage manager to create.
    * @param array $values
    *   The values to use when creating the entity.
+   *
+   * @return string|int
+   *   The entity identifier.
    */
   private function createTestData($storageName, array $values) {
     /** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */
@@ -783,6 +987,7 @@ private function createTestData($storageName, array $values) {
       ->getStorage($storageName);
     $entity = $storage->create($values);
     $entity->save();
+    return $entity->id();
   }
 
 }
diff --git a/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate/process/EntityLookupTest.php b/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate/process/EntityLookupTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..8793609cfdf095cc9693f821814095696d81f3aa
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate/process/EntityLookupTest.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Kernel\Plugin\migrate\process;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\Plugin\MigrateDestinationInterface;
+use Drupal\migrate\Plugin\MigrationInterface;
+use Drupal\migrate\Row;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+
+/**
+ * Tests the entity_lookup plugin.
+ *
+ * @coversDefaultClass \Drupal\migrate_plus\Plugin\migrate\process\EntityLookup
+ * @group migrate_plus
+ */
+class EntityLookupTest extends KernelTestBase {
+
+  use UserCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'migrate_plus',
+    'migrate',
+    'user',
+    'system',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->installSchema('system', ['sequences']);
+    $this->installEntitySchema('user');
+  }
+
+  /**
+   * Lookup an entity without bundles on destination key.
+   *
+   * Using user entity as destination entity without bundles as example for
+   * testing.
+   *
+   * @covers ::transform
+   */
+  public function testLookupEntityWithoutBundles(): void {
+    // Create a user.
+    $known_user = $this->createUser([], 'lucuma');
+    // Setup test migration objects.
+    $migration_prophecy = $this->prophesize(MigrationInterface::class);
+    $migrate_destination_prophecy = $this->prophesize(MigrateDestinationInterface::class);
+    $migrate_destination_prophecy->getPluginId()->willReturn('user');
+    $migrate_destination = $migrate_destination_prophecy->reveal();
+    $migration_prophecy->getDestinationPlugin()->willReturn($migrate_destination);
+    $migration_prophecy->getProcess()->willReturn([]);
+    $migration = $migration_prophecy->reveal();
+    $configuration = [
+      'entity_type' => 'user',
+      'value_key' => 'name',
+    ];
+    $plugin = \Drupal::service('plugin.manager.migrate.process')
+      ->createInstance('entity_lookup', $configuration, $migration);
+    $executable = $this->prophesize(MigrateExecutableInterface::class)->reveal();
+    $row = new Row();
+    // Check the known user is found.
+    $value = $plugin->transform('lucuma', $executable, $row, 'name');
+    $this->assertSame($known_user->id(), $value);
+    // Check an unknown user is not found.
+    $value = $plugin->transform('orange', $executable, $row, 'name');
+    $this->assertNull($value);
+  }
+
+}
diff --git a/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate/process/FileBlobTest.php b/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate/process/FileBlobTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..f681628b12e1434a6ecb325002706de104128619
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate/process/FileBlobTest.php
@@ -0,0 +1,224 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Kernel\Plugin\migrate\process;
+
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\Row;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+
+/**
+ * Tests the file_blob plugin.
+ *
+ * @coversDefaultClass \Drupal\migrate_plus\Plugin\migrate\process\FileBlob
+ * @group migrate_plus
+ */
+class FileBlobTest extends KernelTestBase {
+
+  use UserCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'migrate',
+    'migrate_plus',
+    'system',
+  ];
+
+  /**
+   * The process plugin manager.
+   *
+   * @var \Drupal\migrate\Plugin\MigratePluginManagerInterface
+   */
+  protected $pluginManager;
+
+  /**
+   * The blob representation of a cat image.
+   *
+   * @var string
+   */
+  protected $blob;
+
+  /**
+   * The sha1sum of the blob.
+   *
+   * @var string
+   */
+  protected $sha1sum;
+
+  /**
+   * The filesystem interface.
+   *
+   * @var \Drupal\Core\File\FileSystemInterface
+   */
+  protected $filesystem;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->pluginManager = $this->container->get('plugin.manager.migrate.process');
+    $this->filesystem = $this->container->get('file_system');
+    $this->sha1sum = 'a8eb2b9a987cfda507356d884f0498289bd0f620';
+    $this->blob = <<<EOT
+/9j/4AAQSkZJRgABAQEAYABgAAD//gA7Q1JFQVRPUjogZ2QtanBlZyB2MS4wICh1c2luZyBJSkcg
+SlBFRyB2NjIpLCBxdWFsaXR5ID0gNjUK/9sAQwALCAgKCAcLCgkKDQwLDREcEhEPDxEiGRoUHCkk
+KyooJCcnLTJANy0wPTAnJzhMOT1DRUhJSCs2T1VORlRAR0hF/9sAQwEMDQ0RDxEhEhIhRS4nLkVF
+RUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVF/8AAEQgBLADI
+AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMF
+BQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkq
+NDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqi
+o6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/E
+AB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMR
+BAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVG
+R0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKz
+tLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/aAAwDAQACEQMRAD8A
+jA4pwFCqRUgHNcZ1iAcVIKQU8CmAoFOFA6UUCFpcUmaWgBRS0lKAaBC0UlGaAHZoBqMnilDcUASi
+jFIDTqAExSGnU00AMYCmECnnrSEUhkeKYVqU1GxpDGEU04FDnFNGKB3ExminZzRQFyQJTvLqUKKf
+5Z28VRNyALSqM5qTbgc0BMHPrQK5HjBxT9ppzJnFOAxQFxm00u2pNooI4oC5GBTsUxnxxmhHycZo
+AcaaalxUbjFMCMkmnAetN46U4ZFAEg6/Sl3UzPNAPrQBJnimg45NNJ4psjYAFIBVGcn1odto96QE
+KuSelQNNk5NAx5baMmoWlCjc3WoXuCzBYgXY9hU8WjzSnzLuTywf4R1qlFshzSKTXKl/vU8TA8J+
+Na0Ok2IPyozVK2iwuuI12/Sq5UR7RmRvB78UVNNpT278nIoqGrGik2XhtK80udn+7WbBfo4wGq1H
+cCRSpNKwFn5WFMHDbarLcqpKMcGmy3AU9eaYi2SAcVG8oXBNZz6gAcZqJ71WXOeDTsK5rLcruxmm
+PcgZGawheHf14oluz0znNPlDmL8t3jnPNNjvOeTWS05YAk8CkabaSN3U07C5joor0OcE81JcXAVO
+DXNJc+W5Oc06W9aQgZpWHzG3Hcru5PNSC5UmudW6KjmpftbHPPaiwuY3knDE8055wuBmsKK+2t1z
+Tnu9zcnAosHMbnnAgCoZZwZVGayf7QAGF5pyJc3DKUUgE0coc5durwKdgNOtLOe/O7Plxd2NTWml
+RxOZbg+Yw7dhWzBDJMBlRHEOnqapIhybIbe1gtFCwrub+8amSAzuWfO0d/WppXtrdP3jgAfwg9aq
+3F8hh3SN5UA/An2FVbuSSPIF+SDBxwW7CljZ85Zh9axpdbjCnyUCqOmaoNqzzyqjSEg9hUtlJM6G
+6vIlO0fO/wCgorHWbfIeKKzbNYxVik9pHvymUb26U1/tEOGQ7wPSrE6bDxn2zVLz2RuuM+tURchl
+vnEmXBFNm1AyJweRVhniuUKSKAR3rFnjeByP4e1UlcltlvzzIMmk3vgjqKggkwfm6VckACZU9uad
+hXI0DHkUBmGajjmxle+aWVynXvQAgfP9ajLlnGaieTaPY1HJMARjvTAtq+0nPbrTwSWAHU1TWXIy
+asxn+IHHFIYr53EE1JEpZapSSEuTnqKmilJUZoESM+xqUbpeB9KYy72BParlsFjbJ54oA0rCxgto
+t8/zP1Aqy1/821cKnYCsgXbPKxycAUiyl5AfSkwSOkhvYYkDzHPovrSS6pcXRwh8tB29vesMXCRc
+/efuarT309x+7j+VfQUXK5TWmvIYWySZ5u3PArLu76aZyZGLHsB0FJHavFy/3sc0xzufYqlm9BSG
+kiFVeRt8pOOwq9Zqpl3Yy38qqzI0WPOyWP8ACK09OhYnLDaMdKlspFm0iYsWPrRV+KMKuBRUM0Wx
+iRSOqbS28Dpmqlwck1IkrluFwfTtT2XzFJAGe4rYwKSNknBNSSKsyDIp0cY/jGOatRRK5wpA+tNE
+sy1tsHp0p0xKIO3H51pvEEHrWXfkjtxTEUDJh8mrLfvkA9utUA2489M81fjwIwc8dKYyvP1wKpyt
++8CjGaszSBiRVV1DTL9KEBZTcE6ZqwjZQgdRUDy7IgF71LEp259aGBDMxUY70+BsYyc1DMSW/wAK
+bGWY5zgelAGtEQRuPJ96mDYHHU1XRsoPYVZjYMOe1QMVYh5ZGfcmhj5a5FDuQCTgAdqF+Yhn5pDI
+vInmwqZGeSa0ra2SyjDODJKegA6VJbgyDCLx6+ta0a29ogkmIZ+wNNA2Z1tpN7qLeZM3kRfqamum
+sNIhKRkPJ3aoNS1yeUeXAPLjPU9zWAyy3MmBGx9z3pNroNK+5YN758vyJgn+JutbumREJknJNY1p
+psnnAla6i2i8pADxWbNNETqmBRUqlSOooqR3Ofktyv8ABkVVeNVYstXJJpNxDrx6iqj8scEg/wA6
+3OciYhgQPvURtsBwfm9KQ/KckZoDg5yARTQiwxMsWWyuO1ZN6cg7jz7Ve8wqMFj0qleoNuRkiqEY
+z/Iwx0PWr0GXjxWbM2DirFtdbByaYx1xEVbPrVebIcY6gYq1dyrImVPJNUrhiMYH40ASFswgnjFX
+bRy0eSeKy2lyqgfjV5SsduQDzihgMncgkLgDuabDKCm0D8aqvIzcZ4NPhB4x3oA14ZMR5H61J5hU
+Aj9Krj7gUdh2pcnbk8AVmUWPN3cH86licM3JqkTheP1qWOTCZ6e9IZsQ3QTCqOfSpgySvmR/61kx
+y45P6VYifqQOTSGaqJaq2fL3k/3quQrCOI4VyfasqKZIRl8ZNRT626ZS3AB9aQWOibbAoZ2RB6Ac
+1TOpIWwIyfTPFZENy8qkyuWY9zTCzGXCk07hym9HIzHcSB7Ciq9oJCBmipZaRFJ8jEnkehqrJiQE
+g9PelncEZDDFQqSp4NamBFI+Bgnk0wZxkjcKkkG85wc1XYNG2DkGqAemevO30NMusbODgY71J9oA
+jCsM1n3EpZSFBxVCMy4AL9aZ5DAbh9aZPIQ2Ca2oIxLZ8DJ29aBmXAS24HpT51zIemDSBGjkcdxR
+KcYPr2pAVmBLqnHBp8shBKZpG5YNTSFaQ5NADlj/AHQJGTSxygMM9qtXUXlWQI6Y61lQ5Z8ULUZs
+RS4HJ4PpUjvkjceB0ApqWbmESHAA/SqbEiTr8oqbAXydxCoMCnjLH2FVYpcYwB7k1chwRmpZaJFy
+PpT/ADiO+MdzTcg8A0rw8deKgYx5JGBIYkVD5mOpyamdTt2jpVZyqdeaYy7bzEDk/lVu1l/eZIrI
+jlBPTmrkDNvGKVgbOptpPlHFFUbadlXmipaKTKvldMHP1p4jdedpI9qXax/hOBTi7AYOR9K2Ocqz
+cD0x61l3MzFsDJrZ5cEEk/hVa4tkC5HB96pCMITSI+dxBrZtVS5g/hD46VQdOofGDwKLOQ21wCMs
+nQg9KYGfqUBjnIPWt7w9bNPbNk8L2NVNUtjdYkgjJ9dvNaWhNJa2JEqEc8Z4ovoDM3UrZorrpxjJ
+rMmzxnmurvI0uGPqcGsDUbcQA+9IaM/P7s4HFS29v5jKSOO9ECDIGeGrRhiEbegpN2KSEvo/+Jft
+/wA4rEs0/wBIAxxmujkiN03koD8/GPSoZ9Gj0qPzp5BuJwq+tSpWVhtBfTiOFYY/mbHQVjgEk559
+aLq/Lswj5B6k1VE0g6k4+lWk7Es17ddy8ICB3PSriKNvUE98Viw3xHDKGH61o29yHPGQKmSZSZbj
+XDcA5PerO3IwCKjRkx8x/AVPE6HhTj1rMopzhlGBxWe4JOTW7JHGwIHJqk1tz04prQCrAjEgmtW3
+UdT1qBLck56CrsUW3gUrgSGbavHSioblNqZzxRSGaqq6delRyxeZwGx9KfLcrtweazJtTWDgNW9j
+An5t25INZd/qSLuy2T6AVWu9aEnyqDk1TuIi6xBuGkPPrimkBUmvp52ITOP9kf1piSXkJyPMWu2s
+dP07T/D0t3I0ckzfKkR6g+tc2dUXzQpCnccdOlaONiIz5thdL8QSWt1GZkDKD81emTaXBqGnLLCo
+AZdwxXmGp2UfliaLAPfFd18Pda+16cbKZsyQnAye1RYvcyGEkE2yT72duD2qrqtuZdhA4710viiz
+W2uI7qMfe4IrIRWlhJYDd2qJDRgQw7WGV68irUuNijoat+RsQMcZFU9RcbQFPbgio3LNrw7bB3ed
+gfQVheOLjdqscCn7i9Pc11GiMkGlKzdcZNefazd/adcnlY5G7A+lENZjloixZ2cUcLTTjJAzzTIJ
+Y55GQoFHalZZLm1IQnp2NUbSGRblQ3GK2Mi7e2arHvT7w9KhjEjRbouvcVevB5dsWdhx0qHSl3Ix
+PTBNJ7XHErJdT9CatxXbgAEkUwxjJIApoX5srzU6MrU0Ybp+n86txymUgVmIhUAsOewNXbbf9BUM
+ZpxQADJNI8mwmlgOV5NSNEGFSykZd7fbY8EUUt9bIoOTRTVg1K1zcuXI3H86otvlGCTWjLbnyiwG
+D71VQFWyRWxiNs7ENcoZORnmrGpN9nvEDDCr0+lOjuEjdTkD1q5qUMWpWgmiI3qMY9aE7MGMlRbi
+2/dtwR61zttpsr3mCPlVupqWO7uLMFOSn6intrEhXCLz7DmrfkQk0WNWmWGIRKcnvTfCF9JZawHQ
+kAjBqOz0TU9cm/dQOV7nHFaU2mSaGypJGqtjk5zk0nbYtI7jWZje6VkkbsA//qrCtwyhTgbQemet
+RaTrn2m2mS4Vc42qOgAFSear7VQD6Cs/JjI7xgQAMYz2rLuYd4DjB56VqXFrNkYHy9eapz5hGCMA
+etQykXy3l6SDISqIPmI64rg7uPzbl5YuUJ4robzVnFi1uVDK3FVdFigN4nngFD6miPu3ZT10Mq3u
+3tjtYEVZ/tGMfMF59a7W90bQ5U3zSJET74rnrrStIic+XeoQO45q1NPoRy2MKWeW9kAOdvp61tQr
+9hsiXwHftnpTC2nWakwt58nbA4rNuLmS4fMjY9FHam7yBaFhpw2cVPax72yen0qggJ+7WnZo54AJ
+FS9BouiNDjJzipGwo+UGpEgyRgc1bjtCfvVncorQMfTmrDuyR5PWrEdoFOabdKqoc4FIaOevJXlJ
+zRTL24UMQtFWloBvmA7Qp/I1Ru7NMEBtprpFtFcA8ioLrTY3U7Tlq1MLnDzxsjYznFOt72W3P7sg
+A9cirt9aNC5AGazmVVf5s/hTGbEV5p8w/wBMtmyerKOtXrfUtAsRvTTnllHQPjFc35nQEADsPWrc
+MLyEDYcHucDFLYDs7b4gxRoUGnqgA42muV1bVn1y8aR0Yc/8BUU6OwRCQSM+uOlWWtY1iODjPYd6
+NwMqxUQbyeAOldJpzIse9jknmsRrZoosuc57Yq5YmSRNsfGBzRYLmvcXkWzbnB7Vh3rq+RnnrVt7
+VUceYxbPJIrNvLcByQ+FJ61DSKRkzMHJU8470xQVYMo/I1KqjzSMgnpzU6xiPJ2k+1VewblWZJJc
+ZJ9hmoTCyn5xVt5GLnCAD3NW4raKVAd2T6Glew7GPhh90Z/CkCMzc4rSmtChJUfhmq8cZWQZGPYU
+7isSW0DORgZA9a2YwsS8hfwqgDIq/IuKmjEjY3Nk/Ss3qWi/HOCflGRV6KXJHNZ8MR75zVjY4Ixm
+oGXHm2Dhqx7+5ZsgMTWmqnZ8wNZN/E5zjgUDsZUsLNli4oqvOsoJAziitUI9CkudpySfwqpLqUec
+Nu/Grstskg65rPl04Z74qzAgkuIZQQoH/fPSsa9SEZKqCfrWvJp5xhnbHoKZ/Y8bcyOQPQjmkMwE
+jJYMi8/3jWtZ+YV2gFj6VdFhGq4jTd/vVo6WnlvtMSfUDmpvdjMeezvGOTC20Dt2pIP3exWRg7Gu
+hvvN2lIlZMc8DlqhtZeUE1q5A6ZXNO9hEd/pEk2nGS3yWA/OuZW+ewVkKkNnkNXqllc20kYQqVIH
+TFcz4l0SC7LSRKFcd/Wi9ikr6HKNq7SqPkxjk4NULvUHZgPugdqgn3WbtGQQQe9SafavqF2CEJUd
+TS21Lsti1pOmz6jchwpWMdW9a1r/AEicIBEp475robKJbO2AiiBYD0qG6S9ugVIEakcYrJzbdyrW
+RzcWihnH2mUD1FacOi2kMe6K4O7HQ96b/YhaTMkueOatQ2UUIVfM3EfjQ5N9RWKpstw5x9cVA1os
+bZKAj1rXbKjGQB9Krupx8oVqkZR8uN+ij8qmjhjXBCDNP284YYp6Rge/1piHAgD5VFSIrHqAKQYH
+A4NQTSyjo1AFiXCrway7qRmyABT2mdvvVVmVmPANOwzNuFOTkiirRgJPzLRVhc7WNVK/L+dL5LN0
+z+NVYZmKjgD6VcjYkc/NWyaOZoha32kkEE1CyIv3mBPoK0Xt2ljzuUj+6DUkmlT+WuI9q4zhepqX
+YFcyANzYCgH0xVqBDERtfLH+FFyaa8DRMyiHJHUE9P8AGhJGxt6t/dHAH1pFGrAZnTGYx6A9aY8k
+sb4MaMO+KoxuC+UkJfpx0qRxLtx60MC+dQSPC+T+NZepag0qkIoUUx1ZOTk4rOubhgpCqetZyuaR
+3Oe8QBZ0LsgVl6Y71Pokqx2oRAASM064t/tHL/lTYLfyOU4xRdWsa26m/FdyKuA340ySRnIDzEH2
+qpavKxwRV+O33cmosK4QwRL8zSE596fsRWIUBT79DS+SEIY9O4ol4XI7DjPcUWJuNcn1x7HkVCYw
+Tk/KfUdKPN7g49aRZVJ4IVv0NFguP24GGAx600xoenBpVlXOD8p9O1DxkDI6UxFeRHBqEo/c5FTN
+KUPtQsm88cH0oGMjt93U4qcWeB0zU0Meecc1fig3irSE2YzWo7iit/7DntRWliLmOgdSArZFb2lQ
+o7L5hBFcxBMrfMj59q2bGZi4wcVnIEdlHDDGAUA/KpZC03yoKp2J8xBk5rRidEcCp3GUrjSo3AaQ
+Zb2pYNIt1jJEagt1PrWtKFIzREm5BxxVIDn7jSog+5ECt64qCXT2AwgDcdK6aSIbarxQBic80J2F
+Y5g2DFT5i7f96su8035tpGBXbT2wc4YVWudKVgCOoFN6gtDiJdPEcfA5NRwafyS3Oa6K506Z3CIh
+Iqs1nJbnMgxis2i1JmULZImOBin+aq8H86mnicjcCMfSsuSUI+0uD7UguXGkByM5Bqs0hwV9OlQF
+gWyrfrTydxBpgUXmeKVhnIB/SonuOQV6Gpbq3Pm7gfrUcdockHoaoRYhm8wAOfofSrCzyQHDDcpq
+qsQj61PFImdrcqaAHTAEb0PymoUGW44q+kKg4HKN1qX+zFRgyvwadguLaKzEKT+Nb1rZ5AOeayoI
+NjDDCt+xztGTVwRLJ0thjpRWhGgIBHWit7EHk8mnvajcASPUVpaRcH+I/nThfQudrkc1LEkJOUI5
+9K46ivsaxfc6vSXLR59a0/IJYNnGKytHwsYGa3cgoKmKuht2ZPGPMUA9BVqLAUgCmW0YENIG2uRW
+qViBJUJOBT4EVeD1qZV+XJ71WZsS8Gm9ACeMFwAKe1v8lPYYAY9alRgy5ppAZht8NnFIdPjmB3KD
+WkYwahOUOKm1gMW40mBvlKg1gan4Yt3y8ce1/UV2vlb3yajntR1xUtMaZ5Lc6NdxSHEbYFRossYw
+QTj1FeqS2CSj7tVf7GhdvmQGhDueYXEhZyQuahE7j+E16bN4etlbcI1/KsXVfDpkG6IAEUMEcY1x
+8wJU81YQLIAV61pHQboAbkBAqE2EkTYK4NIYkW4LjOfSn/bHVShB4qPEiSAEcVIYwzA55ppsGhYr
+t1kGeh6V0enXBIHFc+tuFwD2PFbmnkAAelXF6kM6W3YkCimWr8DkUV0Ig8cEM6vh/mrb01WOOtMf
+Yz5AANaulQgnpXHUZrE2tMdo1ANbKSlsGshAUfpxWtYjeeaiD6Dka9pIfK5pgLPNx0pobYdtXIIx
+sz3re1yCYH93+FUgp88fWp/MIYqaesfGe9N6gLKMx4qKFmziptwIxQigDIotdgSUxkBphl2Ng05J
+N1O9xETfI1Mll34Ap102FzUMClzms3vYotJENnNV3Xa3FWgwAxTSgbmqaEQCPf1qKe3XbyKtghTi
+orhsilbQDNNqGHSqUmkJK+SK3Yo8rzSFQrUrDuc9PoURX7orNbw8u/cAa7N1BFM8lSOlFguca+mF
+GGPpU9tYspropLVSelRrbhW4ojowZHbW5Uc0Vb2kCitudE2PI5FdSCK6XQ4iYwTVCKxM0uMZrobG
+3FtGBXJe7NNkWbhQseR1FW9IYsMmq4QyECtKCEQqAOKqMdbib0sTtkzLitNDtQCqFuu6TmrUpKit
+okMY/MwxVwfdqtCu/wCY095tvFNaAQ7j5hFWEbHBqCMb5M0TEocikMbcctxT0yig1ACXYVYcgR4p
+eYEM778AVZt49sdVIYy75PSr24IuKF3ArSPtkxmrCEbM1Sk+aXNSSTBI8UIBs0wV6VT5gzWdJJ5k
+vWr8K4SpTuBaTAXioZB81Kj84pzjvVMCP+GmgmnjnigrikAeXkVA67Wq0rcYqF13PRYBuNy0VOI8
+LRRYDirCLbLtxxWnNbkICo5qS3tlVskVPcLheCMVCjoO+pHp8DM2XXGKuTKQwxTdNJVTuORVwojv
+watR0FfUW1Qqd1SXLZXipSoSPioFIc1TXQRLb8RVXlyZcCpy2wYqNcFs0MESKQgzUM8oYU+RuKp4
+Lvik30GT29LKxzgVJHGEWmlNzZosBNbphc1HM2Gp4kCjFVpSXbjpQwHds1RuJTnFXQQFxWfOu6Ti
+k0AkS/Pk1qRsPLqlHFhcmpo2ycUJWAkXO+rJGUqIJjmn7vlxVCGLwae3So+hqQHNIZCzbadF8xps
+g5p8QxQgLIA20Um8AUVQjKeDapqj5hMojrUmPyVlooN4D71EhoveV5SjbxmnQM3mcmi6YhRSW3Iy
+afURoSSDy8VFbLlzUUh+WpLPjmq6h0JbgbRmoEYnkVYuv9XUFsPkND3Aaz80sQBfNQyH5zU0HrUo
+B8r7TUsWGXNVputWLc/JTQEE7bTSxruXNMuhlqWIkLR1AguHCGq0bh5Kde1Bb9anqM0mYeXxUMDZ
+kprsdtFt96qYkaZHy0wdaUE7aYDzQA9uKZvxT3+7Vcnmkxjt25qlHAqJRUjH5aADfzRUGfmooA//
+2Q==
+EOT;
+  }
+
+  /**
+   * Tests file creation.
+   *
+   * @covers ::transform
+   */
+  public function testFileCreation(): void {
+    /** @var \Drupal\migrate\MigrateExecutableInterface $executable */
+    $executable = $this->prophesize(MigrateExecutableInterface::class)->reveal();
+    $row = new Row([], []);
+    $value = ['public://cat.jpeg', base64_decode($this->blob, TRUE)];
+    /** @var \Drupal\migrate_plus\Plugin\migrate\process\FileBlob $file_blob */
+    $file_blob = $this->pluginManager->createInstance('file_blob');
+    $file = $file_blob->transform($value, $executable, $row, 'destination_property');
+    $this->assertEquals('public://cat.jpeg', $file);
+    $this->assertEquals($this->sha1sum, sha1_file($file));
+    $configuration = [
+      'reuse' => FileSystemInterface::EXISTS_ERROR,
+    ];
+    /** @var \Drupal\migrate_plus\Plugin\migrate\process\FileBlob $file_blob */
+    $file_blob = $this->pluginManager->createInstance('file_blob', $configuration);
+    /** @var \Drupal\migrate\MigrateExecutableInterface $executable */
+    $file = $file_blob->transform($value, $executable, $row, 'destination_property');
+    $this->assertEquals('public://cat.jpeg', $file);
+    $this->assertEquals($this->sha1sum, sha1_file($file));
+  }
+
+}
diff --git a/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate_plus/data_fetcher/HttpTest.php b/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate_plus/data_fetcher/HttpTest.php
index 76a5c1847469f889ec1b175e2ff0827ba69107f5..c3ee25e785f082dc80d4b2aa1e588487465e5253 100644
--- a/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate_plus/data_fetcher/HttpTest.php
+++ b/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate_plus/data_fetcher/HttpTest.php
@@ -18,7 +18,7 @@ class HttpTest extends KernelTestBase {
    *
    * @dataProvider headerDataProvider
    */
-  public function testHttpHeaders(array $definition, array $expected, array $preSeed = []) {
+  public function testHttpHeaders(array $definition, array $expected, array $preSeed = []): void {
     $http = new Http($definition, 'http', []);
     $this->assertEquals($expected, $http->getRequestHeaders());
   }
@@ -29,7 +29,7 @@ public function testHttpHeaders(array $definition, array $expected, array $preSe
    * @return array
    *   The test cases
    */
-  public function headerDataProvider() {
+  public function headerDataProvider(): array {
     return [
       'dummy headers specified' => [
         'definition' => [
diff --git a/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate_plus/data_parser/JsonTest.php b/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate_plus/data_parser/JsonTest.php
index 2fb4f2458dd4b937e75233dc642e90f6468a5939..1216601a915be64eb352713bd363f4fcd6dae1f9 100644
--- a/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate_plus/data_parser/JsonTest.php
+++ b/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate_plus/data_parser/JsonTest.php
@@ -11,6 +11,9 @@
  */
 class JsonTest extends KernelTestBase {
 
+  /**
+   * {@inheritdoc}
+   */
   public static $modules = ['migrate', 'migrate_plus'];
 
   /**
@@ -30,7 +33,7 @@ class JsonTest extends KernelTestBase {
    * @throws \Drupal\Component\Plugin\Exception\PluginException
    * @throws \Exception
    */
-  public function testMissingProperties($file, array $ids, array $fields, array $expected) {
+  public function testMissingProperties($file, array $ids, array $fields, array $expected): void {
     $path = $this->container
       ->get('module_handler')
       ->getModule('migrate_plus')
@@ -66,7 +69,7 @@ public function testMissingProperties($file, array $ids, array $fields, array $e
    * @return array
    *   The test cases.
    */
-  public function jsonBaseDataProvider() {
+  public function jsonBaseDataProvider(): array {
     return [
       'missing properties' => [
         'file' => 'missing_properties.json',
diff --git a/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate_plus/data_parser/SimpleXmlTest.php b/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate_plus/data_parser/SimpleXmlTest.php
index fe6b345e1dfd53383924f7cf0b4ded3e75203ccc..0b93f3a054d024998097001b6afd7a61b8df9cd5 100644
--- a/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate_plus/data_parser/SimpleXmlTest.php
+++ b/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate_plus/data_parser/SimpleXmlTest.php
@@ -3,6 +3,7 @@
 namespace Drupal\Tests\migrate_plus\Kernel\Plugin\migrate_plus\data_parser;
 
 use Drupal\KernelTests\KernelTestBase;
+use Drupal\migrate\MigrateException;
 
 /**
  * Test of the data_parser SimpleXml migrate_plus plugin.
@@ -11,30 +12,54 @@
  */
 class SimpleXmlTest extends KernelTestBase {
 
-  public static $modules = ['migrate', 'migrate_plus'];
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['migrate', 'migrate_plus'];
 
   /**
-   * Tests reducing single values.
+   * Path for the xml file.
+   *
+   * @var string
+   */
+  protected $path;
+
+  /**
+   * The plugin manager.
+   *
+   * @var \Drupal\migrate_plus\DataParserPluginManager
+   */
+  protected $pluginManager;
+
+  /**
+   * The plugin configuration.
    *
-   * @throws \Drupal\Component\Plugin\Exception\PluginException
-   * @throws \Exception
+   * @var array
    */
-  public function testReduceSingleValue() {
-    $path = $this->container
-      ->get('module_handler')
-      ->getModule('migrate_plus')
-      ->getPath();
-    $url = $path . '/tests/data/simple_xml_reduce_single_value.xml';
-
-    /** @var \Drupal\migrate_plus\DataParserPluginManager $plugin_manager */
-    $plugin_manager = $this->container
+  protected $configuration;
+
+  /**
+   * The expected result.
+   *
+   * @var array
+   */
+  protected $expected;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->path = $this->container->get('module_handler')
+      ->getModule('migrate_plus')->getPath();
+    $this->pluginManager = $this->container
       ->get('plugin.manager.migrate_plus.data_parser');
-    $conf = [
+    $this->configuration = [
       'plugin' => 'url',
       'data_fetcher_plugin' => 'file',
       'data_parser_plugin' => 'simple_xml',
       'destination' => 'node',
-      'urls' => [$url],
+      'urls' => [],
       'ids' => ['id' => ['type' => 'integer']],
       'fields' => [
         [
@@ -50,18 +75,7 @@ public function testReduceSingleValue() {
       ],
       'item_selector' => '/items/item',
     ];
-    $parser = $plugin_manager->createInstance('simple_xml', $conf);
-
-    $data = [];
-    foreach ($parser as $item) {
-      $values = [];
-      foreach ($item['values'] as $value) {
-        $values[] = (string) $value;
-      }
-      $data[] = $values;
-    }
-
-    $expected = [
+    $this->expected = [
       [
         'Value 1',
         'Value 2',
@@ -70,7 +84,121 @@ public function testReduceSingleValue() {
         'Value 1 (single)',
       ],
     ];
+  }
+
+  /**
+   * Tests reducing single values.
+   */
+  public function testReduceSingleValue(): void {
+    $url = $this->path . '/tests/data/simple_xml_reduce_single_value.xml';
+    $this->configuration['urls'][0] = $url;
+    $parser = $this->pluginManager->createInstance('simple_xml', $this->configuration);
+    $this->assertResults($this->expected, $parser);
+  }
+
+  /**
+   * Test reading non standard conforming XML.
+   *
+   * XML file with lots of different white spaces before the starting tag.
+   */
+  public function testReadNonStandardXmlWhitespace(): void {
+    $url = $this->path . '/tests/data/simple_xml_invalid_multi_whitespace.xml';
+    $this->configuration['urls'][0] = $url;
 
+    $parser = $this->pluginManager->createInstance('simple_xml', $this->configuration);
+    $this->assertResults($this->expected, $parser);
+  }
+
+  /**
+   * Test reading non standard conforming XML .
+   *
+   * XML file with one empty line before the starting tag.
+   */
+  public function testReadNonStandardXml2(): void {
+    $url = $this->path . '/tests/data/simple_xml_invalid_single_line.xml';
+    $this->configuration['urls'][0] = $url;
+
+    $parser = $this->pluginManager->createInstance('simple_xml', $this->configuration);
+    $this->assertResults($this->expected, $parser);
+  }
+
+  /**
+   * Test reading broken XML (missing closing tag).
+   *
+   * @throws \Drupal\Migrate\MigrateException
+   */
+  public function testReadBrokenXmlMissingTag(): void {
+    $url = $this->path . '/tests/data/simple_xml_broken_missing_tag.xml';
+    $this->configuration['urls'][0] = $url;
+    $this->expectException(MigrateException::class);
+    $this->expectExceptionMessageRegExp('/^Fatal Error 73/');
+    $parser = $this->pluginManager->createInstance('simple_xml', $this->configuration);
+    $parser->next();
+  }
+
+  /**
+   * Test reading broken XML (tag mismatch).
+   *
+   * @throws \Drupal\Migrate\MigrateException
+   */
+  public function testReadBrokenXmlTagMismatch(): void {
+    $url = $this->path . '/tests/data/simple_xml_broken_tag_mismatch.xml';
+    $this->configuration['urls'][0] = $url;
+
+    $this->expectException(MigrateException::class);
+    $this->expectExceptionMessageRegExp('/^Fatal Error 76/');
+
+    $parser = $this->pluginManager->createInstance('simple_xml', $this->configuration);
+    $parser->next();
+  }
+
+  /**
+   * Test reading non XML.
+   *
+   * @throws \Drupal\Migrate\MigrateException
+   */
+  public function testReadNonXml(): void {
+    $url = $this->path . '/tests/data/simple_xml_non_xml.xml';
+    $this->configuration['urls'][0] = $url;
+
+    $this->expectException(MigrateException::class);
+    $this->expectExceptionMessageRegExp('/^Fatal Error 46/');
+    $parser = $this->pluginManager->createInstance('simple_xml', $this->configuration);
+    $parser->next();
+  }
+
+  /**
+   * Tests reading non-existing XML.
+   *
+   * @throws \Drupal\Migrate\MigrateException
+   */
+  public function testReadNonExistingXml(): void {
+    $url = $this->path . '/tests/data/simple_xml_non_existing.xml';
+    $this->configuration['urls'][0] = $url;
+
+    $this->expectException(MigrateException::class);
+    $this->expectExceptionMessage('file parser plugin: could not retrieve data from modules/contrib/migrate_plus/tests/data/simple_xml_non_existing.xml');
+    $parser = $this->pluginManager->createInstance('simple_xml', $this->configuration);
+    $parser->next();
+  }
+
+  /**
+   * Parses and asserts the results match expectations.
+   *
+   * @param array|string $expected
+   *   The expected results.
+   * @param \Traversable $parser
+   *   An iterable data result to parse.
+   */
+  protected function assertResults($expected, \Traversable $parser) {
+    $data = [];
+    foreach ($parser as $item) {
+      $values = [];
+      foreach ($item['values'] as $value) {
+        $values[] = (string) $value;
+      }
+      $data[] = $values;
+    }
     $this->assertEquals($expected, $data);
   }
 
diff --git a/web/modules/migrate_plus/tests/src/Unit/DataParserPluginBaseTest.php b/web/modules/migrate_plus/tests/src/Unit/DataParserPluginBaseTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..3aea50e80b302f5493bc43cbab1a133d8b4fc8fa
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Unit/DataParserPluginBaseTest.php
@@ -0,0 +1,99 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Unit;
+
+use Drupal\migrate_plus\DataParserPluginBase;
+use Drupal\Tests\migrate\Unit\MigrateTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\migrate_plus\DataParserPluginBase
+ *
+ * @group migrate_plus
+ */
+class DataParserPluginBaseTest extends MigrateTestCase {
+
+  /**
+   * @covers ::nextSource
+   */
+  public function testNextSourceWithOneUrl(): void {
+    $parser = $this->getMockedDataParser();
+    $parser->expects($this->once())
+      ->method('openSourceUrl')
+      ->willReturn(TRUE);
+    $this->assertTrue($parser->nextSource());
+  }
+
+  /**
+   * @covers ::nextSource
+   */
+  public function testNextSourceWithoutUrls(): void {
+    $config = [
+      'urls' => [],
+    ];
+
+    $parser = $this->getMockedDataParser($config);
+    $parser->expects($this->never())
+      ->method('openSourceUrl');
+    $this->assertFalse($parser->nextSource());
+  }
+
+  /**
+   * @covers ::count
+   */
+  public function testCountWithoutUrls(): void {
+    $config = [
+      'urls' => [],
+    ];
+
+    $parser = $this->getMockedDataParser($config);
+    $parser->expects($this->never())
+      ->method('openSourceUrl');
+    $this->assertEquals(0, $parser->count());
+  }
+
+  /**
+   * Returns a mocked data parser.
+   *
+   * @param array $configuration
+   *   The configuration to pass to the data parser.
+   *
+   * @return \PHPUnit\Framework\MockObject\MockObject|\Drupal\Tests\migrate_plus\Unit\DataParserPluginBaseMock
+   *   An mock instance of DataParserPluginBase.
+   */
+  protected function getMockedDataParser(array $configuration = []) {
+    // Set constructor arguments.
+    $configuration += [
+      'urls' => ['http://example.org/data_parser_test'],
+      'item_selector' => 0,
+    ];
+    $plugin_id = 'foo';
+    $plugin_definition = [
+      'id' => 'foo',
+      'title' => 'Foo',
+    ];
+
+    return $this->getMockBuilder(DataParserPluginBaseMock::class)
+      ->setConstructorArgs([$configuration, $plugin_id, $plugin_definition])
+      ->setMethods(['openSourceUrl'])
+      ->getMockForAbstractClass();
+  }
+
+}
+
+/**
+ * Mock for abstract class DataParserPluginBase.
+ *
+ * This mock is used to make certain methods publicly accessible.
+ */
+abstract class DataParserPluginBaseMock extends DataParserPluginBase {
+
+  /**
+   * {@inheritdoc}
+   *
+   * phpcs:disable Generic.CodeAnalysis.UselessOverridingMethod.Found
+   */
+  public function nextSource() {
+    return parent::nextSource();
+  }
+
+}
diff --git a/web/modules/migrate_plus/tests/src/Unit/data_fetcher/FileTest.php b/web/modules/migrate_plus/tests/src/Unit/data_fetcher/FileTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..746f44a66ed51024aba26c82b3e6fb0aea067e07
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Unit/data_fetcher/FileTest.php
@@ -0,0 +1,186 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Unit\data_fetcher;
+
+use Drupal\migrate\MigrateException;
+use Drupal\migrate_plus\Plugin\migrate_plus\data_fetcher\File;
+use Drupal\Tests\migrate\Unit\MigrateTestCase;
+use org\bovigo\vfs\vfsStream;
+
+/**
+ * @file
+ * PHPUnit tests for the Migrate Plus File 'data fetcher' plugin.
+ */
+
+/**
+ * @coversDefaultClass \Drupal\migrate_plus\Plugin\migrate_plus\data_fetcher\File
+ *
+ * @group migrate_plus
+ */
+class FileTest extends MigrateTestCase {
+
+  /**
+   * Directory where test data will be created.
+   *
+   * @var string
+   */
+  const BASE_DIRECTORY = 'migration_data';
+
+  /**
+   * Minimal migration configuration data.
+   *
+   * @var array
+   */
+  private $specificMigrationConfig = [
+    'source' => 'url',
+    'data_fetcher_plugin' => 'file',
+    'data_parser_plugin' => 'json',
+    'item_selector' => 0,
+    'fields' => [],
+    'ids' => [
+      'id' => [
+        'type' => 'integer',
+      ],
+    ],
+  ];
+
+  /**
+   * The data fetcher plugin ID being tested.
+   *
+   * @var string
+   */
+  private $dataFetcherPluginId = 'file';
+
+  /**
+   * The data fetcher plugin definition.
+   *
+   * @var array
+   */
+  private $pluginDefinition = [
+    'id' => 'file',
+    'title' => 'File',
+  ];
+
+  /**
+   * Test data to populate a file with.
+   *
+   * @var string
+   */
+  private $testData = '[
+    {
+      "id": 1,
+      "name": "Joe Bloggs"
+    }
+  ]';
+
+  /**
+   * Define virtual dir where we'll be creating files in/fetching files from.
+   *
+   * @var \org\bovigo\vfs\vfsStreamDirectory
+   */
+  private $baseDir;
+
+  /**
+   * Set up test environment.
+   */
+  public function setUp(): void {
+    $this->baseDir = vfsStream::setup(self::BASE_DIRECTORY);
+  }
+
+  /**
+   * Test fetching a valid file.
+   */
+  public function testFetchFile(): void {
+    $file_name = 'file.json';
+    $file_path = vfsStream::url(implode(DIRECTORY_SEPARATOR, [self::BASE_DIRECTORY, $file_name]));
+    $migration_config = $this->specificMigrationConfig + [
+      'urls' => [$file_path],
+    ];
+
+    $plugin = new File(
+      $migration_config,
+      $this->dataFetcherPluginId,
+      $this->pluginDefinition
+    );
+
+    $tree = [
+      $file_name => $this->testData,
+    ];
+
+    vfsStream::create($tree, $this->baseDir);
+
+    $expected = json_decode($this->testData, TRUE);
+    $retrieved = json_decode($plugin->getResponseContent($file_path), TRUE);
+
+    $this->assertEquals($expected, $retrieved);
+  }
+
+  /**
+   * Test fetching multiple valid files.
+   */
+  public function testFetchMultipleFiles(): void {
+    $number_of_files = 3;
+    $file_paths = [];
+    $file_names = [];
+
+    for ($i = 0; $i < $number_of_files; $i++) {
+      $file_name = 'file_' . $i . '.json';
+      $file_names[] = $file_name;
+      $file_paths[] = vfsStream::url(implode(DIRECTORY_SEPARATOR, [self::BASE_DIRECTORY, $file_name]));
+    }
+
+    $migration_config = $this->specificMigrationConfig + [
+      'urls' => $file_paths,
+    ];
+
+    $plugin = new File(
+      $migration_config,
+      $this->dataFetcherPluginId,
+      $this->pluginDefinition
+    );
+
+    for ($i = 0; $i < $number_of_files; $i++) {
+      $file_name = $file_names[$i];
+      $file_path = $file_paths[$i];
+
+      $tree = [
+        $file_name => $this->testData,
+      ];
+
+      vfsStream::create($tree, $this->baseDir);
+
+      $expected = json_decode($this->testData);
+      $retrieved = json_decode($plugin->getResponseContent($file_path));
+
+      $this->assertEquals($expected, $retrieved);
+    }
+  }
+
+  /**
+   * Test trying to fetch an unreadable file results in exception.
+   */
+  public function testFetchUnreadableFile(): void {
+    $file_name = 'file.json';
+    $file_path = vfsStream::url(implode(DIRECTORY_SEPARATOR, [self::BASE_DIRECTORY, $file_name]));
+    $migration_config = $this->specificMigrationConfig + [
+      'urls' => [$file_path],
+    ];
+
+    $plugin = new File(
+      $migration_config,
+      $this->dataFetcherPluginId,
+      $this->pluginDefinition
+    );
+
+    // Create an unreadable file.
+    vfsStream::newFile($file_name, 0300)
+      ->withContent($this->testData)
+      ->at($this->baseDir);
+
+    // Trigger exception trying to read the non-readable file.
+    $this->expectException(MigrateException::class);
+    $this->expectExceptionMessage('file parser plugin: could not retrieve data from vfs://migration_data/file.json');
+    $plugin->getResponseContent($file_path);
+  }
+
+}
diff --git a/web/modules/migrate_plus/tests/src/Unit/data_fetcher/HttpTest.php b/web/modules/migrate_plus/tests/src/Unit/data_fetcher/HttpTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..6e5113e943fa57970380973493e97a793d20aab0
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Unit/data_fetcher/HttpTest.php
@@ -0,0 +1,244 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Unit\data_fetcher;
+
+use Drupal\migrate\MigrateException;
+use Drupal\migrate_plus\DataFetcherPluginBase;
+use Drupal\migrate_plus\Plugin\migrate_plus\authentication\Basic;
+use Drupal\migrate_plus\Plugin\migrate_plus\data_fetcher\Http;
+use Drupal\Tests\migrate\Unit\MigrateTestCase;
+use GuzzleHttp\Client;
+use GuzzleHttp\Handler\MockHandler;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Psr7\Response;
+
+/**
+ * @file
+ * PHPUnit tests for the Migrate Plus Http 'data fetcher' plugin.
+ */
+
+/**
+ * @coversDefaultClass \Drupal\migrate_plus\Plugin\migrate_plus\data_fetcher\Http
+ *
+ * @group migrate_plus
+ */
+class HttpTest extends MigrateTestCase {
+
+  /**
+   * Minimal migration configuration data.
+   *
+   * @var array
+   */
+  private $specificMigrationConfig = [
+    'source' => 'url',
+    'urls' => ['http://example.org/http_fetcher_test'],
+    'data_fetcher_plugin' => 'http',
+    'data_parser_plugin' => 'json',
+    'item_selector' => 0,
+    'authentication' => [
+      'plugin' => 'basic',
+      'username' => 'testing',
+      'password' => 'password',
+    ],
+    'fields' => [],
+    'ids' => [
+      'id' => [
+        'type' => 'integer',
+      ],
+    ],
+  ];
+
+  /**
+   * The data fetcher plugin ID being tested.
+   *
+   * @var string
+   */
+  private $dataFetcherPluginId = 'http';
+
+  /**
+   * The data fetcher plugin definition.
+   *
+   * @var array
+   */
+  private $pluginDefinition = [
+    'id' => 'http',
+    'title' => 'HTTP',
+  ];
+
+  /**
+   * Test data to validate an HTTP response against.
+   *
+   * @var string
+   */
+  private $testData = '
+    {
+      "id": 1,
+      "name": "Joe Bloggs"
+    }
+  ';
+
+  /**
+   * Mocked up Basic authentication plugin.
+   *
+   * @var \PHPUnit_Framework_MockObject_MockObject
+   */
+  private $basicAuthenticator = NULL;
+
+  /**
+   * Set up test environment.
+   */
+  public function setUp(): void {
+    // Mock up a Basic authentication plugin that will be used in requests.
+    $basic_authenticator = $this->getMockBuilder(Basic::class)
+      ->disableOriginalConstructor()
+      ->getMock();
+
+    $basic_authenticator->method('getAuthenticationOptions')
+      ->will($this->returnValue([
+        'auth' => [
+          'username',
+          'password',
+        ],
+      ]));
+
+    $this->basicAuthenticator = $basic_authenticator;
+  }
+
+  /**
+   * Test 'http' data fetcher (with auth) returns an expected response.
+   */
+  public function testFetchHttpWithAuth(): void {
+    $migration_config = $this->migrationConfiguration + $this->specificMigrationConfig;
+
+    $plugin = new TestHttp($migration_config, $this->dataFetcherPluginId, $this->pluginDefinition);
+    $plugin->mockHttpClient([[200, 'application/json', $this->testData]], $this->basicAuthenticator);
+
+    // The Guzzle mock returns an instance of StreamInterface.
+    // http://docs.guzzlephp.org/en/latest/psr7.html
+    $stream = $plugin->getResponseContent($migration_config['urls'][0]);
+
+    $body = json_decode((string) $stream, TRUE);
+
+    // Compare what we got back from the parser to what we expected to get.
+    $expected = json_decode($this->testData, TRUE);
+    $this->assertArrayEquals($expected, $body);
+  }
+
+  /**
+   * Test 'http' data fetcher (without auth) returns an expected response.
+   */
+  public function testFetchHttpNoAuth(): void {
+    $migration_config = $this->migrationConfiguration + $this->specificMigrationConfig;
+    unset($migration_config['authentication']);
+
+    $plugin = new TestHttp($migration_config, $this->dataFetcherPluginId, $this->pluginDefinition);
+    $plugin->mockHttpClient([[200, 'application/json', $this->testData]], NULL);
+
+    $stream = $plugin->getResponseContent($migration_config['urls'][0]);
+
+    $body = json_decode((string) $stream, TRUE);
+
+    $expected = json_decode($this->testData, TRUE);
+    $this->assertArrayEquals($expected, $body);
+  }
+
+  /**
+   * Test 'http' data fetcher (with auth) dies as expected when auth fails.
+   */
+  public function testFetchHttpAuthFailure(): void {
+    $migration_config = $this->migrationConfiguration + $this->specificMigrationConfig;
+
+    $plugin = new TestHttp($migration_config, $this->dataFetcherPluginId, $this->pluginDefinition);
+    $plugin->mockHttpClient([[403, 'text/html', 'Forbidden']], $this->basicAuthenticator);
+
+    $this->expectException(MigrateException::class);
+    $this->expectExceptionMessage('Error message: Client error: `GET http://example.org/http_fetcher_test` resulted in a `403 Forbidden');
+    $plugin->getResponseContent($migration_config['urls'][0]);
+  }
+
+  /**
+   * Test 'http' data fetcher (with auth) dies as expected when server down.
+   */
+  public function testFetchHttp500Error(): void {
+    $migration_config = $this->migrationConfiguration + $this->specificMigrationConfig;
+
+    $plugin = new TestHttp($migration_config, $this->dataFetcherPluginId, $this->pluginDefinition);
+    $plugin->mockHttpClient([[500, 'text/html', 'Internal Server Error']], $this->basicAuthenticator);
+
+    $this->expectException(MigrateException::class);
+    $this->expectExceptionMessage('GET http://example.org/http_fetcher_test` resulted in a `500 Internal Server Error');
+    $plugin->getResponseContent($migration_config['urls'][0]);
+  }
+
+}
+
+/**
+ * Test class to mock an HTTP request.
+ */
+class TestHttp extends Http {
+
+  /**
+   * Mocked authenticator plugin.
+   *
+   * @var \PHPUnit_Framework_MockObject_MockObject
+   */
+  public $authenticator = NULL;
+
+  /**
+   * Mock the HttpClient, so we can control the request/response(s) etc.
+   *
+   * @param array $responses
+   *   An array of responses (arrays), with each consisting of properties,
+   *   ordered: response code, content-type and  response body.
+   * @param object $authenticator
+   *   Mocked authenticator plugin.
+   */
+  public function mockHttpClient(array $responses, object $authenticator = NULL) {
+    // Set mocked authentication plugin to be used for the request auth plugin.
+    $this->authenticator = $authenticator;
+
+    $handler_responses = [];
+    foreach ($responses as $response) {
+      $handler_responses[] = new Response(
+        $response[0],
+        ['Content-Type' => $response[1]],
+        $response[2]
+      );
+    }
+
+    $mock = new MockHandler($handler_responses);
+    $handler = HandlerStack::create($mock);
+
+    $this->httpClient = new Client(['handler' => $handler]);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition) {
+    // Skip calling the Http() constructor (that sets the httpClient instance
+    // variable via \Drupal which we don't want to do), but keep the call to its
+    // parent class constructor. @see https://bugs.php.net/bug.php?id=42016
+    DataFetcherPluginBase::__construct($configuration, $plugin_id, $plugin_definition);
+
+    // This is what the parent class is doing, that we need to override.
+    $this->httpClient = NULL;
+  }
+
+  /**
+   * Override the parent::getAuthenticationPlugin()
+   *
+   * So we can mock the authentication plugin.
+   *
+   * @return \PHPUnit_Framework_MockObject_MockObject
+   *   A mocked authentication plugin.
+   */
+  public function getAuthenticationPlugin() {
+    if (!isset($this->authenticationPlugin)) {
+      $this->authenticationPlugin = $this->authenticator;
+    }
+
+    return $this->authenticationPlugin;
+  }
+
+}
diff --git a/web/modules/migrate_plus/tests/src/Unit/process/ArrayPopTest.php b/web/modules/migrate_plus/tests/src/Unit/process/ArrayPopTest.php
index 84316d799a0d8ec28eabb0c35b682e8613a71d97..c651f57c6615330ed11454c39d43a0b118309d98 100644
--- a/web/modules/migrate_plus/tests/src/Unit/process/ArrayPopTest.php
+++ b/web/modules/migrate_plus/tests/src/Unit/process/ArrayPopTest.php
@@ -2,9 +2,9 @@
 
 namespace Drupal\Tests\migrate_plus\Unit\process;
 
-use Drupal\Tests\migrate\Unit\process\MigrateProcessTestCase;
-use Drupal\migrate_plus\Plugin\migrate\process\ArrayPop;
 use Drupal\migrate\MigrateException;
+use Drupal\migrate_plus\Plugin\migrate\process\ArrayPop;
+use Drupal\Tests\migrate\Unit\process\MigrateProcessTestCase;
 
 /**
  * Tests the array pop process plugin.
@@ -17,7 +17,7 @@ class ArrayPopTest extends MigrateProcessTestCase {
   /**
    * {@inheritdoc}
    */
-  protected function setUp() {
+  protected function setUp(): void {
     $this->plugin = new ArrayPop([], 'array_pop', []);
     parent::setUp();
   }
@@ -28,7 +28,7 @@ protected function setUp() {
    * @return array
    *   An array containing input values and expected output values.
    */
-  public function arrayPopDataProvider() {
+  public function arrayPopDataProvider(): array {
     return [
       'indexed array' => [
         'input' => ['v1', 'v2', 'v3'],
@@ -55,7 +55,7 @@ public function arrayPopDataProvider() {
    *
    * @dataProvider arrayPopDataProvider
    */
-  public function testArrayPop(array $input, $expected_output) {
+  public function testArrayPop(array $input, $expected_output): void {
     $output = $this->plugin->transform($input, $this->migrateExecutable, $this->row, 'destinationproperty');
     $this->assertSame($output, $expected_output);
   }
@@ -63,8 +63,9 @@ public function testArrayPop(array $input, $expected_output) {
   /**
    * Test invalid input.
    */
-  public function testArrayPopFromString() {
-    $this->setExpectedException(MigrateException::class, 'Input should be an array.');
+  public function testArrayPopFromString(): void {
+    $this->expectException(MigrateException::class);
+    $this->expectExceptionMessage('Input should be an array.');
     $this->plugin->transform('foo', $this->migrateExecutable, $this->row, 'destinationproperty');
   }
 
diff --git a/web/modules/migrate_plus/tests/src/Unit/process/ArrayShiftTest.php b/web/modules/migrate_plus/tests/src/Unit/process/ArrayShiftTest.php
index 6c6950fee1109105d8ea9d3eb9001771b7db746a..1a8991a8853c9484c42a8725e361101795db5c48 100644
--- a/web/modules/migrate_plus/tests/src/Unit/process/ArrayShiftTest.php
+++ b/web/modules/migrate_plus/tests/src/Unit/process/ArrayShiftTest.php
@@ -2,9 +2,9 @@
 
 namespace Drupal\Tests\migrate_plus\Unit\process;
 
-use Drupal\Tests\migrate\Unit\process\MigrateProcessTestCase;
-use Drupal\migrate_plus\Plugin\migrate\process\ArrayShift;
 use Drupal\migrate\MigrateException;
+use Drupal\migrate_plus\Plugin\migrate\process\ArrayShift;
+use Drupal\Tests\migrate\Unit\process\MigrateProcessTestCase;
 
 /**
  * Tests the array shift process plugin.
@@ -17,7 +17,7 @@ class ArrayShiftTest extends MigrateProcessTestCase {
   /**
    * {@inheritdoc}
    */
-  protected function setUp() {
+  protected function setUp(): void {
     $this->plugin = new ArrayShift([], 'array_shift', []);
     parent::setUp();
   }
@@ -28,7 +28,7 @@ protected function setUp() {
    * @return array
    *   An array containing input values and expected output values.
    */
-  public function arrayShiftDataProvider() {
+  public function arrayShiftDataProvider(): array {
     return [
       'indexed array' => [
         'input' => ['v1', 'v2', 'v3'],
@@ -55,7 +55,7 @@ public function arrayShiftDataProvider() {
    *
    * @dataProvider arrayShiftDataProvider
    */
-  public function testArrayShift(array $input, $expected_output) {
+  public function testArrayShift(array $input, $expected_output): void {
     $output = $this->plugin->transform($input, $this->migrateExecutable, $this->row, 'destinationproperty');
     $this->assertSame($output, $expected_output);
   }
@@ -63,8 +63,9 @@ public function testArrayShift(array $input, $expected_output) {
   /**
    * Test invalid input.
    */
-  public function testArrayShiftFromString() {
-    $this->setExpectedException(MigrateException::class, 'Input should be an array.');
+  public function testArrayShiftFromString(): void {
+    $this->expectException(MigrateException::class);
+    $this->expectExceptionMessage('Input should be an array.');
     $this->plugin->transform('foo', $this->migrateExecutable, $this->row, 'destinationproperty');
   }
 
diff --git a/web/modules/migrate_plus/tests/src/Unit/process/DomApplyStylesTest.php b/web/modules/migrate_plus/tests/src/Unit/process/DomApplyStylesTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..5ed3e5448d94b1812751d342e343c68d6254477f
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Unit/process/DomApplyStylesTest.php
@@ -0,0 +1,140 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Unit\process;
+
+use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
+use Drupal\Component\Utility\Html;
+use Drupal\Core\Config\ConfigFactory;
+use Drupal\Core\Config\ImmutableConfig;
+use Drupal\migrate\MigrateSkipRowException;
+use Drupal\migrate_plus\Plugin\migrate\process\DomApplyStyles;
+use Drupal\Tests\migrate\Unit\process\MigrateProcessTestCase;
+
+/**
+ * Tests the dom_apply_styles process plugin.
+ *
+ * @group migrate
+ * @coversDefaultClass \Drupal\migrate_plus\Plugin\migrate\process\DomApplyStyles
+ */
+class DomApplyStylesTest extends MigrateProcessTestCase {
+
+  /**
+   * Example configuration for the dom_apply_styles process plugin.
+   *
+   * @var array
+   */
+  protected $exampleConfiguration = [
+    'format' => 'test_format',
+    'rules' => [
+      [
+        'xpath' => '//b',
+        'style' => 'Bold',
+      ],
+      [
+        'xpath' => '//span/i',
+        'style' => 'Italic',
+        'depth' => 1,
+      ],
+    ],
+  ];
+
+  /**
+   * Mock a config factory object.
+   *
+   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   */
+  protected $configFactory = NULL;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    // Mock a config object.
+    $prophecy = $this->prophesize(ImmutableConfig::class);
+    $prophecy
+      ->get('settings.plugins.stylescombo.styles')
+      ->willReturn("strong.foo|Bold\r\nem.foo.bar|Italic\r\n");
+    $style_config = $prophecy->reveal();
+    // Mock the config factory.
+    $prophecy = $this->prophesize(ConfigFactory::class);
+    $prophecy
+      ->get('editor.editor.test_format')
+      ->willReturn($style_config);
+    $this->configFactory = $prophecy->reveal();
+
+    parent::setUp();
+  }
+
+  /**
+   * @covers ::__construct
+   *
+   * @dataProvider providerTestConfig
+   */
+  public function testValidateRules(array $config_overrides, $message): void {
+    $configuration = $config_overrides + $this->exampleConfiguration;
+    $value = '<p>A simple paragraph.</p>';
+    $this->expectException(InvalidPluginDefinitionException::class);
+    $this->expectExceptionMessage($message);
+    (new DomApplyStyles($configuration, 'dom_apply_styles', [], $this->configFactory))
+      ->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
+  }
+
+  /**
+   * Dataprovider for testValidateRules().
+   */
+  public function providerTestConfig(): array {
+    $cases = [
+      'format-empty' => [
+        ['format' => ''],
+        'The "format" option must be a non-empty string.',
+      ],
+      'format-not-string' => [
+        ['format' => [1, 2, 3]],
+        'The "format" option must be a non-empty string.',
+      ],
+      'rules-not-array' => [
+        ['rules' => 'invalid'],
+        'The "rules" option must be an array.',
+      ],
+      'xpath-null' => [
+        [
+          'rules' => [['xpath' => NULL, 'style' => 'Bold']],
+        ],
+        'The "xpath" and "style" options are required for each rule.',
+      ],
+      'style-invalid' => [
+        [
+          'rules' => [['xpath' => '//b', 'style' => 'invalid-style']],
+        ],
+        'The style "invalid-style" is not defined.',
+      ],
+    ];
+
+    return $cases;
+  }
+
+  /**
+   * @covers ::transform
+   */
+  public function testTransformInvalidInput(): void {
+    $value = 'string';
+    $this->expectException(MigrateSkipRowException::class);
+    $this->expectExceptionMessage('The dom_apply_styles plugin in the destinationproperty process pipeline requires a \DOMDocument object. You can use the dom plugin to convert a string to \DOMDocument.');
+    (new DomApplyStyles($this->exampleConfiguration, 'dom_apply_styles', [], $this->configFactory))
+      ->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
+  }
+
+  /**
+   * @covers ::transform
+   */
+  public function testTransform(): void {
+    $input_string = '<div><span><b>Bold text</b></span><span><i>Italic text</i></span></div>';
+    $output_string = '<div><span><strong class="foo">Bold text</strong></span><em class="foo bar">Italic text</em></div>';
+    $value = Html::load($input_string);
+    $document = (new DomApplyStyles($this->exampleConfiguration, 'dom_apply_styles', [], $this->configFactory))
+      ->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
+    $this->assertTrue($document instanceof \DOMDocument);
+    $this->assertEquals($output_string, Html::serialize($document));
+  }
+
+}
diff --git a/web/modules/migrate_plus/tests/src/Unit/process/DomMigrationLookupTest.php b/web/modules/migrate_plus/tests/src/Unit/process/DomMigrationLookupTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..278eac22b5a4703c99b95c2397e9f7b674bc5f23
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Unit/process/DomMigrationLookupTest.php
@@ -0,0 +1,191 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Unit\process;
+
+use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
+use Drupal\Component\Utility\Html;
+use Drupal\migrate\MigrateSkipRowException;
+use Drupal\migrate\Plugin\MigratePluginManager;
+use Drupal\migrate\Plugin\MigrateProcessInterface;
+use Drupal\migrate\Plugin\MigrationInterface;
+use Drupal\migrate_plus\Plugin\migrate\process\DomMigrationLookup;
+use Drupal\Tests\migrate\Unit\process\MigrateProcessTestCase;
+
+/**
+ * Tests the dom_migration_lookup process plugin.
+ *
+ * @group migrate
+ * @coversDefaultClass \Drupal\migrate_plus\Plugin\migrate\process\DomMigrationLookup
+ */
+class DomMigrationLookupTest extends MigrateProcessTestCase {
+
+  /**
+   * Example configuration for the dom_migration_lookup process plugin.
+   *
+   * @var array
+   */
+  protected $exampleConfiguration = [
+    'plugin' => 'dom_migration_lookup',
+    'mode' => 'attribute',
+    'xpath' => '//a',
+    'attribute_options' => [
+      'name' => 'href',
+    ],
+    'search' => '@/user/(\d+)@',
+    'replace' => '/user/[mapped-id]',
+    'migrations' => [
+      'users' => [],
+      'people' => [
+        'replace' => '/people/[mapped-id]',
+      ],
+    ],
+  ];
+
+  /**
+   * Mock a migration.
+   *
+   * @var \Drupal\migrate\Plugin\MigrationInterface
+   */
+  protected $migration;
+
+  /**
+   * Mock a process plugin manager.
+   *
+   * @var \Drupal\migrate\Plugin\MigratePluginManagerInterface
+   */
+  protected $processPluginManager;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    // Mock a  migration.
+    $prophecy = $this->prophesize(MigrationInterface::class);
+    $this->migration = $prophecy->reveal();
+
+    // Mock two migration lookup plugins.
+    $prophecy = $this->prophesize(MigrateProcessInterface::class);
+    $prophecy
+      ->transform('123', $this->migrateExecutable, $this->row, 'destinationproperty')
+      ->willReturn('321');
+    $prophecy
+      ->transform('456', $this->migrateExecutable, $this->row, 'destinationproperty')
+      ->willReturn(NULL);
+    $users_lookup_plugin = $prophecy->reveal();
+    $prophecy = $this->prophesize(MigrateProcessInterface::class);
+    $prophecy
+      ->transform('123', $this->migrateExecutable, $this->row, 'destinationproperty')
+      ->willReturn('ignored');
+    $prophecy
+      ->transform('456', $this->migrateExecutable, $this->row, 'destinationproperty')
+      ->willReturn('654');
+    $people_lookup_plugin = $prophecy->reveal();
+
+    // Mock a process plugin manager.
+    $prophecy = $this->prophesize(MigratePluginManager::class);
+    $users_configuration = [
+      'migration' => 'users',
+      'no_stub' => TRUE,
+    ];
+    $people_configuration = [
+      'migration' => 'people',
+      'no_stub' => TRUE,
+    ];
+    $prophecy
+      ->createInstance('migration_lookup', $users_configuration, $this->migration)
+      ->willReturn($users_lookup_plugin);
+    $prophecy
+      ->createInstance('migration_lookup', $people_configuration, $this->migration)
+      ->willReturn($people_lookup_plugin);
+    $this->processPluginManager = $prophecy->reveal();
+  }
+
+  /**
+   * @covers ::__construct
+   *
+   * @dataProvider providerTestConfigValidation
+   */
+  public function testConfigValidation(array $config_overrides, $message): void {
+    $configuration = $config_overrides + $this->exampleConfiguration;
+    $value = '<p>A simple paragraph.</p>';
+    $this->expectException(InvalidPluginDefinitionException::class);
+    $this->expectExceptionMessage($message);
+    (new DomMigrationLookup($configuration, 'dom_migration_lookup', [], $this->migration, $this->processPluginManager))
+      ->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
+  }
+
+  /**
+   * Dataprovider for testConfigValidation().
+   */
+  public function providerTestConfigValidation(): array {
+    $cases = [
+      'migrations-empty' => [
+        ['migrations' => []],
+        "Configuration option 'migration' is required.",
+      ],
+      'migrations-invalid' => [
+        ['migrations' => 42],
+        "Configuration option 'migration' should be a keyed array.",
+      ],
+      'replace-null' => [
+        ['replace' => NULL],
+        "Please define either a global replace for all migrations, or a specific one for 'migrations.users'.",
+      ],
+    ];
+
+    return $cases;
+  }
+
+  /**
+   * @covers ::transform
+   */
+  public function testTransformInvalidInput(): void {
+    $value = 'string';
+    $this->expectException(MigrateSkipRowException::class);
+    $this->expectExceptionMessage('The dom_migration_lookup plugin in the destinationproperty process pipeline requires a \DOMDocument object. You can use the dom plugin to convert a string to \DOMDocument.');
+    (new DomMigrationLookup($this->exampleConfiguration, 'dom_migration_lookup', [], $this->migration, $this->processPluginManager))
+      ->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
+  }
+
+  /**
+   * @covers ::transform
+   *
+   * @dataProvider providerTestTransform
+   */
+  public function testTransform($config_overrides, $input_string, $output_string): void {
+    $configuration = $config_overrides + $this->exampleConfiguration;
+    $value = Html::load($input_string);
+    $document = (new DomMigrationLookup($configuration, 'dom_migration_lookup', [], $this->migration, $this->processPluginManager))
+      ->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
+    $this->assertTrue($document instanceof \DOMDocument);
+    $this->assertEquals($output_string, Html::serialize($document));
+  }
+
+  /**
+   * Dataprovider for testTransform().
+   */
+  public function providerTestTransform(): array {
+    $cases = [
+      'users-migration' => [
+        [],
+        '<a href="/user/123">text</a>',
+        '<a href="/user/321">text</a>',
+      ],
+      'people-migration' => [
+        [],
+        '<a href="https://www.example.com/user/456">text</a>',
+        '<a href="https://www.example.com/people/654">text</a>',
+      ],
+      'no-match' => [
+        ['search' => '@www\.mysite\.com/user/(\d+)@'],
+        '<a href="https://www.example.com/user/456">text</a>',
+        '<a href="https://www.example.com/user/456">text</a>',
+      ],
+    ];
+
+    return $cases;
+  }
+
+}
diff --git a/web/modules/migrate_plus/tests/src/Unit/process/DomStrReplaceTest.php b/web/modules/migrate_plus/tests/src/Unit/process/DomStrReplaceTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..fb4a93395499e50755b618b8eaf893e536dbcc5a
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Unit/process/DomStrReplaceTest.php
@@ -0,0 +1,145 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Unit\process;
+
+use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
+use Drupal\Component\Utility\Html;
+use Drupal\migrate\MigrateSkipRowException;
+use Drupal\migrate_plus\Plugin\migrate\process\DomStrReplace;
+use Drupal\Tests\migrate\Unit\process\MigrateProcessTestCase;
+
+/**
+ * Tests the dom_str_replace process plugin.
+ *
+ * @group migrate
+ * @coversDefaultClass \Drupal\migrate_plus\Plugin\migrate\process\DomStrReplace
+ */
+class DomStrReplaceTest extends MigrateProcessTestCase {
+
+  /**
+   * Example configuration for the dom_str_replace process plugin.
+   *
+   * @var array
+   */
+  protected $exampleConfiguration = [
+    'mode' => 'attribute',
+    'xpath' => '//a',
+    'attribute_options' => [
+      'name' => 'href',
+    ],
+    'search' => 'foo',
+    'replace' => 'bar',
+  ];
+
+  /**
+   * @covers ::__construct
+   *
+   * @dataProvider providerTestConfigEmpty
+   */
+  public function testConfigValidation(array $config_overrides, $message): void {
+    $configuration = $config_overrides + $this->exampleConfiguration;
+    $value = '<p>A simple paragraph.</p>';
+    $this->expectException(InvalidPluginDefinitionException::class);
+    $this->expectExceptionMessage($message);
+    (new DomStrReplace($configuration, 'dom_str_replace', []))
+      ->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
+  }
+
+  /**
+   * Dataprovider for testConfigValidation().
+   */
+  public function providerTestConfigEmpty(): array {
+    $cases = [
+      'xpath-null' => [
+        ['xpath' => NULL],
+        "Configuration option 'xpath' is required.",
+      ],
+      'mode-null' => [
+        ['mode' => NULL],
+        "Configuration option 'mode' is required.",
+      ],
+      'mode-invalid' => [
+        ['mode' => 'invalid'],
+        'Configuration option "mode" only accepts the following values: attribute.',
+      ],
+      'attribute_options-null' => [
+        ['attribute_options' => NULL],
+        "Configuration option 'attribute_options' is required.",
+      ],
+      'search-null' => [
+        ['search' => NULL],
+        "Configuration option 'search' is required.",
+      ],
+      'replace-null' => [
+        ['replace' => NULL],
+        "Configuration option 'replace' is required.",
+      ],
+    ];
+
+    return $cases;
+  }
+
+  /**
+   * @covers ::transform
+   */
+  public function testTransformInvalidInput(): void {
+    $configuration = [
+      'xpath' => '//a',
+      'mode' => 'attribute',
+      'attribute_options' => [
+        'name' => 'href',
+      ],
+      'search' => 'foo',
+      'replace' => 'bar',
+    ];
+    $value = 'string';
+    $this->expectException(MigrateSkipRowException::class);
+    $this->expectExceptionMessage('The dom_str_replace plugin in the destinationproperty process pipeline requires a \DOMDocument object. You can use the dom plugin to convert a string to \DOMDocument.');
+    (new DomStrReplace($configuration, 'dom_str_replace', []))
+      ->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
+  }
+
+  /**
+   * @covers ::transform
+   *
+   * @dataProvider providerTestTransform
+   */
+  public function testTransform($input_string, $configuration, $output_string): void {
+    $value = Html::load($input_string);
+    $document = (new DomStrReplace($configuration, 'dom_str_replace', []))
+      ->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
+    $this->assertTrue($document instanceof \DOMDocument);
+    $this->assertEquals($output_string, Html::serialize($document));
+  }
+
+  /**
+   * Dataprovider for testTransform().
+   */
+  public function providerTestTransform(): array {
+    $cases = [
+      'string:case_sensitive' => [
+        '<a href="/foo/Foo/foo">text</a>',
+        $this->exampleConfiguration,
+        '<a href="/bar/Foo/bar">text</a>',
+      ],
+      'string:case_insensitive' => [
+        '<a href="/foo/Foo/foo">text</a>',
+        [
+          'case_insensitive' => TRUE,
+        ] + $this->exampleConfiguration,
+        '<a href="/bar/bar/bar">text</a>',
+      ],
+      'regex' => [
+        '<a href="/foo/Foo/foo">text</a>',
+        [
+          'search' => '/(.)\1/',
+          'regex' => TRUE,
+        ] + $this->exampleConfiguration,
+        '<a href="/fbar/Fbar/fbar">text</a>',
+      ],
+    ];
+
+    return $cases;
+  }
+
+}
diff --git a/web/modules/migrate_plus/tests/src/Unit/process/DomTest.php b/web/modules/migrate_plus/tests/src/Unit/process/DomTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..78abf8587aae04d29186c7fa1eb2864edb285a8b
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Unit/process/DomTest.php
@@ -0,0 +1,88 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Unit\process;
+
+use Drupal\Component\Utility\Html;
+use Drupal\migrate\MigrateException;
+use Drupal\migrate_plus\Plugin\migrate\process\Dom;
+use Drupal\Tests\migrate\Unit\process\MigrateProcessTestCase;
+
+/**
+ * Tests the dom process plugin.
+ *
+ * @group migrate
+ * @coversDefaultClass \Drupal\migrate_plus\Plugin\migrate\process\Dom
+ */
+class DomTest extends MigrateProcessTestCase {
+
+  /**
+   * @covers ::__construct
+   */
+  public function testConfigMethodEmpty(): void {
+    $configuration = [];
+    $value = '<p>A simple paragraph.</p>';
+    $this->expectException(\InvalidArgumentException::class);
+    $this->expectExceptionMessage('The "method" must be set.');
+    (new Dom($configuration, 'dom', []))
+      ->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
+  }
+
+  /**
+   * @covers ::__construct
+   */
+  public function testConfigMethodInvalid(): void {
+    $configuration['method'] = 'invalid';
+    $value = '<p>A simple paragraph.</p>';
+    $this->expectException(\InvalidArgumentException::class);
+    $this->expectExceptionMessage('The "method" must be "import" or "export".');
+    (new Dom($configuration, 'dom', []))
+      ->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
+  }
+
+  /**
+   * @covers ::import
+   */
+  public function testImportNonRoot(): void {
+    $configuration['method'] = 'import';
+    $value = '<p>A simple paragraph.</p>';
+    $document = (new Dom($configuration, 'dom', []))
+      ->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
+    $this->assertTrue($document instanceof \DOMDocument);
+  }
+
+  /**
+   * @covers ::import
+   */
+  public function testImportNonRootInvalidInput(): void {
+    $configuration['method'] = 'import';
+    $value = [1, 1];
+    $this->expectException(MigrateException::class);
+    $this->expectExceptionMessage('Cannot import a non-string value.');
+    (new Dom($configuration, 'dom', []))
+      ->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
+  }
+
+  /**
+   * @covers ::export
+   */
+  public function testExportNonRoot(): void {
+    $configuration['method'] = 'export';
+    $partial = '<p>A simple paragraph.</p>';
+    $document = Html::load($partial);
+    $value = (new Dom($configuration, 'dom', []))
+      ->transform($document, $this->migrateExecutable, $this->row, 'destinationproperty');
+    $this->assertEquals($value, $partial);
+  }
+
+  /**
+   * @covers ::export
+   */
+  public function testExportNonRootInvalidInput(): void {
+    $configuration['method'] = 'export';
+    $this->expectException(MigrateException::class);
+    $this->expectExceptionMessage('Cannot export a "string".');
+    (new Dom($configuration, 'dom', []))
+      ->transform('string is not DOMDocument', $this->migrateExecutable, $this->row, 'destinationproperty');
+  }
+
+}
diff --git a/web/modules/migrate_plus/tests/src/Unit/process/MultipleValuesTest.php b/web/modules/migrate_plus/tests/src/Unit/process/MultipleValuesTest.php
index 60a520c0dfedfec7a11bf5200253f95b241919e1..604d3c61249b9e09b6bafa318723081943c9245a 100644
--- a/web/modules/migrate_plus/tests/src/Unit/process/MultipleValuesTest.php
+++ b/web/modules/migrate_plus/tests/src/Unit/process/MultipleValuesTest.php
@@ -2,8 +2,8 @@
 
 namespace Drupal\Tests\migrate_plus\Unit\process;
 
-use Drupal\Tests\migrate\Unit\process\MigrateProcessTestCase;
 use Drupal\migrate_plus\Plugin\migrate\process\MultipleValues;
+use Drupal\Tests\migrate\Unit\process\MigrateProcessTestCase;
 
 /**
  * @coversDefaultClass \Drupal\migrate_plus\Plugin\migrate\process\MultipleValues
@@ -14,7 +14,7 @@ class MultipleValuesTest extends MigrateProcessTestCase {
   /**
    * {@inheritdoc}
    */
-  protected function setUp() {
+  protected function setUp(): void {
     $this->plugin = new MultipleValues([], 'multiple_values', []);
     parent::setUp();
   }
@@ -22,7 +22,7 @@ protected function setUp() {
   /**
    * Test input treated as multiple value output.
    */
-  public function testTreatAsMultiple() {
+  public function testTreatAsMultiple(): void {
     $value = ['v1', 'v2', 'v3'];
     $output = $this->plugin->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
     $this->assertSame($output, $value);
diff --git a/web/modules/migrate_plus/tests/src/Unit/process/SingleValueTest.php b/web/modules/migrate_plus/tests/src/Unit/process/SingleValueTest.php
index 203b1c5cb3313fc205ad7f91d02cd2ee9f8e5b77..fe8d0748361a246edb65a7f44d20704cac2e1821 100644
--- a/web/modules/migrate_plus/tests/src/Unit/process/SingleValueTest.php
+++ b/web/modules/migrate_plus/tests/src/Unit/process/SingleValueTest.php
@@ -2,8 +2,8 @@
 
 namespace Drupal\Tests\migrate_plus\Unit\process;
 
-use Drupal\Tests\migrate\Unit\process\MigrateProcessTestCase;
 use Drupal\migrate_plus\Plugin\migrate\process\SingleValue;
+use Drupal\Tests\migrate\Unit\process\MigrateProcessTestCase;
 
 /**
  * @coversDefaultClass \Drupal\migrate_plus\Plugin\migrate\process\SingleValue
@@ -14,7 +14,7 @@ class SingleValueTest extends MigrateProcessTestCase {
   /**
    * {@inheritdoc}
    */
-  protected function setUp() {
+  protected function setUp(): void {
     $this->plugin = new SingleValue([], 'single_value', []);
     parent::setUp();
   }
@@ -22,7 +22,7 @@ protected function setUp() {
   /**
    * Test input treated as single value output.
    */
-  public function testTreatAsSingle() {
+  public function testTreatAsSingle(): void {
     $value = ['v1', 'v2', 'v3'];
     $output = $this->plugin->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
     $this->assertSame($output, $value);
diff --git a/web/modules/migrate_plus/tests/src/Unit/process/SkipOnValueTest.php b/web/modules/migrate_plus/tests/src/Unit/process/SkipOnValueTest.php
index 968e6f0fbc7a2be0ca86f53095dcb230747458e9..e2af7c929864fe77639e0ee18ed81c31cc36c20b 100644
--- a/web/modules/migrate_plus/tests/src/Unit/process/SkipOnValueTest.php
+++ b/web/modules/migrate_plus/tests/src/Unit/process/SkipOnValueTest.php
@@ -19,10 +19,10 @@ class SkipOnValueTest extends MigrateProcessTestCase {
   /**
    * @covers ::process
    */
-  public function testProcessSkipsOnValue() {
+  public function testProcessSkipsOnValue(): void {
     $configuration['method'] = 'process';
     $configuration['value'] = 86;
-    $this->setExpectedException(MigrateSkipProcessException::class);
+    $this->expectException(MigrateSkipProcessException::class);
     (new SkipOnValue($configuration, 'skip_on_value', []))
       ->transform('86', $this->migrateExecutable, $this->row, 'destinationproperty');
   }
@@ -30,10 +30,10 @@ public function testProcessSkipsOnValue() {
   /**
    * @covers ::process
    */
-  public function testProcessSkipsOnMultipleValue() {
+  public function testProcessSkipsOnMultipleValue(): void {
     $configuration['method'] = 'process';
     $configuration['value'] = [1, 1, 2, 3, 5, 8];
-    $this->setExpectedException(MigrateSkipProcessException::class);
+    $this->expectException(MigrateSkipProcessException::class);
     (new SkipOnValue($configuration, 'skip_on_value', []))
       ->transform('5', $this->migrateExecutable, $this->row, 'destinationproperty');
   }
@@ -41,7 +41,7 @@ public function testProcessSkipsOnMultipleValue() {
   /**
    * @covers ::process
    */
-  public function testProcessBypassesOnNonValue() {
+  public function testProcessBypassesOnNonValue(): void {
     $configuration['method'] = 'process';
     $configuration['value'] = 'sourcevalue';
     $configuration['not_equals'] = TRUE;
@@ -57,7 +57,7 @@ public function testProcessBypassesOnNonValue() {
   /**
    * @covers ::process
    */
-  public function testProcessSkipsOnMultipleNonValue() {
+  public function testProcessSkipsOnMultipleNonValue(): void {
     $configuration['method'] = 'process';
     $configuration['value'] = [1, 1, 2, 3, 5, 8];
     $value = (new SkipOnValue($configuration, 'skip_on_value', []))
@@ -68,7 +68,7 @@ public function testProcessSkipsOnMultipleNonValue() {
   /**
    * @covers ::process
    */
-  public function testProcessBypassesOnMultipleNonValue() {
+  public function testProcessBypassesOnMultipleNonValue(): void {
     $configuration['method'] = 'process';
     $configuration['value'] = [1, 1, 2, 3, 5, 8];
     $configuration['not_equals'] = TRUE;
@@ -83,7 +83,7 @@ public function testProcessBypassesOnMultipleNonValue() {
   /**
    * @covers ::row
    */
-  public function testRowBypassesOnMultipleNonValue() {
+  public function testRowBypassesOnMultipleNonValue(): void {
     $configuration['method'] = 'row';
     $configuration['value'] = [1, 1, 2, 3, 5, 8];
     $configuration['not_equals'] = TRUE;
@@ -98,10 +98,10 @@ public function testRowBypassesOnMultipleNonValue() {
   /**
    * @covers ::row
    */
-  public function testRowSkipsOnValue() {
+  public function testRowSkipsOnValue(): void {
     $configuration['method'] = 'row';
     $configuration['value'] = 86;
-    $this->setExpectedException(MigrateSkipRowException::class);
+    $this->expectException(MigrateSkipRowException::class);
     (new SkipOnValue($configuration, 'skip_on_value', []))
       ->transform('86', $this->migrateExecutable, $this->row, 'destinationproperty');
   }
@@ -109,7 +109,7 @@ public function testRowSkipsOnValue() {
   /**
    * @covers ::row
    */
-  public function testRowBypassesOnNonValue() {
+  public function testRowBypassesOnNonValue(): void {
     $configuration['method'] = 'row';
     $configuration['value'] = 'sourcevalue';
     $configuration['not_equals'] = TRUE;
@@ -125,9 +125,9 @@ public function testRowBypassesOnNonValue() {
   /**
    * @covers ::row
    */
-  public function testRequiredRowConfiguration() {
+  public function testRequiredRowConfiguration(): void {
     $configuration['method'] = 'row';
-    $this->setExpectedException(MigrateException::class);
+    $this->expectException(MigrateException::class);
     (new SkipOnValue($configuration, 'skip_on_value', []))
       ->transform('sourcevalue', $this->migrateExecutable, $this->row, 'destinationproperty');
   }
@@ -135,9 +135,9 @@ public function testRequiredRowConfiguration() {
   /**
    * @covers ::process
    */
-  public function testRequiredProcessConfiguration() {
+  public function testRequiredProcessConfiguration(): void {
     $configuration['method'] = 'process';
-    $this->setExpectedException(MigrateException::class);
+    $this->expectException(MigrateException::class);
     (new SkipOnValue($configuration, 'skip_on_value', []))
       ->transform('sourcevalue', $this->migrateExecutable, $this->row, 'destinationproperty');
   }
diff --git a/web/modules/migrate_plus/tests/src/Unit/process/StrReplaceTest.php b/web/modules/migrate_plus/tests/src/Unit/process/StrReplaceTest.php
index 7194c711be4e73066bc035136e30c7b99ca6cdf9..c015cf362eb0ee18f2040040429ccfb2a2c086e9 100644
--- a/web/modules/migrate_plus/tests/src/Unit/process/StrReplaceTest.php
+++ b/web/modules/migrate_plus/tests/src/Unit/process/StrReplaceTest.php
@@ -2,6 +2,7 @@
 
 namespace Drupal\Tests\migrate_plus\Unit\process;
 
+use Drupal\migrate\MigrateException;
 use Drupal\migrate_plus\Plugin\migrate\process\StrReplace;
 use Drupal\Tests\migrate\Unit\process\MigrateProcessTestCase;
 
@@ -16,7 +17,7 @@ class StrReplaceTest extends MigrateProcessTestCase {
   /**
    * Test for a simple str_replace string.
    */
-  public function testStrReplace() {
+  public function testStrReplace(): void {
     $value = 'vero eos et accusam et justo vero';
     $configuration['search'] = 'et';
     $configuration['replace'] = 'that';
@@ -29,7 +30,7 @@ public function testStrReplace() {
   /**
    * Test for case insensitive searches.
    */
-  public function testStrIreplace() {
+  public function testStrIreplace(): void {
     $value = 'VERO eos et accusam et justo vero';
     $configuration['search'] = 'vero';
     $configuration['replace'] = 'that';
@@ -43,7 +44,7 @@ public function testStrIreplace() {
   /**
    * Test for regular expressions.
    */
-  public function testPregReplace() {
+  public function testPregReplace(): void {
     $value = 'vero eos et 123 accusam et justo 123 duo';
     $configuration['search'] = '/[0-9]{3}/';
     $configuration['replace'] = 'the';
@@ -56,29 +57,31 @@ public function testPregReplace() {
   /**
    * Test for MigrateException for "search" configuration.
    */
-  public function testSearchMigrateException() {
+  public function testSearchMigrateException(): void {
     $value = 'vero eos et accusam et justo vero';
     $configuration['replace'] = 'that';
     $plugin = new StrReplace($configuration, 'str_replace', []);
-    $this->setExpectedException('\Drupal\migrate\MigrateException', '"search" must be configured.');
+    $this->expectException(MigrateException::class);
+    $this->expectExceptionMessage('"search" must be configured.');
     $plugin->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
   }
 
   /**
    * Test for MigrateException for "replace" configuration.
    */
-  public function testReplaceMigrateException() {
+  public function testReplaceMigrateException(): void {
     $value = 'vero eos et accusam et justo vero';
     $configuration['search'] = 'et';
     $plugin = new StrReplace($configuration, 'str_replace', []);
-    $this->setExpectedException('\Drupal\migrate\MigrateException', '"replace" must be configured.');
+    $this->expectException(MigrateException::class);
+    $this->expectExceptionMessage('"replace" must be configured.');
     $plugin->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
   }
 
   /**
    * Test for multiple.
    */
-  public function testIsMultiple() {
+  public function testIsMultiple(): void {
     $value = [
       'vero eos et accusam et justo vero',
       'et eos vero accusam vero justo et',
diff --git a/web/modules/migrate_plus/tests/src/Unit/process/TransliterationTest.php b/web/modules/migrate_plus/tests/src/Unit/process/TransliterationTest.php
index dbfe288570f10bd877473cf2a23dfdc6da681142..2ed635656e6ce38dd5dccab4388fb14263a40d69 100644
--- a/web/modules/migrate_plus/tests/src/Unit/process/TransliterationTest.php
+++ b/web/modules/migrate_plus/tests/src/Unit/process/TransliterationTest.php
@@ -25,7 +25,7 @@ class TransliterationTest extends MigrateProcessTestCase {
   /**
    * {@inheritdoc}
    */
-  protected function setUp() {
+  protected function setUp(): void {
     $this->transliteration = new PhpTransliteration();
     $this->row = $this->getMockBuilder(Row::class)
       ->disableOriginalConstructor()
@@ -39,7 +39,7 @@ protected function setUp() {
   /**
    * Tests transliteration transformation of non-alphanumeric characters.
    */
-  public function testTransform() {
+  public function testTransform(): void {
     $actual = '9000004351_53494854_Spøgelsesjægerneáéö';
     $expected_result = '9000004351_53494854_Spogelsesjaegerneaeo';
 
diff --git a/web/modules/migrate_tools/composer.json b/web/modules/migrate_tools/composer.json
index d5c8fca8fd46ecb2e9c889dea5fd38082b16baf9..a521cf6f2852f2908956e565aadd8c962536991f 100644
--- a/web/modules/migrate_tools/composer.json
+++ b/web/modules/migrate_tools/composer.json
@@ -1,27 +1,45 @@
 {
-  "name": "drupal/migrate_tools",
-  "description": "Tools to assist in developing and running migrations.",
-  "type": "drupal-module",
-  "homepage": "http://drupal.org/project/migrate_tools",
-  "support": {
-    "issues": "http://drupal.org/project/migrate_tools",
-    "irc": "irc://irc.freenode.org/drupal-migrate",
-    "source": "http://cgit.drupalcode.org/migrate_tools"
-  },
-  "license": "GPL-2.0+",
-  "require": {
-    "drupal/migrate_plus": "^4"
-  },
-  "require-dev": {
-    "drupal/coder": "^8",
-    "drupal/migrate_source_csv": "^2.2"
-  },
-  "minimum-stability": "dev",
-  "extra": {
-    "drush": {
-      "services": {
-        "drush.services.yml": "^9"
-      }
+    "name": "drupal/migrate_tools",
+    "description": "Tools to assist in developing and running migrations.",
+    "type": "drupal-module",
+    "homepage": "http://drupal.org/project/migrate_tools",
+    "authors": [
+        {
+            "name": "Mike Ryan",
+            "homepage":"https://www.drupal.org/u/mikeryan",
+            "role": "Maintainer"
+        },
+        {
+            "name": "Lucas Hedding",
+            "homepage": "https://www.drupal.org/u/heddn",
+            "role": "Maintainer"
+        }
+    ],
+    "support": {
+        "issues": "https://www.drupal.org/project/issues/migrate_tools",
+        "slack": "#migrate",
+        "source": "https://git.drupalcode.org/project/migrate_tools"
+    },
+    "license": "GPL-2.0-or-later",
+    "require": {
+        "php": ">=7.1",
+        "drupal/migrate_plus": "^5",
+        "drupal/core": "^8.8 | ^9"
+    },
+    "require-dev": {
+        "drupal/migrate_plus": "^5",
+        "drupal/migrate_source_csv": "^3",
+        "drush/drush": "^10"
+    },
+    "suggest": {
+        "drush/drush": "^9 || ^10"
+    },
+    "minimum-stability": "dev",
+    "extra": {
+        "drush": {
+            "services": {
+                "drush.services.yml": "^9 || ^10"
+            }
+        }
     }
-  }
 }
diff --git a/web/modules/migrate_tools/migrate_tools.drush.inc b/web/modules/migrate_tools/migrate_tools.drush.inc
deleted file mode 100644
index 73ddec5307cf285b602278ae90d00fd4014908ac..0000000000000000000000000000000000000000
--- a/web/modules/migrate_tools/migrate_tools.drush.inc
+++ /dev/null
@@ -1,561 +0,0 @@
-<?php
-
-/**
- * @file
- * Command-line tools to aid performing and developing migrations.
- */
-
-use Drupal\Component\Utility\Unicode;
-use Drupal\migrate\Exception\RequirementsException;
-use Drupal\migrate\Plugin\MigrationInterface;
-use Drupal\migrate\Plugin\RequirementsInterface;
-use Drupal\migrate_plus\Entity\MigrationGroup;
-use Drupal\migrate_tools\DrushLogMigrateMessage;
-use Drupal\migrate_tools\MigrateExecutable;
-
-/**
- * Implements hook_drush_command().
- */
-function migrate_tools_drush_command() {
-  $items['migrate-status'] = [
-    'description' => 'List all migrations with current status.',
-    'options' => [
-      'group' => 'A comma-separated list of migration groups to list',
-      'tag' => 'Name of the migration tag to list',
-      'names-only' => 'Only return names, not all the details (faster)',
-    ],
-    'arguments' => [
-      'migration' => 'Restrict to a comma-separated list of migrations. Optional',
-    ],
-    'examples' => [
-      'migrate-status' => 'Retrieve status for all migrations',
-      'migrate-status --group=beer' => 'Retrieve status for all migrations in a given group',
-      'migrate-status --tag=user' => 'Retrieve status for all migrations with a given tag',
-      'migrate-status --group=beer --tag=user' => 'Retrieve status for all migrations in the beer group and with the user tag',
-      'migrate-status beer_term,beer_node' => 'Retrieve status for specific migrations',
-    ],
-    'drupal dependencies' => ['migrate_tools'],
-    'aliases' => ['ms'],
-  ];
-
-  $items['migrate-import'] = [
-    'description' => 'Perform one or more migration processes.',
-    'options' => [
-      'all' => 'Process all migrations.',
-      'group' => 'A comma-separated list of migration groups to import',
-      'tag' => 'Name of the migration tag to import',
-      'limit' => 'Limit on the number of items to process in each migration',
-      'feedback' => 'Frequency of progress messages, in items processed',
-      'idlist' => 'Comma-separated list of IDs to import',
-      'update' => ' In addition to processing unprocessed items from the source, update previously-imported items with the current data',
-      'force' => 'Force an operation to run, even if all dependencies are not satisfied',
-      'execute-dependencies' => 'Execute all dependent migrations first.',
-    ],
-    'arguments' => [
-      'migration' => 'ID of migration(s) to import. Delimit multiple using commas.',
-    ],
-    'examples' => [
-      'migrate-import --all' => 'Perform all migrations',
-      'migrate-import --group=beer' => 'Import all migrations in the beer group',
-      'migrate-import --tag=user' => 'Import all migrations with the user tag',
-      'migrate-import --group=beer --tag=user' => 'Import all migrations in the beer group and with the user tag',
-      'migrate-import beer_term,beer_node' => 'Import new terms and nodes',
-      'migrate-import beer_user --limit=2' => 'Import no more than 2 users',
-      'migrate-import beer_user --idlist=5' => 'Import the user record with source ID 5',
-    ],
-    'drupal dependencies' => ['migrate_tools'],
-    'aliases' => ['mi', 'mim'],
-  ];
-
-  $items['migrate-rollback'] = [
-    'description' => 'Rollback one or more migrations.',
-    'options' => [
-      'all' => 'Process all migrations.',
-      'group' => 'A comma-separated list of migration groups to rollback',
-      'tag' => 'ID of the migration tag to rollback',
-      'feedback' => 'Frequency of progress messages, in items processed',
-    ],
-    'arguments' => [
-      'migration' => 'Name of migration(s) to rollback. Delimit multiple using commas.',
-    ],
-    'examples' => [
-      'migrate-rollback --all' => 'Perform all migrations',
-      'migrate-rollback --group=beer' => 'Rollback all migrations in the beer group',
-      'migrate-rollback --tag=user' => 'Rollback all migrations with the user tag',
-      'migrate-rollback --group=beer --tag=user' => 'Rollback all migrations in the beer group and with the user tag',
-      'migrate-rollback beer_term,beer_node' => 'Rollback imported terms and nodes',
-    ],
-    'drupal dependencies' => ['migrate_tools'],
-    'aliases' => ['mr'],
-  ];
-
-  $items['migrate-stop'] = [
-    'description' => 'Stop an active migration operation.',
-    'arguments' => [
-      'migration' => 'ID of migration to stop',
-    ],
-    'drupal dependencies' => ['migrate_tools'],
-    'aliases' => ['mst'],
-  ];
-
-  $items['migrate-reset-status'] = [
-    'description' => 'Reset a active migration\'s status to idle.',
-    'arguments' => [
-      'migration' => 'ID of migration to reset',
-    ],
-    'drupal dependencies' => ['migrate_tools'],
-    'aliases' => ['mrs'],
-  ];
-
-  $items['migrate-messages'] = [
-    'description' => 'View any messages associated with a migration.',
-    'arguments' => [
-      'migration' => 'ID of the migration',
-    ],
-    'options' => [
-      'csv' => 'Export messages as a CSV',
-    ],
-    'examples' => [
-      'migrate-messages MyNode' => 'Show all messages for the MyNode migration',
-    ],
-    'drupal dependencies' => ['migrate_tools'],
-    'aliases' => ['mmsg'],
-  ];
-
-  $items['migrate-fields-source'] = [
-    'description' => 'List the fields available for mapping in a source.',
-    'arguments' => [
-      'migration' => 'ID of the migration',
-    ],
-    'examples' => [
-      'migrate-fields-source my_node' => 'List fields for the source in the my_node migration',
-    ],
-    'drupal dependencies' => ['migrate_tools'],
-    'aliases' => ['mfs'],
-  ];
-
-  return $items;
-}
-
-/**
- * Display migration status.
- *
- * @param string $migration_names
- *   The migration names.
- */
-function drush_migrate_tools_migrate_status($migration_names = '') {
-  $names_only = drush_get_option('names-only');
-
-  $migrations = drush_migrate_tools_migration_list($migration_names);
-
-  $table = [];
-  // Take it one group at a time, listing the migrations within each group.
-  foreach ($migrations as $group_id => $migration_list) {
-    $group = MigrationGroup::load($group_id);
-    $group_name = !empty($group) ? "{$group->label()} ({$group->id()})" : $group_id;
-    if ($names_only) {
-      $table[] = [
-        dt('Group: @name', ['@name' => $group_name]),
-      ];
-    }
-    else {
-      $table[] = [
-        dt('Group: @name', ['@name' => $group_name]),
-        dt('Status'),
-        dt('Total'),
-        dt('Imported'),
-        dt('Unprocessed'),
-        dt('Last imported'),
-      ];
-    }
-    foreach ($migration_list as $migration_id => $migration) {
-      try {
-        $map = $migration->getIdMap();
-        $imported = $map->importedCount();
-        $source_plugin = $migration->getSourcePlugin();
-      }
-      catch (Exception $e) {
-        drush_log(dt('Failure retrieving information on @migration: @message',
-          ['@migration' => $migration_id, '@message' => $e->getMessage()]));
-        continue;
-      }
-      if ($names_only) {
-        $table[] = [$migration_id];
-      }
-      else {
-        try {
-          $source_rows = $source_plugin->count();
-          // -1 indicates uncountable sources.
-          if ($source_rows == -1) {
-            $source_rows = dt('N/A');
-            $unprocessed = dt('N/A');
-          }
-          else {
-            $unprocessed = $source_rows - $map->processedCount();
-          }
-        }
-        catch (Exception $e) {
-          drush_print($e->getMessage());
-          drush_log(dt('Could not retrieve source count from @migration: @message',
-            ['@migration' => $migration_id, '@message' => $e->getMessage()]));
-          $source_rows = dt('N/A');
-          $unprocessed = dt('N/A');
-        }
-
-        $status = $migration->getStatusLabel();
-        $migrate_last_imported_store = \Drupal::keyValue('migrate_last_imported');
-        $last_imported = $migrate_last_imported_store->get($migration->id(), FALSE);
-        if ($last_imported) {
-          /** @var \Drupal\Core\Datetime\DateFormatter $date_formatter */
-          $date_formatter = \Drupal::service('date.formatter');
-          $last_imported = $date_formatter->format($last_imported / 1000,
-            'custom', 'Y-m-d H:i:s');
-        }
-        else {
-          $last_imported = '';
-        }
-        $table[] = [
-          $migration_id,
-          $status,
-          $source_rows,
-          $imported,
-          $unprocessed,
-          $last_imported,
-        ];
-      }
-    }
-  }
-  drush_print_table($table);
-}
-
-/**
- * Import a migration.
- *
- * @param string $migration_names
- *   The migration names.
- */
-function drush_migrate_tools_migrate_import($migration_names = '') {
-  $group_names = drush_get_option('group');
-  $tag_names = drush_get_option('tag');
-  $all = drush_get_option('all');
-
-  // Display a depreciation message if "mi" alias is used.
-  $args = drush_get_arguments();
-  if ($args[0] === 'mi') {
-    drush_log('The \'mi\' alias is deprecated and will no longer work with Drush 9. Consider the use of \'mim\' alias instead.', 'warning');
-  }
-
-  $options = [];
-  if (!$all && !$group_names && !$migration_names && !$tag_names) {
-    drush_set_error('MIGRATE_ERROR', dt('You must specify --all, --group, --tag or one or more migration names separated by commas'));
-    return;
-  }
-
-  foreach (['limit', 'feedback', 'idlist', 'update', 'force'] as $option) {
-    if (drush_get_option($option)) {
-      $options[$option] = drush_get_option($option);
-    }
-  }
-
-  $migrations = drush_migrate_tools_migration_list($migration_names);
-  if (empty($migrations)) {
-    drush_log(dt('No migrations found.'), 'error');
-  }
-
-  // Take it one group at a time, importing the migrations within each group.
-  foreach ($migrations as $group_id => $migration_list) {
-    array_walk($migration_list, '_drush_migrate_tools_execute_migration', $options);
-  }
-}
-
-/**
- * Executes a single migration.
- *
- * If the --execute-dependencies option was given, the migration's dependencies
- * will also be executed first.
- *
- * @param \Drupal\migrate\Plugin\MigrationInterface $migration
- *   The migration to execute.
- * @param string $migration_id
- *   The migration ID (not used, just an artifact of array_walk()).
- * @param array $options
- *   Additional options for the migration.
- */
-function _drush_migrate_tools_execute_migration(MigrationInterface $migration, $migration_id, array $options = []) {
-  $log = new DrushLogMigrateMessage();
-
-  if (drush_get_option('execute-dependencies')) {
-    if ($required_IDS = $migration->get('requirements')) {
-      $manager = \Drupal::service('plugin.manager.migration');
-      $required_migrations = $manager->createInstances($required_IDS);
-      $dependency_options = array_merge($options, ['is_dependency' => TRUE]);
-      array_walk($required_migrations, __FUNCTION__, $dependency_options);
-    }
-  }
-  if (!empty($options['force'])) {
-    $migration->set('requirements', []);
-  }
-  if (!empty($options['update'])) {
-    $migration->getIdMap()->prepareUpdate();
-  }
-  $executable = new MigrateExecutable($migration, $log, $options);
-  // Function drush_op() provides --simulate support.
-  drush_op([$executable, 'import']);
-  if ($count = $executable->getFailedCount()) {
-    // Nudge Drush to use a non-zero exit code.
-    drush_set_error('MIGRATE_ERROR', dt('!name Migration - !count failed.', [
-      '!name' => $migration_id,
-      '!count' => $count,
-    ]));
-  }
-}
-
-/**
- * Rollback migrations.
- *
- * @param string $migration_names
- *   The migration names.
- */
-function drush_migrate_tools_migrate_rollback($migration_names = '') {
-  $group_names = drush_get_option('group');
-  $tag_names = drush_get_option('tag');
-  $all = drush_get_option('all');
-  $options = [];
-  if (!$all && !$group_names && !$migration_names && !$tag_names) {
-    drush_set_error('MIGRATE_ERROR', dt('You must specify --all, --group, --tag, or one or more migration names separated by commas'));
-    return;
-  }
-
-  if (drush_get_option('feedback')) {
-    $options['feedback'] = drush_get_option('feedback');
-  }
-
-  $log = new DrushLogMigrateMessage();
-
-  $migrations = drush_migrate_tools_migration_list($migration_names);
-  if (empty($migrations)) {
-    drush_log(dt('No migrations found.'), 'error');
-  }
-
-  // Take it one group at a time, rolling back the migrations within each group.
-  foreach ($migrations as $group_id => $migration_list) {
-    // Roll back in reverse order.
-    $migration_list = array_reverse($migration_list);
-    foreach ($migration_list as $migration_id => $migration) {
-      $executable = new MigrateExecutable($migration, $log, $options);
-      // drush_op() provides --simulate support.
-      drush_op([$executable, 'rollback']);
-    }
-  }
-}
-
-/**
- * Stop a migration.
- *
- * @param string $migration_id
- *   The migration id.
- */
-function drush_migrate_tools_migrate_stop($migration_id = '') {
-  /** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
-  $migration = \Drupal::service('plugin.manager.migration')
-    ->createInstance($migration_id);
-  if ($migration) {
-    $status = $migration->getStatus();
-    switch ($status) {
-      case MigrationInterface::STATUS_IDLE:
-        drush_log(dt('Migration @id is idle', ['@id' => $migration_id]), 'warning');
-        break;
-      case MigrationInterface::STATUS_DISABLED:
-        drush_log(dt('Migration @id is disabled', ['@id' => $migration_id]), 'warning');
-        break;
-      case MigrationInterface::STATUS_STOPPING:
-        drush_log(dt('Migration @id is already stopping', ['@id' => $migration_id]), 'warning');
-        break;
-      default:
-        $migration->interruptMigration(MigrationInterface::RESULT_STOPPED);
-        drush_log(dt('Migration @id requested to stop', ['@id' => $migration_id]), 'success');
-        break;
-    }
-  }
-  else {
-    drush_log(dt('Migration @id does not exist', ['@id' => $migration_id]), 'error');
-  }
-}
-
-/**
- * Reset status.
- *
- * @param string $migration_id
- *   The migration id.
- */
-function drush_migrate_tools_migrate_reset_status($migration_id = '') {
-  /** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
-  $migration = \Drupal::service('plugin.manager.migration')
-    ->createInstance($migration_id);
-  if ($migration) {
-    $status = $migration->getStatus();
-    if ($status == MigrationInterface::STATUS_IDLE) {
-      drush_log(dt('Migration @id is already Idle', ['@id' => $migration_id]), 'warning');
-    }
-    else {
-      $migration->setStatus(MigrationInterface::STATUS_IDLE);
-      drush_log(dt('Migration @id reset to Idle', ['@id' => $migration_id]), 'status');
-    }
-  }
-  else {
-    drush_log(dt('Migration @id does not exist', ['@id' => $migration_id]), 'error');
-  }
-}
-
-/**
- * Print messages.
- *
- * @param string $migration_id
- *   The migration id.
- */
-function drush_migrate_tools_migrate_messages($migration_id) {
-  /** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
-  $migration = \Drupal::service('plugin.manager.migration')
-    ->createInstance($migration_id);
-  if ($migration) {
-    $map = $migration->getIdMap();
-    $first = TRUE;
-    $table = [];
-    foreach ($map->getMessageIterator() as $row) {
-      unset($row->msgid);
-      if ($first) {
-        // @todo: Ideally, replace sourceid* with source key names. Or, should
-        // getMessageIterator() do that?
-        foreach ($row as $column => $value) {
-          $table[0][] = $column;
-        }
-        $first = FALSE;
-      }
-      $table[] = (array) $row;
-    }
-    if (empty($table)) {
-      drush_log(dt('No messages for this migration'), 'status');
-    }
-    else {
-      if (drush_get_option('csv')) {
-        foreach ($table as $row) {
-          fputcsv(STDOUT, $row);
-        }
-      }
-      else {
-        $widths = [];
-        foreach ($table[0] as $header) {
-          $widths[] = strlen($header) + 1;
-        }
-        drush_print_table($table, TRUE, $widths);
-      }
-    }
-  }
-  else {
-    drush_log(dt('Migration @id does not exist', ['@id' => $migration_id]), 'error');
-  }
-}
-
-/**
- * Print source fields.
- *
- * @param string $migration_id
- *   The migration id.
- */
-function drush_migrate_tools_migrate_fields_source($migration_id) {
-  /** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
-  $migration = \Drupal::service('plugin.manager.migration')
-    ->createInstance($migration_id);
-  if ($migration) {
-    $source = $migration->getSourcePlugin();
-    $table = [];
-    foreach ($source->fields() as $machine_name => $description) {
-      $table[] = [strip_tags($description), $machine_name];
-    }
-    drush_print_table($table);
-  }
-  else {
-    drush_log(dt('Migration @id does not exist', ['@id' => $migration_id]), 'error');
-  }
-}
-
-/**
- * Retrieve a list of active migrations.
- *
- * @param string $migration_ids
- *   Comma-separated list of migrations - if present, return only these
- *   migrations.
- *
- * @return \Drupal\migrate\Plugin\MigrationInterface[][]
- *   An array keyed by migration group, each value containing an array of
- *   migrations or an empty array if no migrations match the input criteria.
- */
-function drush_migrate_tools_migration_list($migration_ids = '') {
-  // Filter keys must match the migration configuration property name.
-  $filter['migration_group'] = drush_get_option('group') ? explode(',', drush_get_option('group')) : [];
-  $filter['migration_tags'] = drush_get_option('tag') ? explode(',', drush_get_option('tag')) : [];
-
-  $manager = \Drupal::service('plugin.manager.migration');
-  $plugins = $manager->createInstances([]);
-  $matched_migrations = [];
-
-  // Get the set of migrations that may be filtered.
-  if (empty($migration_ids)) {
-    $matched_migrations = $plugins;
-  }
-  else {
-    // Get the requested migrations.
-    $migration_ids = explode(',', Unicode::strtolower($migration_ids));
-    foreach ($plugins as $id => $migration) {
-      if (in_array(Unicode::strtolower($id), $migration_ids)) {
-        $matched_migrations[$id] = $migration;
-      }
-    }
-  }
-
-  // Do not return any migrations which fail to meet requirements.
-  /** @var \Drupal\migrate\Plugin\Migration $migration */
-  foreach ($matched_migrations as $id => $migration) {
-    if ($migration->getSourcePlugin() instanceof RequirementsInterface) {
-      try {
-        $migration->getSourcePlugin()->checkRequirements();
-      }
-      catch (RequirementsException $e) {
-        unset($matched_migrations[$id]);
-      }
-    }
-  }
-
-  // Filters the matched migrations if a group or a tag has been input.
-  if (!empty($filter['migration_group']) || !empty($filter['migration_tags'])) {
-    // Get migrations in any of the specified groups and with any of the
-    // specified tags.
-    foreach ($filter as $property => $values) {
-      if (!empty($values)) {
-        $filtered_migrations = [];
-        foreach ($values as $search_value) {
-          foreach ($matched_migrations as $id => $migration) {
-            // Cast to array because migration_tags can be an array.
-            $configured_values = (array) $migration->get($property);
-            $configured_id = (in_array($search_value, $configured_values)) ? $search_value : 'default';
-            if (empty($search_value) || $search_value == $configured_id) {
-              if (empty($migration_ids) || in_array(Unicode::strtolower($id), $migration_ids)) {
-                $filtered_migrations[$id] = $migration;
-              }
-            }
-          }
-        }
-        $matched_migrations = $filtered_migrations;
-      }
-    }
-  }
-
-  // Sort the matched migrations by group.
-  if (!empty($matched_migrations)) {
-    foreach ($matched_migrations as $id => $migration) {
-      $configured_group_id = empty($migration->get('migration_group')) ? 'default' : $migration->get('migration_group');
-      $migrations[$configured_group_id][$id] = $migration;
-    }
-  }
-  return isset($migrations) ? $migrations : [];
-}
diff --git a/web/modules/migrate_tools/migrate_tools.info.yml b/web/modules/migrate_tools/migrate_tools.info.yml
index e3cb18da29ec395afa91997e5ad162061d055c74..ef848aa5f41c23358344cf6d1ba597c4a2882b04 100644
--- a/web/modules/migrate_tools/migrate_tools.info.yml
+++ b/web/modules/migrate_tools/migrate_tools.info.yml
@@ -2,15 +2,16 @@ type: module
 name: Migrate Tools
 description: 'Tools to assist in developing and running migrations.'
 package: Migration
-# core: 8.x
+core: 8.x
+core_version_requirement: ^8 || ^9
+configure: entity.migration_group.list
 dependencies:
   - drupal:migrate (>=8.3)
   - migrate_plus:migrate_plus
 test_dependencies:
   - migrate_source_csv:migrate_source_csv (>=8.x-2.2)
 
-# Information added by Drupal.org packaging script on 2018-08-27
-version: '8.x-4.0'
-core: '8.x'
+# Information added by Drupal.org packaging script on 2020-04-30
+version: '8.x-5.0'
 project: 'migrate_tools'
-datestamp: 1535380087
+datestamp: 1588260533
diff --git a/web/modules/migrate_tools/migrate_tools.links.task.yml b/web/modules/migrate_tools/migrate_tools.links.task.yml
index da508a0ff0e2a1c8bd54cc75615d879a3c5a06f5..df0ae0e0185ac0631e7d2c4e2510eea73ee5454c 100644
--- a/web/modules/migrate_tools/migrate_tools.links.task.yml
+++ b/web/modules/migrate_tools/migrate_tools.links.task.yml
@@ -40,14 +40,7 @@ entity.migration.delete_form:
   title: Delete
   weight: 10
 
-
 entity.migration.overview_general_edit:
   title: Overview
   route_name: entity.migration.edit_form
   parent_id: entity.migration.edit_form
-
-entity.migration.overview_source_edit:
-  title: Source
-  route_name: migrate_tools.source_csv
-  parent_id: entity.migration.edit_form
-  weight: 1
diff --git a/web/modules/migrate_tools/migrate_tools.module b/web/modules/migrate_tools/migrate_tools.module
index b8113fe3d8b2b29514a1a3d585ebaf48742a8aca..6e4dde593f89c323f82cbf4017af9181f2203ec7 100644
--- a/web/modules/migrate_tools/migrate_tools.module
+++ b/web/modules/migrate_tools/migrate_tools.module
@@ -28,31 +28,3 @@ function migrate_tools_entity_type_build(array &$entity_types) {
     ->setLinkTemplate('edit-form', '/admin/structure/migrate/manage/{migration_group}')
     ->setLinkTemplate('delete-form', '/admin/structure/migrate/manage/{migration_group}/delete');
 }
-
-/**
- * Implements hook_migration_plugins_alter().
- */
-function migrate_tools_migration_plugins_alter(array &$migrations) {
-  /** @var \Drupal\Core\TempStore\PrivateTempStoreFactory $store */
-  $tempStoreFactory = \Drupal::service('tempstore.private');
-  $store = $tempStoreFactory->get('migrate_tools');
-  // TODO: remove work-around after
-  // https://www.drupal.org/project/drupal/issues/2860341 is fixed.
-  if (!\Drupal::request()->hasSession()) {
-    $session = \Drupal::service('session');
-    \Drupal::request()->setSession($session);
-    $session->start();
-  }
-  // Get the list of changed migrations.
-  $migrationsChanged = $store->get('migrations_changed');
-  if (isset($store) && (is_array($migrationsChanged))) {
-    // Alter the source column names for each changed migration.
-    foreach ($migrationsChanged as $id) {
-      $data = $store->get($id);
-      if (isset($data['changed'])) {
-        $migrations[$id]['source']['column_names'] = $data['changed'];
-      }
-    }
-  }
-
-}
diff --git a/web/modules/migrate_tools/migrate_tools.routing.yml b/web/modules/migrate_tools/migrate_tools.routing.yml
index 08f43e5b58488576af25fac906cd8a4cdfe97574..bc9f866a33404a359b35e56f2baca3a913f91064 100644
--- a/web/modules/migrate_tools/migrate_tools.routing.yml
+++ b/web/modules/migrate_tools/migrate_tools.routing.yml
@@ -157,7 +157,7 @@ migrate_tools.messages:
   path: '/admin/structure/migrate/manage/{migration_group}/migrations/{migration}/messages'
   defaults:
     _controller: '\Drupal\migrate_tools\Controller\MessageController::overview'
-    _title: 'Messages'
+    _title_callback: '\Drupal\migrate_tools\Controller\MessageController::title'
     _migrate_group: true
   requirements:
     _permission: 'administer migrations'
@@ -181,17 +181,3 @@ migrate_tools.execute:
         type: entity:migration
       migration_group:
         type: entity:migration_group
-migrate_tools.source_csv:
-  path: '/admin/structure/migrate/manage/{migration_group}/migrations/{migration}/source/edit'
-  defaults:
-    _form: 'Drupal\migrate_tools\Form\SourceCsvForm'
-    _migrate_group: true
-  requirements:
-    _permission: 'administer migrations'
-    _custom_access: 'Drupal\migrate_tools\Form\SourceCsvForm::access'
-  options:
-    parameters:
-      migration:
-        type: entity:migration
-      migration_group:
-        type: entity:migration_group
diff --git a/web/modules/migrate_tools/migrate_tools.services.yml b/web/modules/migrate_tools/migrate_tools.services.yml
index fa61e007f537ee1863af5c91f567664f288ce344..50abdd21d1d96ea63aaa4f359e3c3172f1f7ca8b 100644
--- a/web/modules/migrate_tools/migrate_tools.services.yml
+++ b/web/modules/migrate_tools/migrate_tools.services.yml
@@ -7,3 +7,14 @@ services:
     class: Drupal\migrate_tools\Routing\RouteProcessor
     tags:
     - { name: route_processor_outbound }
+  migrate_tools.migration_drush_command_progress:
+    class: Drupal\migrate_tools\EventSubscriber\MigrationDrushCommandProgress
+    tags:
+      - { name: event_subscriber }
+    arguments: ['@logger.channel.migrate_tools']
+  migrate_tools.migration_sync:
+    class: Drupal\migrate_tools\EventSubscriber\MigrationImportSync
+    tags:
+      - { name: event_subscriber }
+    arguments:
+      - '@event_dispatcher'
diff --git a/web/modules/migrate_tools/phpcs.xml b/web/modules/migrate_tools/phpcs.xml
deleted file mode 100644
index a18054efbc2dc47c2595364bb13a992a0fffbb4a..0000000000000000000000000000000000000000
--- a/web/modules/migrate_tools/phpcs.xml
+++ /dev/null
@@ -1,207 +0,0 @@
-<?xml version="1.0"?>
-<ruleset name="Drupal coding standards">
-  <description>Drupal 8 coding standards</description>
-
-  <file>.</file>
-  <arg name="extensions" value="inc,install,module,php,profile,test,theme"/>
-
-  <!--Exclude third party code.-->
-  <exclude-pattern>./vendor/*</exclude-pattern>
-  <!--Run Drupal standards.-->
-  <rule ref="Drupal.Array"/>
-  <rule ref="Drupal.Classes"/>
-  <rule ref="Drupal.Commenting">
-    <!-- TagsNotGrouped and ParamGroup have false-positives.
-      @see https://www.drupal.org/node/2060925 -->
-    <exclude name="Drupal.Commenting.DocComment.TagsNotGrouped"/>
-    <exclude name="Drupal.Commenting.DocComment.ParamGroup"/>
-  </rule>
-  <rule ref="Drupal.ControlStructures"/>
-  <rule ref="Drupal.CSS"/>
-  <rule ref="Drupal.Files"/>
-  <rule ref="Drupal.Formatting"/>
-  <rule ref="Drupal.Functions"/>
-  <rule ref="Drupal.InfoFiles"/>
-  <rule ref="Drupal.Methods"/>
-  <rule ref="Drupal.NamingConventions"/>
-  <rule ref="Drupal.Scope"/>
-  <rule ref="Drupal.Semantics"/>
-  <rule ref="Drupal.Strings"/>
-  <rule ref="Drupal.WhiteSpace"/>
-
-  <!-- Drupal Practice sniffs -->
-  <rule ref="DrupalPractice.Commenting"/>
-
-  <!-- Generic sniffs -->
-  <rule ref="Generic.Arrays.DisallowLongArraySyntax"/>
-  <rule ref="Generic.Files.ByteOrderMark"/>
-  <rule ref="Generic.Files.LineEndings"/>
-  <rule ref="Generic.Formatting.SpaceAfterCast"/>
-  <rule ref="Generic.Functions.FunctionCallArgumentSpacing"/>
-  <rule ref="Generic.Functions.OpeningFunctionBraceKernighanRitchie">
-    <properties>
-      <property name="checkClosures" value="true"/>
-    </properties>
-  </rule>
-  <rule ref="Generic.NamingConventions.ConstructorName"/>
-  <rule ref="Generic.NamingConventions.UpperCaseConstantName"/>
-  <rule ref="Generic.PHP.DeprecatedFunctions"/>
-  <rule ref="Generic.PHP.DisallowShortOpenTag"/>
-  <rule ref="Generic.PHP.LowerCaseKeyword"/>
-  <rule ref="Generic.PHP.UpperCaseConstant"/>
-  <rule ref="Generic.WhiteSpace.DisallowTabIndent"/>
-
-  <!-- MySource sniffs -->
-  <rule ref="MySource.Debug.DebugCode"/>
-
-  <!-- PEAR sniffs -->
-  <rule ref="PEAR.Files.IncludingFile"/>
-  <!-- Disable some error messages that we do not want. -->
-  <rule ref="PEAR.Files.IncludingFile.UseIncludeOnce">
-    <severity>0</severity>
-  </rule>
-  <rule ref="PEAR.Files.IncludingFile.UseInclude">
-    <severity>0</severity>
-  </rule>
-  <rule ref="PEAR.Files.IncludingFile.UseRequireOnce">
-    <severity>0</severity>
-  </rule>
-  <rule ref="PEAR.Files.IncludingFile.UseRequire">
-    <severity>0</severity>
-  </rule>
-  <rule ref="PEAR.Functions.ValidDefaultValue"/>
-
-  <!-- PEAR sniffs -->
-  <rule ref="PEAR.Functions.FunctionCallSignature"/>
-  <!-- The sniffs inside PEAR.Functions.FunctionCallSignature silenced below are
-    also silenced in Drupal CS' ruleset.xml. The code below is a 1-on-1 copy
-    from that file. -->
-  <!-- Disable some error messages that we already cover. -->
-  <rule ref="PEAR.Functions.FunctionCallSignature.SpaceAfterOpenBracket">
-    <severity>0</severity>
-  </rule>
-  <rule ref="PEAR.Functions.FunctionCallSignature.SpaceBeforeCloseBracket">
-    <severity>0</severity>
-  </rule>
-  <!-- Disable some error messages that we do not want. -->
-  <rule ref="PEAR.Functions.FunctionCallSignature.Indent">
-    <severity>0</severity>
-  </rule>
-  <rule ref="PEAR.Functions.FunctionCallSignature.ContentAfterOpenBracket">
-    <severity>0</severity>
-  </rule>
-  <rule ref="PEAR.Functions.FunctionCallSignature.CloseBracketLine">
-    <severity>0</severity>
-  </rule>
-  <rule ref="PEAR.Functions.FunctionCallSignature.EmptyLine">
-    <severity>0</severity>
-  </rule>
-
-  <!-- PSR-2 sniffs -->
-  <rule ref="PSR2.Classes.PropertyDeclaration">
-    <exclude name="PSR2.Classes.PropertyDeclaration.Underscore"/>
-  </rule>
-  <rule ref="PSR2.Namespaces.NamespaceDeclaration"/>
-  <rule ref="PSR2.Namespaces.UseDeclaration">
-    <exclude name="PSR2.Namespaces.UseDeclaration.UseAfterNamespace"/>
-  </rule>
-
-  <!-- Squiz sniffs -->
-  <rule ref="Squiz.Arrays.ArrayBracketSpacing"/>
-  <rule ref="Squiz.Arrays.ArrayDeclaration">
-    <exclude name="Squiz.Arrays.ArrayDeclaration.NoKeySpecified"/>
-    <exclude name="Squiz.Arrays.ArrayDeclaration.KeySpecified"/>
-  </rule>
-  <!-- Disable some error messages that we do not want. -->
-  <rule ref="Squiz.Arrays.ArrayDeclaration.CloseBraceNotAligned">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Arrays.ArrayDeclaration.DoubleArrowNotAligned">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Arrays.ArrayDeclaration.FirstValueNoNewline">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Arrays.ArrayDeclaration.KeyNotAligned">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Arrays.ArrayDeclaration.MultiLineNotAllowed">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Arrays.ArrayDeclaration.NoComma">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Arrays.ArrayDeclaration.NoCommaAfterLast">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Arrays.ArrayDeclaration.NotLowerCase">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Arrays.ArrayDeclaration.SingleLineNotAllowed">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Arrays.ArrayDeclaration.ValueNotAligned">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Arrays.ArrayDeclaration.ValueNoNewline">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.ControlStructures.ForEachLoopDeclaration"/>
-  <!-- Disable some error messages that we already cover. -->
-  <rule ref="Squiz.ControlStructures.ForEachLoopDeclaration.AsNotLower">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.ControlStructures.ForEachLoopDeclaration.SpaceAfterOpen">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.ControlStructures.ForEachLoopDeclaration.SpaceBeforeClose">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.ControlStructures.ForLoopDeclaration"/>
-  <!-- Disable some error messages that we already cover. -->
-  <rule ref="Squiz.ControlStructures.ForLoopDeclaration.SpacingAfterOpen">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.ControlStructures.ForLoopDeclaration.SpacingBeforeClose">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Functions.MultiLineFunctionDeclaration"/>
-  <rule ref="Squiz.Functions.MultiLineFunctionDeclaration.BraceOnSameLine">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Functions.MultiLineFunctionDeclaration.ContentAfterBrace">
-    <severity>0</severity>
-  </rule>
-  <!-- Standard yet to be finalized on this (https://www.drupal.org/node/1539712). -->
-  <rule ref="Squiz.Functions.MultiLineFunctionDeclaration.FirstParamSpacing">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Functions.MultiLineFunctionDeclaration.Indent">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Functions.MultiLineFunctionDeclaration.CloseBracketLine">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.Functions.FunctionDeclarationArgumentSpacing">
-    <properties>
-      <property name="equalsSpacing" value="1"/>
-    </properties>
-  </rule>
-  <rule ref="Squiz.Functions.FunctionDeclarationArgumentSpacing.NoSpaceBeforeArg">
-    <severity>0</severity>
-  </rule>
-  <rule ref="Squiz.PHP.LowercasePHPFunctions"/>
-  <rule ref="Squiz.Strings.ConcatenationSpacing">
-    <properties>
-      <property name="spacing" value="1"/>
-      <property name="ignoreNewlines" value="true"/>
-    </properties>
-  </rule>
-  <rule ref="Squiz.WhiteSpace.LanguageConstructSpacing" />
-  <rule ref="Squiz.WhiteSpace.SemicolonSpacing"/>
-  <rule ref="Squiz.WhiteSpace.SuperfluousWhitespace"/>
-
-  <!-- Zend sniffs -->
-  <rule ref="Zend.Files.ClosingTag"/>
-
-</ruleset>
diff --git a/web/modules/migrate_tools/src/Commands/MigrateToolsCommands.php b/web/modules/migrate_tools/src/Commands/MigrateToolsCommands.php
index 976b3c684db8784b712f308f7c8b25ad6b627100..f07e9be82e3e72c4445d355de99c380d987f2b87 100644
--- a/web/modules/migrate_tools/src/Commands/MigrateToolsCommands.php
+++ b/web/modules/migrate_tools/src/Commands/MigrateToolsCommands.php
@@ -3,7 +3,7 @@
 namespace Drupal\migrate_tools\Commands;
 
 use Consolidation\OutputFormatters\StructuredData\RowsOfFields;
-use Drupal\Component\Utility\Unicode;
+use Drupal\Component\Plugin\Exception\PluginNotFoundException;
 use Drupal\Core\Datetime\DateFormatter;
 use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
@@ -12,7 +12,9 @@
 use Drupal\migrate\Plugin\MigrationPluginManager;
 use Drupal\migrate\Plugin\RequirementsInterface;
 use Drupal\migrate_tools\Drush9LogMigrateMessage;
+use Drupal\migrate_tools\IdMapFilter;
 use Drupal\migrate_tools\MigrateExecutable;
+use Drupal\migrate_tools\MigrateTools;
 use Drush\Commands\DrushCommands;
 
 /**
@@ -88,6 +90,10 @@ public function __construct(MigrationPluginManager $migrationPluginManager, Date
    * @option group A comma-separated list of migration groups to list
    * @option tag Name of the migration tag to list
    * @option names-only Only return names, not all the details (faster)
+   * @option continue-on-failure When a migration fails, continue processing
+   *   remaining migrations.
+   *
+   * @default $options []
    *
    * @usage migrate:status
    *   Retrieve status for all migrations
@@ -118,12 +124,18 @@ public function __construct(MigrationPluginManager $migrationPluginManager, Date
    * @return \Consolidation\OutputFormatters\StructuredData\RowsOfFields
    *   Migrations status formatted as table.
    */
-  public function status($migration_names = '', array $options = ['group' => NULL, 'tag' => NULL, 'names-only' => NULL]) {
+  public function status($migration_names = '', array $options = [
+    'group' => self::REQ,
+    'tag' => self::REQ,
+    'names-only' => FALSE,
+    'continue-on-failure' => FALSE,
+  ]) {
     $names_only = $options['names-only'];
 
     $migrations = $this->migrationsList($migration_names, $options);
 
     $table = [];
+    $errors = [];
     // Take it one group at a time, listing the migrations within each group.
     foreach ($migrations as $group_id => $migration_list) {
       /** @var \Drupal\migrate_plus\Entity\MigrationGroup $group */
@@ -144,12 +156,12 @@ public function status($migration_names = '', array $options = ['group' => NULL,
             $source_plugin = $migration->getSourcePlugin();
           }
           catch (\Exception $e) {
-            $this->logger()->error(
-              dt(
-                'Failure retrieving information on @migration: @message',
-                ['@migration' => $migration_id, '@message' => $e->getMessage()]
-              )
+            $error = dt(
+              'Failure retrieving information on @migration: @message',
+              ['@migration' => $migration_id, '@message' => $e->getMessage()]
             );
+            $this->logger()->error($error);
+            $errors[] = $error;
             continue;
           }
 
@@ -212,6 +224,11 @@ public function status($migration_names = '', array $options = ['group' => NULL,
       }
     }
 
+    // If any errors occurred, throw an exception.
+    if (!empty($errors)) {
+      throw new \Exception(implode(PHP_EOL, $errors));
+    }
+
     return new RowsOfFields($table);
   }
 
@@ -231,11 +248,19 @@ public function status($migration_names = '', array $options = ['group' => NULL,
    * @option limit Limit on the number of items to process in each migration
    * @option feedback Frequency of progress messages, in items processed
    * @option idlist Comma-separated list of IDs to import
+   * @option idlist-delimiter The delimiter for records
    * @option update  In addition to processing unprocessed items from the
    *   source, update previously-imported items with the current data
    * @option force Force an operation to run, even if all dependencies are not
    *   satisfied
+   * @option continue-on-failure When a migration fails, continue processing
+   *   remaining migrations.
    * @option execute-dependencies Execute all dependent migrations first.
+   * @option skip-progress-bar Skip displaying a progress bar.
+   * @option sync Sync source and destination. Delete destination records that
+   *   do not exist in the source.
+   *
+   * @default $options []
    *
    * @usage migrate:import --all
    *   Perform all migrations
@@ -251,6 +276,8 @@ public function status($migration_names = '', array $options = ['group' => NULL,
    *   Import no more than 2 users
    * @usage migrate:import beer_user --idlist=5
    *   Import the user record with source ID 5
+   * @usage migrate:import beer_node_revision --idlist=1:2,2:3,3:5
+   *   Import the node revision record with source IDs [1,2], [2,3], and [3,5]
    *
    * @validate-module-enabled migrate_tools
    *
@@ -259,21 +286,28 @@ public function status($migration_names = '', array $options = ['group' => NULL,
    * @throws \Exception
    *   If there are not enough parameters to the command.
    */
-  public function import($migration_names = '', array $options = ['all' => NULL, 'group' => NULL, 'tag' => NULL, 'limit' => NULL, 'feedback' => NULL, 'idlist' => NULL, 'update' => NULL, 'force' => NULL, 'execute-dependencies' => NULL]) {
+  public function import($migration_names = '', array $options = [
+    'all' => FALSE,
+    'group' => self::REQ,
+    'tag' => self::REQ,
+    'limit' => self::REQ,
+    'feedback' => self::REQ,
+    'idlist' => self::REQ,
+    'idlist-delimiter' => MigrateTools::DEFAULT_ID_LIST_DELIMITER,
+    'update' => FALSE,
+    'force' => FALSE,
+    'continue-on-failure' => FALSE,
+    'execute-dependencies' => FALSE,
+    'skip-progress-bar' => FALSE,
+    'sync' => FALSE,
+  ]) {
     $group_names = $options['group'];
     $tag_names = $options['tag'];
     $all = $options['all'];
-    $additional_options = [];
     if (!$all && !$group_names && !$migration_names && !$tag_names) {
       throw new \Exception(dt('You must specify --all, --group, --tag or one or more migration names separated by commas'));
     }
 
-    foreach (['limit', 'feedback', 'idlist', 'update', 'force', 'execute-dependencies'] as $option) {
-      if ($options[$option]) {
-        $additional_options[$option] = $options[$option];
-      }
-    }
-
     $migrations = $this->migrationsList($migration_names, $options);
     if (empty($migrations)) {
       $this->logger->error(dt('No migrations found.'));
@@ -284,7 +318,7 @@ public function import($migration_names = '', array $options = ['all' => NULL, '
       array_walk(
         $migration_list,
         [$this, 'executeMigration'],
-        $additional_options
+        $options
       );
     }
   }
@@ -303,6 +337,13 @@ public function import($migration_names = '', array $options = ['all' => NULL, '
    * @option group A comma-separated list of migration groups to rollback
    * @option tag ID of the migration tag to rollback
    * @option feedback Frequency of progress messages, in items processed
+   * @option idlist Comma-separated list of IDs to rollback
+   * @option idlist-delimiter The delimiter for records
+   * @option skip-progress-bar Skip displaying a progress bar.
+   * @option continue-on-failure When a rollback fails, continue processing
+   *   remaining migrations.
+   *
+   * @default $options []
    *
    * @usage migrate:rollback --all
    *   Perform all migrations
@@ -314,6 +355,8 @@ public function import($migration_names = '', array $options = ['all' => NULL, '
    *   Rollback all migrations in the beer group and with the user tag
    * @usage migrate:rollback beer_term,beer_node
    *   Rollback imported terms and nodes
+   * @usage migrate:rollback beer_user --idlist=5
+   *   Rollback imported user record with source ID 5
    * @validate-module-enabled migrate_tools
    *
    * @aliases mr, migrate-rollback
@@ -321,19 +364,23 @@ public function import($migration_names = '', array $options = ['all' => NULL, '
    * @throws \Exception
    *   If there are not enough parameters to the command.
    */
-  public function rollback($migration_names = '', array $options = ['all' => NULL, 'group' => NULL, 'tag' => NULL, 'feedback' => NULL]) {
+  public function rollback($migration_names = '', array $options = [
+    'all' => FALSE,
+    'group' => self::REQ,
+    'tag' => self::REQ,
+    'feedback' => self::REQ,
+    'idlist' => self::REQ,
+    'idlist-delimiter' => MigrateTools::DEFAULT_ID_LIST_DELIMITER,
+    'skip-progress-bar' => FALSE,
+    'continue-on-failure' => FALSE,
+  ]) {
     $group_names = $options['group'];
     $tag_names = $options['tag'];
     $all = $options['all'];
-    $additional_options = [];
     if (!$all && !$group_names && !$migration_names && !$tag_names) {
       throw new \Exception(dt('You must specify --all, --group, --tag, or one or more migration names separated by commas'));
     }
 
-    if ($options['feedback']) {
-      $additional_options['feedback'] = $options['feedback'];
-    }
-
     $migrations = $this->migrationsList($migration_names, $options);
     if (empty($migrations)) {
       $this->logger()->error(dt('No migrations found.'));
@@ -341,17 +388,42 @@ public function rollback($migration_names = '', array $options = ['all' => NULL,
 
     // Take it one group at a time,
     // rolling back the migrations within each group.
-    foreach ($migrations as $group_id => $migration_list) {
+    $has_failure = FALSE;
+    foreach ($migrations as $migration_list) {
       // Roll back in reverse order.
       $migration_list = array_reverse($migration_list);
       foreach ($migration_list as $migration_id => $migration) {
+        if ($options['skip-progress-bar']) {
+          $migration->set('skipProgressBar', TRUE);
+        }
+        // Initialize the Synmfony Console progress bar.
+        \Drupal::service('migrate_tools.migration_drush_command_progress')->initializeProgress(
+          $this->output(),
+          $migration
+        );
         $executable = new MigrateExecutable(
           $migration,
           $this->getMigrateMessage(),
-          $additional_options
+          $options
         );
         // drush_op() provides --simulate support.
-        drush_op([$executable, 'rollback']);
+        $result = drush_op([$executable, 'rollback']);
+        if ($result == MigrationInterface::RESULT_FAILED) {
+          $has_failure = TRUE;
+          $errored_migration_id = $migration_id;
+        }
+      }
+    }
+
+    // If any rollbacks failed, throw an exception to generate exit status.
+    if ($has_failure) {
+      $error_message = dt('!name migration failed.', ['!name' => $errored_migration_id]);
+      if ($options['continue-on-failure']) {
+        $this->logger()->error($error_message);
+      }
+      else {
+        // Nudge Drush to use a non-zero exit code.
+        throw new \Exception($error_message);
       }
     }
   }
@@ -402,9 +474,9 @@ public function stop($migration_id = '') {
       }
     }
     else {
-      $this->logger()->error(
-        dt('Migration @id does not exist', ['@id' => $migration_id])
-      );
+      $error = dt('Migration @id does not exist', ['@id' => $migration_id]);
+      $this->logger()->error($error);
+      throw new \Exception($error);
     }
   }
 
@@ -439,9 +511,9 @@ public function resetStatus($migration_id = '') {
       }
     }
     else {
-      $this->logger()->error(
-        dt('Migration @id does not exist', ['@id' => $migration_id])
-      );
+      $error = dt('Migration @id does not exist', ['@id' => $migration_id]);
+      $this->logger()->error($error);
+      throw new \Exception($error);
     }
   }
 
@@ -455,7 +527,11 @@ public function resetStatus($migration_id = '') {
    *
    * @command migrate:messages
    *
-   * @option csv Export messages as a CSV
+   * @option csv Export messages as a CSV (deprecated)
+   * @option idlist Comma-separated list of IDs to import
+   * @option idlist-delimiter The delimiter for records
+   *
+   * @default $options []
    *
    * @usage migrate:messages MyNode
    *   Show all messages for the MyNode migration
@@ -466,30 +542,55 @@ public function resetStatus($migration_id = '') {
    *
    * @field-labels
    *   source_ids_hash: Source IDs Hash
+   *   source_ids: Source ID(s)
+   *   destination_ids: Destination ID(s)
    *   level: Level
    *   message: Message
-   * @default-fields source_ids_hash,level,message
+   * @default-fields source_ids_hash,source_ids,destination_ids,level,message
    *
    * @return \Consolidation\OutputFormatters\StructuredData\RowsOfFields
    *   Source fields of the given migration formatted as a table.
    */
-  public function messages($migration_id, array $options = ['csv' => NULL]) {
+  public function messages($migration_id, array $options = [
+    'csv' => FALSE,
+    'idlist' => self::REQ,
+    'idlist-delimiter' => MigrateTools::DEFAULT_ID_LIST_DELIMITER,
+  ]) {
     /** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
     $migration = $this->migrationPluginManager->createInstance(
       $migration_id
     );
     if (!$migration) {
-      $this->logger()->error(
-        dt('Migration @id does not exist', ['@id' => $migration_id])
-      );
-      return NULL;
+      $error = dt('Migration @id does not exist', ['@id' => $migration_id]);
+      $this->logger()->error($error);
+      throw new \Exception($error);
     }
-
-    $map = $migration->getIdMap();
+    $id_list = MigrateTools::buildIdList($options);
+    /** @var \Drupal\migrate\Plugin\MigrateIdMapInterface|\Drupal\migrate_tools\IdMapFilter $map */
+    $map = new IdMapFilter($migration->getIdMap(), $id_list);
+    $source_id_keys = $this->getSourceIdKeys($map);
     $table = [];
-    foreach ($map->getMessageIterator() as $row) {
+    // TODO: Remove after 8.7 support goes away.
+    $iterator_method = method_exists($map, 'getMessages') ? 'getMessages' : 'getMessageIterator';
+    foreach ($map->{$iterator_method}() as $row) {
       unset($row->msgid);
-      $table[] = (array) $row;
+      $array_row = (array) $row;
+      // If the message includes useful IDs don't print the hash.
+      if (count($source_id_keys) === count(array_intersect_key($source_id_keys, $array_row))) {
+        unset($array_row['source_ids_hash']);
+      }
+      $source_ids = $destination_ids = [];
+      foreach ($array_row as $name => $item) {
+        if (substr($name, 0, 4) === 'src_') {
+          $source_ids[$name] = $item;
+        }
+        if (substr($name, 0, 5) === 'dest_') {
+          $destination_ids[$name] = $item;
+        }
+      }
+      $array_row['source_ids'] = implode(', ', $source_ids);
+      $array_row['destination_ids'] = implode(', ', $destination_ids);
+      $table[] = $array_row;
     }
     if (empty($table)) {
       $this->logger()->notice(dt('No messages for this migration'));
@@ -501,11 +602,31 @@ public function messages($migration_id, array $options = ['csv' => NULL]) {
       foreach ($table as $row) {
         fputcsv(STDOUT, $row);
       }
+      $this->logger()->notice('--csv option is deprecated in 4.5 and is removed from 5.0. Use \'--format=csv\' instead.');
+      @trigger_error('--csv option is deprecated in migrate_tool:8.x-4.5 and is removed from migrate_tool:8.x-5.0. Use \'--format=csv\' instead. See https://www.drupal.org/node/123', E_USER_DEPRECATED);
       return NULL;
     }
     return new RowsOfFields($table);
   }
 
+  /**
+   * Get the source ID keys.
+   *
+   * @param \Drupal\migrate_tools\IdMapFilter $map
+   *   The migration ID map.
+   *
+   * @return array
+   *   The source ID keys.
+   */
+  protected function getSourceIdKeys(IdMapFilter $map) {
+    $map->rewind();
+    $columns = $map->currentSource();
+    $source_id_keys = array_map(static function ($id) {
+      return 'src_' . $id;
+    }, array_keys($columns));
+    return array_combine($source_id_keys, $source_id_keys);
+  }
+
   /**
    * List the fields available for mapping in a source.
    *
@@ -546,9 +667,9 @@ public function fieldsSource($migration_id) {
       return new RowsOfFields($table);
     }
     else {
-      $this->logger()->error(
-        dt('Migration @id does not exist', ['@id' => $migration_id])
-      );
+      $error = dt('Migration @id does not exist', ['@id' => $migration_id]);
+      $this->logger()->error($error);
+      throw new \Exception($error);
     }
   }
 
@@ -561,20 +682,16 @@ public function fieldsSource($migration_id) {
    * @param array $options
    *   Command options.
    *
+   * @default $options []
+   *
    * @return \Drupal\migrate\Plugin\MigrationInterface[][]
    *   An array keyed by migration group, each value containing an array of
    *   migrations or an empty array if no migrations match the input criteria.
    */
   protected function migrationsList($migration_ids = '', array $options = []) {
     // Filter keys must match the migration configuration property name.
-    $filter['migration_group'] = $options['group'] ? explode(
-      ',',
-      $options['group']
-    ) : [];
-    $filter['migration_tags'] = $options['tag'] ? explode(
-      ',',
-      $options['tag']
-    ) : [];
+    $filter['migration_group'] = explode(',', $options['group']);
+    $filter['migration_tags'] = explode(',', $options['tag']);
 
     $manager = $this->migrationPluginManager;
     $plugins = $manager->createInstances([]);
@@ -586,9 +703,9 @@ protected function migrationsList($migration_ids = '', array $options = []) {
     }
     else {
       // Get the requested migrations.
-      $migration_ids = explode(',', Unicode::strtolower($migration_ids));
+      $migration_ids = explode(',', mb_strtolower($migration_ids));
       foreach ($plugins as $id => $migration) {
-        if (in_array(Unicode::strtolower($id), $migration_ids)) {
+        if (in_array(mb_strtolower($id), $migration_ids)) {
           $matched_migrations[$id] = $migration;
         }
       }
@@ -597,13 +714,22 @@ protected function migrationsList($migration_ids = '', array $options = []) {
     // Do not return any migrations which fail to meet requirements.
     /** @var \Drupal\migrate\Plugin\Migration $migration */
     foreach ($matched_migrations as $id => $migration) {
-      if ($migration->getSourcePlugin() instanceof RequirementsInterface) {
-        try {
+      try {
+        if ($migration->getSourcePlugin() instanceof RequirementsInterface) {
           $migration->getSourcePlugin()->checkRequirements();
         }
-        catch (RequirementsException $e) {
+      }
+      catch (RequirementsException $e) {
+        unset($matched_migrations[$id]);
+      }
+      catch (PluginNotFoundException $exception) {
+        if ($options['continue-on-failure']) {
+          $this->logger()->error($exception->getMessage());
           unset($matched_migrations[$id]);
         }
+        else {
+          throw $exception;
+        }
       }
     }
 
@@ -617,15 +743,14 @@ protected function migrationsList($migration_ids = '', array $options = []) {
           foreach ($values as $search_value) {
             foreach ($matched_migrations as $id => $migration) {
               // Cast to array because migration_tags can be an array.
-              $configured_values = (array) $migration->get($property);
-              $configured_id = (in_array(
-                $search_value,
-                $configured_values
-              )) ? $search_value : 'default';
-              if (empty($search_value) || $search_value == $configured_id) {
+              $definition = $migration->getPluginDefinition();
+              $configured_values = (array) ($definition[$property] ?? NULL);
+              $configured_id = in_array($search_value, $configured_values, TRUE) ? $search_value : 'default';
+              if (empty($search_value) || $search_value === $configured_id) {
                 if (empty($migration_ids) || in_array(
-                    Unicode::strtolower($id),
-                    $migration_ids
+                    mb_strtolower($id),
+                    $migration_ids,
+                    TRUE
                   )) {
                   $filtered_migrations[$id] = $migration;
                 }
@@ -640,11 +765,11 @@ protected function migrationsList($migration_ids = '', array $options = []) {
     // Sort the matched migrations by group.
     if (!empty($matched_migrations)) {
       foreach ($matched_migrations as $id => $migration) {
-        $configured_group_id = empty($migration->get('migration_group')) ? 'default' : $migration->get('migration_group');
+        $configured_group_id = empty($migration->migration_group) ? 'default' : $migration->migration_group;
         $migrations[$configured_group_id][$id] = $migration;
       }
     }
-    return isset($migrations) ? $migrations : [];
+    return $migrations ?? [];
   }
 
   /**
@@ -660,6 +785,8 @@ protected function migrationsList($migration_ids = '', array $options = []) {
    * @param array $options
    *   Additional options of the command.
    *
+   * @default $options []
+   *
    * @throws \Exception
    *   If some migrations failed during execution.
    */
@@ -668,8 +795,9 @@ protected function executeMigration(MigrationInterface $migration, $migration_id
     // migration is not run multiple times.
     static $executed_migrations = [];
 
-    if (isset($options['execute-dependencies'])) {
-      $required_migrations = $migration->get('requirements');
+    if ($options['execute-dependencies']) {
+      $definition = $migration->getPluginDefinition();
+      $required_migrations = $definition['requirements'] ?? [];
       $required_migrations = array_filter($required_migrations, function ($value) use ($executed_migrations) {
         return !isset($executed_migrations[$value]);
       });
@@ -682,25 +810,62 @@ protected function executeMigration(MigrationInterface $migration, $migration_id
         $executed_migrations += $required_migrations;
       }
     }
-    if (!empty($options['force'])) {
+    if ($options['sync']) {
+      $migration->set('syncSource', TRUE);
+    }
+    if ($options['skip-progress-bar']) {
+      $migration->set('skipProgressBar', TRUE);
+    }
+    if ($options['continue-on-failure']) {
+      $migration->set('continueOnFailure', TRUE);
+    }
+    if ($options['force']) {
       $migration->set('requirements', []);
     }
-    if (!empty($options['update'])) {
-      $migration->getIdMap()->prepareUpdate();
+    if ($options['update']) {
+      if (!$options['idlist']) {
+        $migration->getIdMap()->prepareUpdate();
+      }
+      else {
+        $source_id_values_list = MigrateTools::buildIdList($options);
+        $keys = array_keys($migration->getSourcePlugin()->getIds());
+        foreach ($source_id_values_list as $source_id_values) {
+          $migration->getIdMap()->setUpdate(array_combine($keys, $source_id_values));
+        }
+      }
     }
+
+    // Initialize the Synmfony Console progress bar.
+    \Drupal::service('migrate_tools.migration_drush_command_progress')->initializeProgress(
+      $this->output(),
+      $migration
+    );
+
     $executable = new MigrateExecutable($migration, $this->getMigrateMessage(), $options);
     // drush_op() provides --simulate support.
-    drush_op([$executable, 'import']);
+    $result = drush_op([$executable, 'import']);
     $executed_migrations += [$migration_id => $migration_id];
     if ($count = $executable->getFailedCount()) {
-      // Nudge Drush to use a non-zero exit code.
-      throw new \Exception(
-        dt(
-          '!name Migration - !count failed.',
-          ['!name' => $migration_id, '!count' => $count]
-        )
+      $error_message = dt(
+        '!name Migration - !count failed.',
+        ['!name' => $migration_id, '!count' => $count]
       );
     }
+    elseif ($result == MigrationInterface::RESULT_FAILED) {
+      $error_message = dt('!name migration failed.', ['!name' => $migration_id]);
+    }
+    else {
+      $error_message = '';
+    }
+    if ($error_message) {
+      if ($options['continue-on-failure']) {
+        $this->logger()->error($error_message);
+      }
+      else {
+        // Nudge Drush to use a non-zero exit code.
+        throw new \Exception($error_message);
+      }
+    }
   }
 
   /**
diff --git a/web/modules/migrate_tools/src/Controller/MessageController.php b/web/modules/migrate_tools/src/Controller/MessageController.php
index fbd347c96d56eb9d5313e72d13991939359d8895..7b16715686d124809050d5817fb297c98f5c0326 100644
--- a/web/modules/migrate_tools/src/Controller/MessageController.php
+++ b/web/modules/migrate_tools/src/Controller/MessageController.php
@@ -104,18 +104,21 @@ public function overview(MigrationGroupInterface $migration_group, MigratePlusMi
       'field' => 'message',
     ];
 
+    $result = [];
     $message_table = $migration_plugin->getIdMap()->messageTableName();
-    $map_table = $migration_plugin->getIdMap()->mapTableName();
-    $query = $this->database->select($message_table, 'msg')
-      ->extend('\Drupal\Core\Database\Query\PagerSelectExtender')
-      ->extend('\Drupal\Core\Database\Query\TableSortExtender');
-    $query->innerJoin($map_table, 'map', 'msg.source_ids_hash=map.source_ids_hash');
-    $query->fields('msg');
-    $query->fields('map');
-    $result = $query
-      ->limit(50)
-      ->orderByHeader($header)
-      ->execute();
+    if ($this->database->schema()->tableExists($message_table)) {
+      $map_table = $migration_plugin->getIdMap()->mapTableName();
+      $query = $this->database->select($message_table, 'msg')
+        ->extend('\Drupal\Core\Database\Query\PagerSelectExtender')
+        ->extend('\Drupal\Core\Database\Query\TableSortExtender');
+      $query->innerJoin($map_table, 'map', 'msg.source_ids_hash=map.source_ids_hash');
+      $query->fields('msg');
+      $query->fields('map');
+      $result = $query
+        ->limit(50)
+        ->orderByHeader($header)
+        ->execute();
+    }
 
     foreach ($result as $message_row) {
       $column_number = 1;
@@ -141,4 +144,22 @@ public function overview(MigrationGroupInterface $migration_group, MigratePlusMi
     return $build;
   }
 
+  /**
+   * Get the title of the page.
+   *
+   * @param \Drupal\migrate_plus\Entity\MigrationGroupInterface $migration_group
+   *   The migration group.
+   * @param \Drupal\migrate_plus\Entity\MigrationInterface $migration
+   *   The $migration.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup
+   *   The translated title.
+   */
+  public function title(MigrationGroupInterface $migration_group, MigratePlusMigrationInterface $migration) {
+    return $this->t(
+      'Messages of %migration',
+      ['%migration' => $migration->label()]
+    );
+  }
+
 }
diff --git a/web/modules/migrate_tools/src/Controller/MigrationController.php b/web/modules/migrate_tools/src/Controller/MigrationController.php
index 15c90795ca5dc179cacd9044a0d888b5a517bfa3..42fc101402a18fde60107d38ccc943820ab550c7 100644
--- a/web/modules/migrate_tools/src/Controller/MigrationController.php
+++ b/web/modules/migrate_tools/src/Controller/MigrationController.php
@@ -2,18 +2,18 @@
 
 namespace Drupal\migrate_tools\Controller;
 
-use Drupal\Core\Controller\ControllerBase;
-use Drupal\Component\Utility\Xss;
 use Drupal\Component\Utility\Html;
+use Drupal\Component\Utility\Xss;
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
 use Drupal\Core\Routing\CurrentRouteMatch;
+use Drupal\Core\Url;
+use Drupal\migrate\MigrateMessage;
 use Drupal\migrate\Plugin\MigrationPluginManagerInterface;
-use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
 use Drupal\migrate_plus\Entity\MigrationGroupInterface;
 use Drupal\migrate_plus\Entity\MigrationInterface;
 use Drupal\migrate_tools\MigrateBatchExecutable;
 use Symfony\Component\DependencyInjection\ContainerInterface;
-use Drupal\Core\Url;
-use Drupal\migrate\MigrateMessage;
 
 /**
  * Returns responses for migrate_tools migration view routes.
@@ -161,8 +161,8 @@ public function source(MigrationGroupInterface $migration_group, MigrationInterf
    * @param \Drupal\migrate_plus\Entity\MigrationInterface $migration
    *   The $migration.
    *
-   * @return array
-   *   A render array as expected by drupal_render().
+   * @return \Symfony\Component\HttpFoundation\RedirectResponse|null
+   *   A redirect response if the batch is progressive. Else no return value.
    */
   public function run(MigrationGroupInterface $migration_group, MigrationInterface $migration) {
     $migrateMessage = new MigrateMessage();
@@ -172,7 +172,6 @@ public function run(MigrationGroupInterface $migration_group, MigrationInterface
     $executable = new MigrateBatchExecutable($migration_plugin, $migrateMessage, $options);
     $executable->batchImport();
 
-    $migration_group = $this->currentRouteMatch->getParameter('migration_group');
     $route_parameters = [
       'migration_group' => $migration_group,
       'migration' => $migration->id(),
@@ -211,13 +210,20 @@ public function process(MigrationGroupInterface $migration_group, MigrationInter
       $row = [];
       $row[] = ['data' => Html::escape($destination_id)];
       if (isset($process_line[0]['source'])) {
+        if (is_array($process_line[0]['source'])) {
+          $process_line[0]['source'] = implode(', ', $process_line[0]['source']);
+        }
         $row[] = ['data' => Xss::filterAdmin($process_line[0]['source'])];
       }
       else {
         $row[] = '';
       }
       if (isset($process_line[0]['plugin'])) {
-        $row[] = ['data' => Xss::filterAdmin($process_line[0]['plugin'])];
+        $process_line_plugins = [];
+        foreach ($process_line as $process_line_row) {
+          $process_line_plugins[] = Xss::filterAdmin($process_line_row['plugin']);
+        }
+        $row[] = ['data' => implode(', ', $process_line_plugins)];
       }
       else {
         $row[] = '';
diff --git a/web/modules/migrate_tools/src/Controller/MigrationListBuilder.php b/web/modules/migrate_tools/src/Controller/MigrationListBuilder.php
index d078764cea5cb26a22b91cd3becd0656968b9a55..a7f2edb515dc7798ead4aae4194011e05ff7d693 100644
--- a/web/modules/migrate_tools/src/Controller/MigrationListBuilder.php
+++ b/web/modules/migrate_tools/src/Controller/MigrationListBuilder.php
@@ -2,17 +2,16 @@
 
 namespace Drupal\migrate_tools\Controller;
 
-use Drupal\Component\Plugin\Exception\PluginException;
 use Drupal\Core\Config\Entity\ConfigEntityListBuilder;
 use Drupal\Core\Entity\EntityHandlerInterface;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Entity\EntityStorageInterface;
 use Drupal\Core\Entity\EntityTypeInterface;
-use Drupal\Core\Logger\LoggerChannelInterface;
 use Drupal\Core\Routing\CurrentRouteMatch;
+use Drupal\Core\Url;
 use Drupal\migrate\Plugin\MigrationPluginManagerInterface;
 use Drupal\migrate_plus\Entity\MigrationGroup;
-use Drupal\Core\Url;
+use Psr\Log\LoggerInterface;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
 /**
@@ -41,7 +40,7 @@ class MigrationListBuilder extends ConfigEntityListBuilder implements EntityHand
   /**
    * The logger service.
    *
-   * @var \Drupal\Core\Logger\LoggerChannelInterface
+   * @var \Psr\Log\LoggerInterface
    */
   protected $logger;
 
@@ -56,10 +55,10 @@ class MigrationListBuilder extends ConfigEntityListBuilder implements EntityHand
    *   The current route match service.
    * @param \Drupal\migrate\Plugin\MigrationPluginManagerInterface $migration_plugin_manager
    *   The plugin manager for config entity-based migrations.
-   * @param \Drupal\Core\Logger\LoggerChannelInterface $logger
+   * @param \Psr\Log\LoggerInterface $logger
    *   The logger service.
    */
-  public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $storage, CurrentRouteMatch $current_route_match, MigrationPluginManagerInterface $migration_plugin_manager, LoggerChannelInterface $logger) {
+  public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $storage, CurrentRouteMatch $current_route_match, MigrationPluginManagerInterface $migration_plugin_manager, LoggerInterface $logger) {
     parent::__construct($entity_type, $storage);
     $this->currentRouteMatch = $current_route_match;
     $this->migrationPluginManager = $migration_plugin_manager;
@@ -72,7 +71,7 @@ public function __construct(EntityTypeInterface $entity_type, EntityStorageInter
   public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
     return new static(
       $entity_type,
-      $container->get('entity.manager')->getStorage($entity_type->id()),
+      $container->get('entity_type.manager')->getStorage($entity_type->id()),
       $container->get('current_route_match'),
       $container->get('plugin.manager.migration'),
       $container->get('logger.channel.migrate_tools')
@@ -142,7 +141,7 @@ public function buildRow(EntityInterface $migration_entity) {
     try {
       /** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
       $migration = $this->migrationPluginManager->createInstance($migration_entity->id());
-      $migration_group = $migration->get('migration_group');
+      $migration_group = $migration_entity->get('migration_group');
       if (!$migration_group) {
         $migration_group = 'default';
       }
@@ -160,7 +159,7 @@ public function buildRow(EntityInterface $migration_entity) {
       $row['machine_name'] = $migration->id();
       $row['status'] = $migration->getStatusLabel();
     }
-    catch (PluginException $e) {
+    catch (\Exception $e) {
       $this->logger->warning('Migration entity id %id is malformed: %orig', ['%id' => $migration_entity->id(), '%orig' => $e->getMessage()]);
       return NULL;
     }
@@ -211,20 +210,29 @@ public function buildRow(EntityInterface $migration_entity) {
         ],
       ];
     }
-    catch (PluginException $e) {
-      // Derive the stats.
-      $row['status'] = $this->t('No data found');
-      $row['total'] = $this->t('N/A');
-      $row['imported'] = $this->t('N/A');
-      $row['unprocessed'] = $this->t('N/A');
-      $row['messages'] = $this->t('N/A');
-      $row['last_imported'] = $this->t('N/A');
-      $row['operations'] = $this->t('N/A');
+    catch (\Throwable $throwable) {
+      $this->handleThrowable($row);
     }
 
     return $row;
   }
 
+  /**
+   * Derive the row data.
+   *
+   * @param array $row
+   *   The table row.
+   */
+  protected function handleThrowable(array &$row) {
+    $row['status'] = $this->t('No data found');
+    $row['total'] = $this->t('N/A');
+    $row['imported'] = $this->t('N/A');
+    $row['unprocessed'] = $this->t('N/A');
+    $row['messages'] = $this->t('N/A');
+    $row['last_imported'] = $this->t('N/A');
+    $row['operations'] = $this->t('N/A');
+  }
+
   /**
    * Add group route parameter.
    *
diff --git a/web/modules/migrate_tools/src/DrushLogMigrateMessage.php b/web/modules/migrate_tools/src/DrushLogMigrateMessage.php
index 4f7e92494c83de372613f8f8c0ce4b934c81e4b5..964a4d99302d5dae9e63d4b1cc671ffe87bc2089 100644
--- a/web/modules/migrate_tools/src/DrushLogMigrateMessage.php
+++ b/web/modules/migrate_tools/src/DrushLogMigrateMessage.php
@@ -2,6 +2,8 @@
 
 namespace Drupal\migrate_tools;
 
+use Drupal\Core\Logger\RfcLogLevel;
+use Drupal\migrate\MigrateMessage;
 use Drupal\migrate\MigrateMessageInterface;
 
 /**
@@ -9,7 +11,7 @@
  *
  * @package Drupal\migrate_tools
  */
-class DrushLogMigrateMessage implements MigrateMessageInterface {
+class DrushLogMigrateMessage extends MigrateMessage implements MigrateMessageInterface {
 
   /**
    * Output a message from the migration.
@@ -22,7 +24,8 @@ class DrushLogMigrateMessage implements MigrateMessageInterface {
    * @see drush_log()
    */
   public function display($message, $type = 'status') {
-    drush_log($message, $type);
+    $type = isset($this->map[$type]) ? $this->map[$type] : RfcLogLevel::NOTICE;
+    \Drupal::service(('logger.channel.migrate_tools'))->log($type, $message);
   }
 
 }
diff --git a/web/modules/migrate_tools/src/EventSubscriber/MigrationDrushCommandProgress.php b/web/modules/migrate_tools/src/EventSubscriber/MigrationDrushCommandProgress.php
new file mode 100644
index 0000000000000000000000000000000000000000..64c2fedb348fa8e2ac884cd4c63ab833aa5fbb47
--- /dev/null
+++ b/web/modules/migrate_tools/src/EventSubscriber/MigrationDrushCommandProgress.php
@@ -0,0 +1,121 @@
+<?php
+
+namespace Drupal\migrate_tools\EventSubscriber;
+
+use Drupal\migrate\Event\MigrateEvents;
+use Drupal\migrate\Plugin\MigrationInterface;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\Console\Helper\ProgressBar;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Import and rollback progress bar.
+ */
+class MigrationDrushCommandProgress implements EventSubscriberInterface {
+
+  /**
+   * The logger service.
+   *
+   * @var \Psr\Log\LoggerInterface
+   */
+  protected $logger;
+
+  /**
+   * MigrationDrushCommandProgress constructor.
+   *
+   * @param \Psr\Log\LoggerInterface $logger
+   *   The logger service.
+   */
+  public function __construct(LoggerInterface $logger) {
+    $this->logger = $logger;
+  }
+
+  /**
+   * The progress bar.
+   *
+   * @var \Symfony\Component\Console\Helper\ProgressBar
+   */
+  protected $symfonyProgressBar;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    $events = [];
+    $events[MigrateEvents::POST_ROW_SAVE][] = ['updateProgressBar', -10];
+    $events[MigrateEvents::MAP_DELETE][] = ['updateProgressBar', -10];
+    $events[MigrateEvents::POST_IMPORT][] = ['clearProgress', 10];
+    $events[MigrateEvents::POST_ROLLBACK][] = ['clearProgress', 10];
+    return $events;
+  }
+
+  /**
+   * Initializes the progress bar.
+   *
+   * This must be called before the progress bar can be used.
+   *
+   * @param \Symfony\Component\Console\Output\OutputInterface $output
+   *   The output.
+   * @param \Drupal\migrate\Plugin\MigrationInterface $migration
+   *   The migration.
+   */
+  public function initializeProgress(OutputInterface $output, MigrationInterface $migration) {
+    // Don't display progress bar if explicitly disabled.
+    if (!empty($migration->skipProgressBar)) {
+      return;
+    }
+    // If the source is configured to skip counts, a progress bar is not
+    // possible.
+    if (!empty($migration->getSourceConfiguration()['skip_count'])) {
+      return;
+    }
+    try {
+      // Clone so that any generators aren't initialized prematurely.
+      $source = clone $migration->getSourcePlugin();
+      $this->symfonyProgressBar = new ProgressBar($output, $source->count());
+    }
+    catch (\Exception $exception) {
+      if (!empty($migration->continueOnFailure)) {
+        $this->logger->error($exception->getMessage());
+      }
+      else {
+        throw $exception;
+      }
+    }
+  }
+
+  /**
+   * Event callback for advancing the progress bar.
+   */
+  public function updateProgressBar() {
+    if ($this->isProgressBar()) {
+      $this->symfonyProgressBar->advance();
+    }
+  }
+
+  /**
+   * Event callback for removing the progress bar after operation is finished.
+   */
+  public function clearProgress() {
+    if ($this->isProgressBar()) {
+      $this->symfonyProgressBar->clear();
+    }
+  }
+
+  /**
+   * Determine if a progress bar should be displayed.
+   *
+   * @return bool
+   *   TRUE if a progress bar should be displayed, FALSE otherwise.
+   */
+  protected function isProgressBar() {
+    // Can't do anything if the progress bar is not initialised; this probably
+    // means we're not running as a Drush command and so we should do nothing.
+    if (!$this->symfonyProgressBar) {
+      return FALSE;
+    }
+    return TRUE;
+  }
+
+}
diff --git a/web/modules/migrate_tools/src/EventSubscriber/MigrationImportSync.php b/web/modules/migrate_tools/src/EventSubscriber/MigrationImportSync.php
new file mode 100644
index 0000000000000000000000000000000000000000..afd5242e651ca1f23a5702f5b18c59cd94d166d0
--- /dev/null
+++ b/web/modules/migrate_tools/src/EventSubscriber/MigrationImportSync.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace Drupal\migrate_tools\EventSubscriber;
+
+use Drupal\migrate\Event\MigrateEvents;
+use Drupal\migrate\Event\MigrateImportEvent;
+use Drupal\migrate\Event\MigrateRollbackEvent;
+use Drupal\migrate\Event\MigrateRowDeleteEvent;
+use Drupal\migrate\Plugin\MigrationInterface;
+use Drupal\migrate_plus\Event\MigrateEvents as MigratePlusEvents;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Import and sync source and destination.
+ */
+class MigrationImportSync implements EventSubscriberInterface {
+
+  /**
+   * The event dispatcher.
+   *
+   * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
+   */
+  protected $dispatcher;
+
+  /**
+   * MigrationImportSync constructor.
+   *
+   * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+   *   The event dispatcher.
+   */
+  public function __construct(EventDispatcherInterface $dispatcher) {
+    $this->dispatcher = $dispatcher;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    $events = [];
+    $events[MigrateEvents::PRE_IMPORT][] = ['sync'];
+    return $events;
+  }
+
+  /**
+   * Event callback to sync source and destination.
+   *
+   * @param \Drupal\migrate\Event\MigrateImportEvent $event
+   *   The migration import event.
+   */
+  public function sync(MigrateImportEvent $event) {
+    $migration = $event->getMigration();
+    if (!empty($migration->syncSource)) {
+      $id_map = $migration->getIdMap();
+      $id_map->prepareUpdate();
+      // Clone so that any generators aren't initialized prematurely.
+      $source = clone $migration->getSourcePlugin();
+      $source->rewind();
+      $source_id_values = [];
+      while ($source->valid()) {
+        $source_id_values[] = $source->current()->getSourceIdValues();
+        $source->next();
+      }
+      $id_map->rewind();
+      $destination = $migration->getDestinationPlugin();
+      while ($id_map->valid()) {
+        $map_source_id = $id_map->currentSource();
+        if (!in_array($map_source_id, $source_id_values, TRUE)) {
+          $destination_ids = $id_map->currentDestination();
+          $this->dispatchRowDeleteEvent(MigrateEvents::PRE_ROW_DELETE, $migration, $destination_ids);
+          $this->dispatchRowDeleteEvent(MigratePlusEvents::MISSING_SOURCE_ITEM, $migration, $destination_ids);
+          $destination->rollback($destination_ids);
+          $this->dispatchRowDeleteEvent(MigrateEvents::POST_ROW_DELETE, $migration, $destination_ids);
+          $id_map->delete($map_source_id);
+        }
+        $id_map->next();
+      }
+      $this->dispatcher->dispatch(MigrateEvents::POST_ROLLBACK, new MigrateRollbackEvent($migration));
+    }
+  }
+
+  /**
+   * Dispatches MigrateRowDeleteEvent event.
+   *
+   * @param string $event_name
+   *   The event name to dispatch.
+   * @param \Drupal\migrate\Plugin\MigrationInterface $migration
+   *   The active migration.
+   * @param array $destination_ids
+   *   The destination identifier values of the record.
+   */
+  protected function dispatchRowDeleteEvent($event_name, MigrationInterface $migration, array $destination_ids) {
+    // Symfony changing dispatcher so implementation could change.
+    $this->dispatcher->dispatch($event_name, new MigrateRowDeleteEvent($migration, $destination_ids));
+  }
+
+}
diff --git a/web/modules/migrate_tools/src/Form/MigrationDeleteForm.php b/web/modules/migrate_tools/src/Form/MigrationDeleteForm.php
index 593046dbaeae4b28e8d040b6294fc963c16e9edb..45cb56df126f8ace4a0241abad4d7f11d503a8bb 100644
--- a/web/modules/migrate_tools/src/Form/MigrationDeleteForm.php
+++ b/web/modules/migrate_tools/src/Form/MigrationDeleteForm.php
@@ -3,8 +3,8 @@
 namespace Drupal\migrate_tools\Form;
 
 use Drupal\Core\Entity\EntityConfirmFormBase;
-use Drupal\Core\Url;
 use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Url;
 
 /**
  * Provides the delete form for our Migration entity.
@@ -60,7 +60,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
     $this->entity->delete();
 
     // Set a message that the entity was deleted.
-    drupal_set_message(t('Migration %label was deleted.', [
+    $this->messenger()->addStatus($this->t('Migration %label was deleted.', [
       '%label' => $this->entity->label(),
     ]));
 
diff --git a/web/modules/migrate_tools/src/Form/MigrationEditForm.php b/web/modules/migrate_tools/src/Form/MigrationEditForm.php
index 214d0ae35d2e8150a3e1ce4d9743ba16112bdf15..15740c60bd1649524d7a921ac2dcc558d3a2cd53 100644
--- a/web/modules/migrate_tools/src/Form/MigrationEditForm.php
+++ b/web/modules/migrate_tools/src/Form/MigrationEditForm.php
@@ -29,7 +29,7 @@ class MigrationEditForm extends MigrationFormBase {
    */
   public function actions(array $form, FormStateInterface $form_state) {
     $actions = parent::actions($form, $form_state);
-    $actions['submit']['#value'] = t('Update Migration');
+    $actions['submit']['#value'] = $this->t('Update Migration');
 
     return $actions;
   }
diff --git a/web/modules/migrate_tools/src/Form/MigrationExecuteForm.php b/web/modules/migrate_tools/src/Form/MigrationExecuteForm.php
index d05b736ebb71b5833bdc6c9f4151e2af1e3227b9..150f7d1823519b669ce77f6f02ad447a680c85fd 100644
--- a/web/modules/migrate_tools/src/Form/MigrationExecuteForm.php
+++ b/web/modules/migrate_tools/src/Form/MigrationExecuteForm.php
@@ -2,8 +2,9 @@
 
 namespace Drupal\migrate_tools\Form;
 
-use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Routing\RouteMatchInterface;
 use Drupal\migrate\MigrateMessage;
 use Drupal\migrate\Plugin\MigrationInterface;
 use Drupal\migrate\Plugin\MigrationPluginManagerInterface;
@@ -27,9 +28,12 @@ class MigrationExecuteForm extends FormBase {
    *
    * @param \Drupal\migrate\Plugin\MigrationPluginManagerInterface $migration_plugin_manager
    *   The plugin manager for config entity-based migrations.
+   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
+   *   The current route match.
    */
-  public function __construct(MigrationPluginManagerInterface $migration_plugin_manager) {
+  public function __construct(MigrationPluginManagerInterface $migration_plugin_manager, RouteMatchInterface $route_match) {
     $this->migrationPluginManager = $migration_plugin_manager;
+    $this->routeMatch = $route_match;
   }
 
   /**
@@ -37,7 +41,8 @@ public function __construct(MigrationPluginManagerInterface $migration_plugin_ma
    */
   public static function create(ContainerInterface $container) {
     return new static(
-      $container->get('plugin.manager.migration')
+      $container->get('plugin.manager.migration'),
+      $container->get('current_route_match')
     );
   }
 
@@ -52,85 +57,105 @@ public function getFormId() {
    * {@inheritdoc}
    */
   public function buildForm(array $form, FormStateInterface $form_state) {
+    $form = $form ?: [];
 
-    $form = [];
+    /** @var \Drupal\migrate_plus\Entity\MigrationInterface $migration */
+    $migration = $this->getRouteMatch()->getParameter('migration');
+    $form['#title'] = $this->t('Execute migration %label', ['%label' => $migration->label()]);
 
-    $form['operations'] = $this->migrateMigrateOperations();
+    $form = $this->buildFormOperations($form, $form_state);
+    $form = $this->buildFormOptions($form, $form_state);
+
+    $form['submit'] = [
+      '#type' => 'submit',
+      '#value' => $this->t('Execute'),
+    ];
 
     return $form;
   }
 
   /**
-   * Get Operations.
+   * Build the operation form field.
+   *
+   * @param array $form
+   *   The execution form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   *
+   * @return array
+   *   The execution form updated with the operations.
    */
-  private function migrateMigrateOperations() {
-    // Build the 'Update options' form.
-    $form = [
-      '#type' => 'fieldset',
-      '#title' => t('Operations'),
-    ];
+  protected function buildFormOperations(array $form, FormStateInterface $form_state) {
+    // Build the migration execution form.
     $options = [
-      'import' => t('Import'),
-      'rollback' => t('Rollback'),
-      'stop' => t('Stop'),
-      'reset' => t('Reset'),
+      'import' => $this->t('Import'),
+      'rollback' => $this->t('Rollback'),
+      'stop' => $this->t('Stop'),
+      'reset' => $this->t('Reset'),
     ];
+
     $form['operation'] = [
-      '#type' => 'select',
-      '#title' => t('Choose an operation to run'),
+      '#type' => 'radios',
+      '#title' => $this->t('Operation'),
+      '#description' => $this->t('Choose an operation to run.'),
       '#options' => $options,
       '#default_value' => 'import',
       '#required' => TRUE,
+      'import' => [
+        '#description' => $this->t('Imports all previously unprocessed records from the source, plus any records marked for update, into destination Drupal objects.'),
+      ],
+      'rollback' => [
+        '#description' => $this->t('Deletes all Drupal objects created by the import.'),
+      ],
+      'stop' => [
+        '#description' => $this->t('Cleanly interrupts any import or rollback processes that may currently be running.'),
+      ],
+      'reset' => [
+        '#description' => $this->t('Sometimes a process may fail to stop cleanly, and be left stuck in an Importing or Rolling Back status. Choose Reset to clear the status and permit other operations to proceed.'),
+      ],
     ];
-    $form['submit'] = [
-      '#type' => 'submit',
-      '#value' => t('Execute'),
-    ];
-    $definitions = [];
-    $definitions[] = $this->t('Import: Imports all previously unprocessed records from the source, plus any records marked for update, into destination Drupal objects.');
-    $definitions[] = $this->t('Rollback: Deletes all Drupal objects created by the import.');
-    $definitions[] = $this->t('Stop: Cleanly interrupts any import or rollback processes that may currently be running.');
-    $definitions[] = $this->t('Reset: Sometimes a process may fail to stop cleanly, and be left stuck in an Importing or Rolling Back status. Choose Reset to clear the status and permit other operations to proceed.');
-    $form['definitions'] = [
-      '#theme' => 'item_list',
-      '#title' => $this->t('Definitions'),
-      '#list_type' => 'ul',
-      '#items' => $definitions,
-    ];
 
+    return $form;
+  }
+
+  /**
+   * Build the execution options form field.
+   *
+   * @param array $form
+   *   The execution form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   *
+   * @return array
+   *   The execution form updated with the execution options.
+   */
+  protected function buildFormOptions(array $form, FormStateInterface $form_state) {
     $form['options'] = [
-      '#type' => 'fieldset',
-      '#title' => t('Options'),
-      '#collapsible' => TRUE,
-      '#collapsed' => TRUE,
+      '#type' => 'details',
+      '#title' => $this->t('Additional execution options'),
+      '#open' => FALSE,
     ];
+
     $form['options']['update'] = [
       '#type' => 'checkbox',
-      '#title' => t('Update'),
-      '#description' => t('Check this box to update all previously-imported content
-      in addition to importing new content. Leave unchecked to only import
-      new content'),
+      '#title' => $this->t('Update'),
+      '#description' => $this->t('Check this box to update all previously-imported content in addition to importing new content. Leave unchecked to only import new content'),
     ];
+
     $form['options']['force'] = [
       '#type' => 'checkbox',
-      '#title' => t('Ignore dependencies'),
-      '#description' => t('Check this box to ignore dependencies when running imports
-      - all tasks will run whether or not their dependent tasks have
-      completed.'),
+      '#title' => $this->t('Ignore dependencies'),
+      '#description' => $this->t('Check this box to ignore dependencies when running imports - all tasks will run whether or not their dependent tasks have completed.'),
     ];
-    // @TODO: Limit is not working. Perhaps because of batch? See
-    // https://www.drupal.org/project/migrate_tools/issues/2924298.
-    return $form;
-  }
 
-  /**
-   * {@inheritdoc}
-   */
-  public function validateForm(array &$form, FormStateInterface $form_state) {
-    if (empty($form_state->getValue('operation'))) {
-      $form_state->setErrorByName('operation', $this->t('Please select an operation.'));
-      return;
-    }
+    $form['options']['limit'] = [
+      '#type' => 'number',
+      '#title' => $this->t('Limit to:'),
+      '#size' => 10,
+      '#description' => $this->t('Set a limit of how many items to process for each migration task.'),
+    ];
+
+    return $form;
   }
 
   /**
@@ -160,7 +185,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
       $force = 0;
     }
 
-    $migration = \Drupal::routeMatch()->getParameter('migration');
+    $migration = $this->getRouteMatch()->getParameter('migration');
     if ($migration) {
       /** @var \Drupal\migrate\Plugin\MigrationInterface $migration_plugin */
       $migration_plugin = $this->migrationPluginManager->createInstance($migration->id(), $migration->toArray());
diff --git a/web/modules/migrate_tools/src/Form/MigrationFormBase.php b/web/modules/migrate_tools/src/Form/MigrationFormBase.php
index 3ba6a0f55fd14ccb8044a4da8fa451a8237fa728..fc8f0eb7c0ff69c4899c89a0ef3a4e13f9eec809 100644
--- a/web/modules/migrate_tools/src/Form/MigrationFormBase.php
+++ b/web/modules/migrate_tools/src/Form/MigrationFormBase.php
@@ -3,10 +3,8 @@
 namespace Drupal\migrate_tools\Form;
 
 use Drupal\Core\Entity\EntityForm;
-use Drupal\Core\Entity\Query\QueryFactory;
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\migrate_plus\Entity\MigrationGroup;
-use Symfony\Component\DependencyInjection\ContainerInterface;
 
 /**
  * Class MigrationFormBase.
@@ -17,35 +15,6 @@
  */
 class MigrationFormBase extends EntityForm {
 
-  /**
-   * The entity query factory.
-   *
-   * @var \Drupal\Core\Entity\Query\QueryFactory
-   */
-  protected $entityQueryFactory;
-
-  /**
-   * Construct the MigrationGroupFormBase.
-   *
-   * For simple entity forms, there's no need for a constructor. Our migration
-   * form base, however, requires an entity query factory to be injected into it
-   * from the container. We later use this query factory to build an entity
-   * query for the exists() method.
-   *
-   * @param \Drupal\Core\Entity\Query\QueryFactory $query_factory
-   *   An entity query factory for the migration group entity type.
-   */
-  public function __construct(QueryFactory $query_factory) {
-    $this->entityQueryFactory = $query_factory;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function create(ContainerInterface $container) {
-    return new static($container->get('entity.query'));
-  }
-
   /**
    * Overrides Drupal\Core\Entity\EntityFormController::form().
    *
@@ -97,7 +66,7 @@ public function buildForm(array $form, FormStateInterface $form_state) {
     foreach ($groups as $group) {
       $group_options[$group->id()] = $group->label();
     }
-    if (!$migration->get('migration_group') && isset($group_options['default'])) {
+    if (!$migration->migration_group && isset($group_options['default'])) {
       $migration->set('migration_group', 'default');
     }
 
@@ -105,7 +74,7 @@ public function buildForm(array $form, FormStateInterface $form_state) {
       '#type' => 'select',
       '#title' => $this->t('Migration Group'),
       '#empty_value' => '',
-      '#default_value' => $migration->get('migration_group'),
+      '#default_value' => $migration->migration_group,
       '#options' => $group_options,
       '#description' => $this->t('Assign this migration to an existing group.'),
     ];
@@ -127,8 +96,7 @@ public function buildForm(array $form, FormStateInterface $form_state) {
    *   TRUE if this format already exists, FALSE otherwise.
    */
   public function exists($entity_id, array $element, FormStateInterface $form_state) {
-    // Use the query factory to build a new migration entity query.
-    $query = $this->entityQueryFactory->get('migration');
+    $query = $this->entityTypeManager->getStorage('migration')->getQuery();
 
     // Query the entity ID to see if its in use.
     $result = $query->condition('id', $element['#field_prefix'] . $entity_id)
@@ -150,7 +118,7 @@ public function exists($entity_id, array $element, FormStateInterface $form_stat
    *   An array of supported actions for the current entity form.
    */
   protected function actions(array $form, FormStateInterface $form_state) {
-    // Get the basic actins from the base class.
+    // Get the basic actions from the base class.
     $actions = parent::actions($form, $form_state);
 
     // Change the submit button text.
@@ -169,11 +137,11 @@ public function save(array $form, FormStateInterface $form_state) {
 
     if ($status == SAVED_UPDATED) {
       // If we edited an existing entity...
-      drupal_set_message($this->t('Migration %label has been updated.', ['%label' => $migration->label()]));
+      $this->messenger()->addStatus($this->t('Migration %label has been updated.', ['%label' => $migration->label()]));
     }
     else {
       // If we created a new entity...
-      drupal_set_message($this->t('Migration %label has been added.', ['%label' => $migration->label()]));
+      $this->messenger()->addStatus($this->t('Migration %label has been added.', ['%label' => $migration->label()]));
     }
 
     // Redirect the user back to the listing route after the save operation.
diff --git a/web/modules/migrate_tools/src/Form/MigrationGroupDeleteForm.php b/web/modules/migrate_tools/src/Form/MigrationGroupDeleteForm.php
index 98d1baf700efc203f3079dde5ce7e23c6feba6df..e50911bf5edf417d2e62b38b1e69de438210089e 100644
--- a/web/modules/migrate_tools/src/Form/MigrationGroupDeleteForm.php
+++ b/web/modules/migrate_tools/src/Form/MigrationGroupDeleteForm.php
@@ -3,8 +3,8 @@
 namespace Drupal\migrate_tools\Form;
 
 use Drupal\Core\Entity\EntityConfirmFormBase;
-use Drupal\Core\Url;
 use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Url;
 
 /**
  * Provides the delete form for our Migration Group entity.
@@ -60,7 +60,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
     $this->entity->delete();
 
     // Set a message that the entity was deleted.
-    drupal_set_message(t('Migration group %label was deleted.', [
+    $this->messenger()->addStatus($this->t('Migration group %label was deleted.', [
       '%label' => $this->entity->label(),
     ]));
 
diff --git a/web/modules/migrate_tools/src/Form/MigrationGroupEditForm.php b/web/modules/migrate_tools/src/Form/MigrationGroupEditForm.php
index 1eedc2d261d362be898bfca83f7b1e3b43366d84..551950a018dd6e74fa6872e81ae935491cc58079 100644
--- a/web/modules/migrate_tools/src/Form/MigrationGroupEditForm.php
+++ b/web/modules/migrate_tools/src/Form/MigrationGroupEditForm.php
@@ -28,7 +28,7 @@ class MigrationGroupEditForm extends MigrationGroupFormBase {
    */
   public function actions(array $form, FormStateInterface $form_state) {
     $actions = parent::actions($form, $form_state);
-    $actions['submit']['#value'] = t('Update Migration Group');
+    $actions['submit']['#value'] = $this->t('Update Migration Group');
     return $actions;
   }
 
diff --git a/web/modules/migrate_tools/src/Form/MigrationGroupFormBase.php b/web/modules/migrate_tools/src/Form/MigrationGroupFormBase.php
index d60944a759fd9c55c4fb79957851437c4fce7e7d..91d484679918dda17913ddb6bbfdfddf4da9fb07 100644
--- a/web/modules/migrate_tools/src/Form/MigrationGroupFormBase.php
+++ b/web/modules/migrate_tools/src/Form/MigrationGroupFormBase.php
@@ -3,9 +3,7 @@
 namespace Drupal\migrate_tools\Form;
 
 use Drupal\Core\Entity\EntityForm;
-use Drupal\Core\Entity\Query\QueryFactory;
 use Drupal\Core\Form\FormStateInterface;
-use Symfony\Component\DependencyInjection\ContainerInterface;
 
 /**
  * Class MigrationGroupFormBase.
@@ -16,35 +14,6 @@
  */
 class MigrationGroupFormBase extends EntityForm {
 
-  /**
-   * The query factory service.
-   *
-   * @var \Drupal\Core\Entity\Query\QueryFactory
-   */
-  protected $entityQueryFactory;
-
-  /**
-   * Construct the MigrationGroupFormBase.
-   *
-   * For simple entity forms, there's no need for a constructor. Our migration
-   * group form base, however, requires an entity query factory to be injected
-   * into it from the container. We later use this query factory to build an
-   * entity query for the exists() method.
-   *
-   * @param \Drupal\Core\Entity\Query\QueryFactory $query_factory
-   *   An entity query factory for the migration group entity type.
-   */
-  public function __construct(QueryFactory $query_factory) {
-    $this->entityQueryFactory = $query_factory;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function create(ContainerInterface $container) {
-    return new static($container->get('entity.query'));
-  }
-
   /**
    * Overrides Drupal\Core\Entity\EntityFormController::form().
    *
@@ -116,8 +85,7 @@ public function buildForm(array $form, FormStateInterface $form_state) {
    *   TRUE if this format already exists, FALSE otherwise.
    */
   public function exists($entity_id, array $element, FormStateInterface $form_state) {
-    // Use the query factory to build a new migration group entity query.
-    $query = $this->entityQueryFactory->get('migration_group');
+    $query = $this->entityTypeManager->getStorage('migration_group')->getQuery();
 
     // Query the entity ID to see if its in use.
     $result = $query->condition('id', $element['#field_prefix'] . $entity_id)
@@ -158,11 +126,11 @@ public function save(array $form, FormStateInterface $form_state) {
 
     if ($status == SAVED_UPDATED) {
       // If we edited an existing entity...
-      drupal_set_message($this->t('Migration group %label has been updated.', ['%label' => $migration_group->label()]));
+      $this->messenger()->addStatus($this->t('Migration group %label has been updated.', ['%label' => $migration_group->label()]));
     }
     else {
       // If we created a new entity...
-      drupal_set_message($this->t('Migration group %label has been added.', ['%label' => $migration_group->label()]));
+      $this->messenger()->addStatus($this->t('Migration group %label has been added.', ['%label' => $migration_group->label()]));
     }
 
     // Redirect the user back to the listing route after the save operation.
diff --git a/web/modules/migrate_tools/src/Form/SourceCsvForm.php b/web/modules/migrate_tools/src/Form/SourceCsvForm.php
deleted file mode 100644
index 40b5dc3bac4cac7bae0c814e5b75da517582e03d..0000000000000000000000000000000000000000
--- a/web/modules/migrate_tools/src/Form/SourceCsvForm.php
+++ /dev/null
@@ -1,453 +0,0 @@
-<?php
-
-namespace Drupal\migrate_tools\Form;
-
-use Drupal\Component\Plugin\Exception\PluginException;
-use Drupal\Core\Access\AccessResult;
-use Drupal\Core\Session\AccountInterface;
-use Drupal\Core\Database\Connection;
-use Drupal\Core\Form\FormBase;
-use Drupal\Core\Form\FormStateInterface;
-use Drupal\Core\Messenger\MessengerInterface;
-use Drupal\Core\TempStore\PrivateTempStoreFactory;
-use Drupal\Core\TempStore\TempStoreException;
-use Drupal\Core\Url;
-use Drupal\migrate_plus\Entity\MigrationInterface;
-use Drupal\migrate_source_csv\Plugin\migrate\source\CSV;
-use Symfony\Component\DependencyInjection\ContainerInterface;
-use Drupal\migrate\Plugin\MigrationPluginManagerInterface;
-
-/**
- * Provides an edit form for CSV source plugin column_names configuration.
- *
- * This means you can tell the migration which columns your data is in and no
- * longer edit the CSV to fit the column order set in the migration or edit the
- * migration yml itself.
- *
- * Changes made to the column configuration, or aliases, are stored in the
- * private migrate_toools private store keyed by the migration plugin id. The
- * data stored for each migrations consists of two arrays, the 'original' column
- * aliases and the 'updated' column aliases.
- *
- * An addtional list of all changed migration id is kept in the store, in the
- * key 'migrations_changed'
- *
- * Private Store Usage:
- *   migrations_changed: An array of the ids of the migrations that have been
- * changed:
- *   [migration_id]: The original and changed values for this column assignments
- *
- * Format of the source configuration saved in the store.
- * @code
- * migration_id
- *   original
- *     column_index1
- *       property 1 => label 1
- *     column_index2
- *       property 2 => label 2
- *   updated
- *     column_index1
- *       property 2 => label 2
- *     column_index2
- *       property 1 => label 1
- * @endcode
- *
- * Example source configuration.
- * @code
- * custom_migration
- *  original
- *   2
- *     title => title
- *   3
- *     body => foo
- *  updated
- *   8
- *     title => new_title
- *   9
- *     body => new_body
- * @endcode
- */
-class SourceCsvForm extends FormBase {
-
-  /**
-   * The database connection.
-   *
-   * @var \Drupal\Core\Database\Connection
-   */
-  protected $connection;
-
-  /**
-   * Plugin manager for migration plugins.
-   *
-   * @var \Drupal\migrate\Plugin\MigrationPluginManagerInterface
-   */
-  protected $migrationPluginManager;
-
-  /**
-   * The messenger service.
-   *
-   * @var \Drupal\Core\Messenger\MessengerInterface
-   */
-  protected $messenger;
-
-  /**
-   * Temporary store for column assignment changes.
-   *
-   * @var \Drupal\Core\TempStore\PrivateTempStoreFactory
-   */
-  protected $store;
-
-  /**
-   * The file object that reads the CSV file.
-   *
-   * @var \SplFileObject
-   */
-  protected $file = NULL;
-
-  /**
-   * The migration being examined.
-   *
-   * @var \Drupal\migrate\Plugin\MigrationInterface
-   */
-  protected $migration;
-
-  /**
-   * The migration plugin id.
-   *
-   * @var string
-   */
-  protected $id;
-
-  /**
-   * The array of columns names from the CSV source plugin.
-   *
-   * @var array
-   */
-  protected $columnNames;
-
-  /**
-   * An array of options for the column select form field..
-   *
-   * @var array
-   */
-  protected $options;
-
-  /**
-   * An array of modified and original column_name source plugin configuration.
-   *
-   * @var array
-   */
-  protected $sourceConfiguration;
-
-  /**
-   * Constructs new SourceCsvForm object.
-   *
-   * @param \Drupal\Core\Database\Connection $connection
-   *   The database connection.
-   * @param \Drupal\migrate\Plugin\MigrationPluginManagerInterface $migration_plugin_manager
-   *   The plugin manager for config entity-based migrations.
-   * @param \Drupal\Core\Messenger\MessengerInterface $messenger
-   *   The messenger service.
-   * @param \Drupal\Core\TempStore\PrivateTempStoreFactory $private_store
-   *   The private store.
-   */
-  public function __construct(Connection $connection, MigrationPluginManagerInterface $migration_plugin_manager, MessengerInterface $messenger, PrivateTempStoreFactory $private_store) {
-    $this->connection = $connection;
-    $this->migrationPluginManager = $migration_plugin_manager;
-    $this->messenger = $messenger;
-    $this->store = $private_store->get('migrate_tools');
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function create(ContainerInterface $container) {
-    return new static(
-      $container->get('database'),
-      $container->get('plugin.manager.migration'),
-      $container->get('messenger'),
-      $container->get('tempstore.private')
-    );
-  }
-
-  /**
-   * A custom access check.
-   *
-   * @param \Drupal\Core\Session\AccountInterface $account
-   *   Run access checks for this account.
-   * @param \Drupal\migrate_plus\Entity\MigrationInterface $migration
-   *   The $migration.
-   *
-   * @return \Drupal\Core\Access\AccessResult
-   *   Allowed or forbidden, neutral if tempstore is empty.
-   */
-  public function access(AccountInterface $account, MigrationInterface $migration) {
-    try {
-      $this->migration = $this->migrationPluginManager->createInstance($migration->id(), $migration->toArray());
-    }
-    catch (PluginException $e) {
-      return AccessResult::forbidden();
-    }
-
-    if ($this->migration) {
-      if ($source = $this->migration->getSourcePlugin()) {
-        if (is_a($source, CSV::class)) {
-          return AccessResult::allowed();
-        }
-      }
-    }
-    return AccessResult::forbidden();
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function buildForm(array $form, FormStateInterface $form_state, MigrationInterface $migration = NULL) {
-    try {
-      // @TODO: remove this horrible config work around after
-      // https://www.drupal.org/project/drupal/issues/2986665 is fixed.
-      $this->migration = $this->migrationPluginManager->createInstance($migration->id(), $migration->toArray());
-      /** @var \Drupal\migrate_source_csv\Plugin\migrate\source\CSV $source */
-      $source = $this->migration->getSourcePlugin();
-      $source->setConfiguration($migration->toArray()['source']);
-    }
-    catch (PluginException $e) {
-      return AccessResult::forbidden();
-    }
-
-    // Get the source file after the properties are initialized.
-    $source->initializeIterator();
-    $this->file = $source->getFile();
-
-    // Set the input field options to the header row values or, if there are
-    // no such values, use an indexed array.
-    if ($this->file->getHeaderRowCount() > 0) {
-      $this->options = $this->getHeaderColumnNames();
-    }
-    else {
-      for ($i = 0; $i < $this->getFileColumnCount(); $i++) {
-        $this->options[$i] = $i;
-      }
-    }
-
-    // Set the store key to the migration id.
-    $this->id = $this->migration->getPluginId();
-
-    // Get the column names from the file or from the store, if updated
-    // values are in the store.
-    $this->sourceConfiguration = $this->store->get($this->id);
-    if (isset($this->sourceConfiguration['changed'])) {
-      if ($config = $this->sourceConfiguration['changed']) {
-        $this->columnNames = $config;
-      }
-    }
-    else {
-      // Get the calculated column names. This is either the header rows or
-      // the configuration column_name value.
-      $this->columnNames = $this->file->getColumnNames();
-      if (!isset($this->sourceConfiguration['original'])) {
-        // Save as the original values.
-        $this->sourceConfiguration['original'] = $this->columnNames;
-        $this->store->set($this->id, $this->sourceConfiguration);
-      }
-    }
-    $form['#title'] = $this->t('Column Aliases');
-
-    $form['heading'] = [
-      '#type' => 'item',
-      '#title' => $this->t(':label', [':label' => $this->migration->label()]),
-      '#description' => '<p>' . $this->t('You can change the columns to be used by this migration for each source property.') . '</p>',
-    ];
-    // Create a form field for each column in this migration.
-    foreach ($this->columnNames as $index => $data) {
-      $property_name = key($data);
-      $default_value = $index;
-      $label = $this->getLabel($this->sourceConfiguration['original'], $property_name);
-
-      $description = $this->t('Select the column where the data for <em>:label</em>, property <em>:property</em>, will be found.', [
-        ':label' => $label,
-        ':property' => $property_name,
-      ]);
-      $form['aliases'][$property_name] = [
-        '#type' => 'select',
-        '#title' => $label,
-        '#description' => $description,
-        '#options' => $this->options,
-        '#default_value' => $default_value,
-      ];
-    }
-    $form['actions'] = ['#type' => 'actions'];
-    $form['actions']['submit'] = [
-      '#type' => 'submit',
-      '#button_type' => 'primary',
-      '#value' => $this->t('Submit'),
-    ];
-    $form['actions']['cancel'] = [
-      '#type' => 'submit',
-      '#value' => $this->t('Cancel'),
-      '#submit' => ['::cancel'],
-      '#limit_validation_errors' => [],
-    ];
-
-    return $form;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function validateForm(array &$form, FormStateInterface $form_state) {
-    // Display an error message if two properties have the same source column.
-    $values = [];
-    foreach ($this->columnNames as $index => $data) {
-      $property_name = key($data);
-      $value = $form_state->getValue($property_name);
-      if (in_array($value, $values)) {
-        $form_state->setErrorByName($property_name, $this->t('Source properties can not share the same source column.'));
-      }
-      $values[] = $value;
-    }
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function submitForm(array &$form, FormStateInterface $form_state) {
-    // Create a new column_names configuration.
-    $new_column_names = [];
-    foreach ($this->columnNames as $index => $data) {
-      // Keep the property name as it is used in the process pipeline.
-      $property_name = key($data);
-      // Get the new column number from the form alias field for this property.
-      $new_index = $form_state->getValue($property_name);
-      // Get the new label from the options array.
-      $new_label = $this->options[$new_index];
-      // Save using the new column number and new label.
-      $new_column_names[$new_index] = [$property_name => $new_label];
-    }
-    // Update the file columns.
-    $this->file->setColumnNames($new_column_names);
-    // Save as updated in the store.
-    $this->sourceConfiguration['changed'] = $new_column_names;
-    $this->store->set($this->id, $this->sourceConfiguration);
-
-    $changed = ($this->store->get('migrations_changed')) ? $this->store->get('migrations_changed') : [];
-    if (!in_array($this->id, $changed)) {
-      $changed[] = $this->id;
-      $this->store->set('migrations_changed', $changed);
-    }
-  }
-
-  /**
-   * Form submission handler for the 'cancel' action.
-   *
-   * @param array $form
-   *   An associative array containing the structure of the form.
-   * @param \Drupal\Core\Form\FormStateInterface $form_state
-   *   The current state of the form.
-   */
-  public function cancel(array $form, FormStateInterface $form_state) {
-    // Restore the file columns to the original settings.
-    $this->file->setColumnNames($this->sourceConfiguration['original']);
-    // Remove this migration from the store.
-    try {
-      $this->store->delete($this->id);
-    }
-    catch (TempStoreException $e) {
-      $this->messenger->addError($e->getMessage());
-    }
-
-    $migrationsChanged = $this->store->get('migrations_changed');
-    unset($migrationsChanged[$this->id]);
-    try {
-      $this->store->set('migrations_changed', $migrationsChanged);
-    }
-    catch (TempStoreException $e) {
-      $this->messenger->addError($e->getMessage());
-    }
-    $form_state->setRedirect('entity.migration_group.list');
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getFormId() {
-    return 'migrate_tools_source_csv';
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getQuestion() {}
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getCancelUrl() {
-    return new Url('entity.migration_group.list');
-  }
-
-  /**
-   * Returns the header row.
-   *
-   * Use a new file handle so that CSVFileObject::current() is not executed.
-   *
-   * @return array
-   *   The header row.
-   */
-  public function getHeaderColumnNames() {
-    $row = [];
-    $fname = $this->file->getPathname();
-    $handle = fopen($fname, 'r');
-    if ($handle) {
-      fseek($handle, $this->file->getHeaderRowCount() - 1);
-      $row = fgetcsv($handle);
-      fclose($handle);
-    }
-    return $row;
-  }
-
-  /**
-   * Returns the count of fields in the header row.
-   *
-   * Use a new file handle so that CSVFileObject::current() is not executed.
-   *
-   * @return int
-   *   The number of fields in the header row.
-   */
-  public function getFileColumnCount() {
-    $count = 0;
-    $fname = $this->file->getPathname();
-    $handle = fopen($fname, 'r');
-    if ($handle) {
-      $row = fgetcsv($handle);
-      $count = count($row);
-      fclose($handle);
-    }
-    return $count;
-  }
-
-  /**
-   * Gets the label for a given property from a column_names array.
-   *
-   * @param array $column_names
-   *   An array of column_names.
-   * @param string $property_name
-   *   The property name to find a label for.
-   *
-   * @return string
-   *   The label for this property.
-   */
-  protected function getLabel(array $column_names, $property_name) {
-    $label = '';
-    foreach ($column_names as $column) {
-      foreach ($column as $key => $value) {
-        if ($key === $property_name) {
-          $label = $value;
-          break;
-        }
-      }
-    }
-    return $label;
-  }
-
-}
diff --git a/web/modules/migrate_tools/src/IdMapFilter.php b/web/modules/migrate_tools/src/IdMapFilter.php
new file mode 100644
index 0000000000000000000000000000000000000000..46518d206dbb3a4b292c8c6bb15f2c1cbc5ca0a7
--- /dev/null
+++ b/web/modules/migrate_tools/src/IdMapFilter.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Drupal\migrate_tools;
+
+use Drupal\migrate\Plugin\MigrateIdMapInterface;
+
+/**
+ * Class to filter ID map by an ID list.
+ */
+class IdMapFilter extends \FilterIterator {
+
+  /**
+   * List of specific source IDs to import.
+   *
+   * @var array
+   */
+  protected $idList;
+
+  /**
+   * IdMapFilter constructor.
+   *
+   * @param \Drupal\migrate\Plugin\MigrateIdMapInterface $id_map
+   *   The ID map.
+   * @param array $id_list
+   *   The id list to use in the filter.
+   */
+  public function __construct(MigrateIdMapInterface $id_map, array $id_list) {
+    parent::__construct($id_map);
+    $this->idList = $id_list;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function accept() {
+    // Row is included.
+    if (empty($this->idList) || in_array(array_values($this->getInnerIterator()->currentSource()), $this->idList)) {
+      return TRUE;
+    }
+  }
+
+}
diff --git a/web/modules/migrate_tools/src/MigrateBatchExecutable.php b/web/modules/migrate_tools/src/MigrateBatchExecutable.php
index ee24f64d77352b5b4dd2bb77c8b1470c533aeb93..03e1b40741c527f2e186e0deb0d003975bba91a6 100644
--- a/web/modules/migrate_tools/src/MigrateBatchExecutable.php
+++ b/web/modules/migrate_tools/src/MigrateBatchExecutable.php
@@ -4,7 +4,6 @@
 
 use Drupal\migrate\MigrateMessage;
 use Drupal\migrate\MigrateMessageInterface;
-use Drupal\migrate\Plugin\Migration;
 use Drupal\migrate\Plugin\MigrationInterface;
 
 /**
@@ -65,10 +64,10 @@ public function __construct(MigrationInterface $migration, MigrateMessageInterfa
   /**
    * Sets the current batch content so listeners can update the messages.
    *
-   * @param array $context
+   * @param array|\DrushBatchContext $context
    *   The batch context.
    */
-  public function setBatchContext(array &$context) {
+  public function setBatchContext(&$context) {
     $this->batchContext = &$context;
   }
 
@@ -98,10 +97,10 @@ public function batchImport() {
     if (count($operations) > 0) {
       $batch = [
         'operations' => $operations,
-        'title' => t('Migrating %migrate', ['%migrate' => $this->migration->label()]),
-        'init_message' => t('Start migrating %migrate', ['%migrate' => $this->migration->label()]),
-        'progress_message' => t('Migrating %migrate', ['%migrate' => $this->migration->label()]),
-        'error_message' => t('An error occurred while migrating %migrate.', ['%migrate' => $this->migration->label()]),
+        'title' => $this->t('Migrating %migrate', ['%migrate' => $this->migration->label()]),
+        'init_message' => $this->t('Start migrating %migrate', ['%migrate' => $this->migration->label()]),
+        'progress_message' => $this->t('Migrating %migrate', ['%migrate' => $this->migration->label()]),
+        'error_message' => $this->t('An error occurred while migrating %migrate.', ['%migrate' => $this->migration->label()]),
         'finished' => '\Drupal\migrate_tools\MigrateBatchExecutable::batchFinishedImport',
       ];
 
@@ -138,13 +137,11 @@ protected function batchOperations(array $migrations, $operation, array $options
         if (!empty($dependencies['required'])) {
           $required_migrations = $this->migrationPluginManager->createInstances($dependencies['required']);
           // For dependent migrations will need to be migrate all items.
-          $dependent_options = $options;
-          $dependent_options['limit'] = 0;
-          $operations += $this->batchOperations($required_migrations, $operation, [
+          $operations = array_merge($operations, $this->batchOperations($required_migrations, $operation, [
             'limit' => 0,
             'update' => $options['update'],
             'force' => $options['force'],
-          ]);
+          ]));
         }
       }
 
@@ -164,10 +161,10 @@ protected function batchOperations(array $migrations, $operation, array $options
    *   The migration id.
    * @param array $options
    *   The batch executable options.
-   * @param array $context
+   * @param array|\DrushBatchContext $context
    *   The sandbox context.
    */
-  public static function batchProcessImport($migration_id, array $options, array &$context) {
+  public static function batchProcessImport($migration_id, array $options, &$context) {
     if (empty($context['sandbox'])) {
       $context['finished'] = 0;
       $context['sandbox'] = [];
@@ -181,6 +178,12 @@ public static function batchProcessImport($migration_id, array $options, array &
     $message = new MigrateMessage();
     /** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
     $migration = \Drupal::getContainer()->get('plugin.manager.migration')->createInstance($migration_id);
+
+    // Each batch run we need to reinitialize the counter for the migration.
+    if (!empty($options['limit']) && isset($context['results'][$migration->id()]['@numitems'])) {
+      $options['limit'] = $options['limit'] - $context['results'][$migration->id()]['@numitems'];
+    }
+
     $executable = new MigrateBatchExecutable($migration, $message, $options);
 
     if (empty($context['sandbox']['total'])) {
@@ -249,7 +252,7 @@ public static function batchFinishedImport($success, array $results, array $oper
       foreach ($results as $migration_id => $result) {
         $singular_message = "Processed 1 item (@created created, @updated updated, @failures failed, @ignored ignored) - done with '@name'";
         $plural_message = "Processed @numitems items (@created created, @updated updated, @failures failed, @ignored ignored) - done with '@name'";
-        drupal_set_message(\Drupal::translation()->formatPlural($result['@numitems'],
+        \Drupal::messenger()->addStatus(\Drupal::translation()->formatPlural($result['@numitems'],
           $singular_message,
           $plural_message,
           $result));
@@ -281,13 +284,13 @@ public function checkStatus() {
   /**
    * Calculates how much a single batch iteration will handle.
    *
-   * @param array $context
+   * @param array|\DrushBatchContext $context
    *   The sandbox context.
    *
    * @return float
    *   The batch limit.
    */
-  public function calculateBatchLimit(array $context) {
+  public function calculateBatchLimit($context) {
     // TODO Maybe we need some other more sophisticated logic here?
     return ceil($context['sandbox']['total'] / 100);
   }
diff --git a/web/modules/migrate_tools/src/MigrateExecutable.php b/web/modules/migrate_tools/src/MigrateExecutable.php
index 2282ab8cbcb0839be8d437426567805917d3c2d5..2cb067a45652567ecabd87d6e42f22e92c96caa3 100644
--- a/web/modules/migrate_tools/src/MigrateExecutable.php
+++ b/web/modules/migrate_tools/src/MigrateExecutable.php
@@ -2,19 +2,18 @@
 
 namespace Drupal\migrate_tools;
 
+use Drupal\migrate\Event\MigrateEvents;
+use Drupal\migrate\Event\MigrateImportEvent;
+use Drupal\migrate\Event\MigrateMapDeleteEvent;
+use Drupal\migrate\Event\MigrateMapSaveEvent;
 use Drupal\migrate\Event\MigratePreRowSaveEvent;
 use Drupal\migrate\Event\MigrateRollbackEvent;
 use Drupal\migrate\Event\MigrateRowDeleteEvent;
 use Drupal\migrate\MigrateExecutable as MigrateExecutableBase;
 use Drupal\migrate\MigrateMessageInterface;
-use Drupal\migrate\Plugin\MigrationInterface;
-use Drupal\migrate\MigrateSkipRowException;
 use Drupal\migrate\Plugin\MigrateIdMapInterface;
-use Drupal\migrate\Event\MigrateEvents;
+use Drupal\migrate\Plugin\MigrationInterface;
 use Drupal\migrate_plus\Event\MigrateEvents as MigratePlusEvents;
-use Drupal\migrate\Event\MigrateMapSaveEvent;
-use Drupal\migrate\Event\MigrateMapDeleteEvent;
-use Drupal\migrate\Event\MigrateImportEvent;
 use Drupal\migrate_plus\Event\MigratePrepareRowEvent;
 
 /**
@@ -104,14 +103,7 @@ public function __construct(MigrationInterface $migration, MigrateMessageInterfa
     if (isset($options['feedback'])) {
       $this->feedback = $options['feedback'];
     }
-    if (isset($options['idlist'])) {
-      if (is_string($options['idlist'])) {
-        $this->idlist = explode(',', $options['idlist']);
-        array_walk($this->idlist, function (&$value, $key) {
-          $value = explode(':', $value);
-        });
-      }
-    }
+    $this->idlist = MigrateTools::buildIdList($options);
 
     $this->listeners[MigrateEvents::MAP_SAVE] = [$this, 'onMapSave'];
     $this->listeners[MigrateEvents::MAP_DELETE] = [$this, 'onMapDelete'];
@@ -121,7 +113,7 @@ public function __construct(MigrationInterface $migration, MigrateMessageInterfa
     $this->listeners[MigrateEvents::POST_ROW_DELETE] = [$this, 'onPostRowDelete'];
     $this->listeners[MigratePlusEvents::PREPARE_ROW] = [$this, 'onPrepareRow'];
     foreach ($this->listeners as $event => $listener) {
-      \Drupal::service('event_dispatcher')->addListener($event, $listener);
+      $this->getEventDispatcher()->addListener($event, $listener);
     }
   }
 
@@ -242,7 +234,7 @@ protected function resetCounters() {
    */
   public function onPostImport(MigrateImportEvent $event) {
     $migrate_last_imported_store = \Drupal::keyValue('migrate_last_imported');
-    $migrate_last_imported_store->set($event->getMigration()->id(), round(microtime(TRUE) * 1000));
+    $migrate_last_imported_store->set($event->getMigration()->id(), round(\Drupal::time()->getCurrentMicroTime() * 1000));
     $this->progressMessage();
     $this->removeListeners();
   }
@@ -252,7 +244,7 @@ public function onPostImport(MigrateImportEvent $event) {
    */
   protected function removeListeners() {
     foreach ($this->listeners as $event => $listener) {
-      \Drupal::service('event_dispatcher')->removeListener($event, $listener);
+      $this->getEventDispatcher()->removeListener($event, $listener);
     }
   }
 
@@ -294,8 +286,14 @@ protected function progressMessage($done = TRUE) {
    *   The map event.
    */
   public function onPostRollback(MigrateRollbackEvent $event) {
+    $migrate_last_imported_store = \Drupal::keyValue('migrate_last_imported');
+    $migrate_last_imported_store->set($event->getMigration()->id(), FALSE);
     $this->rollbackMessage();
-    $this->removeListeners();
+    // If this is a sync import, then don't remove listeners or post import will
+    // not be executed. Leave it to post import to remove listeners.
+    if (empty($event->getMigration()->syncSource)) {
+      $this->removeListeners();
+    }
   }
 
   /**
@@ -363,25 +361,7 @@ public function onPostRowDelete(MigrateRowDeleteEvent $event) {
    * @throws \Drupal\migrate\MigrateSkipRowException
    */
   public function onPrepareRow(MigratePrepareRowEvent $event) {
-    if (!empty($this->idlist)) {
-      $row = $event->getRow();
-      // TODO: replace for $source_id = $row->getSourceIdValues();
-      // when https://www.drupal.org/node/2698023 is fixed.
-      $migration = $event->getMigration();
-      $source_id = array_merge(array_flip(array_keys($migration->getSourcePlugin()
-        ->getIds())), $row->getSourceIdValues());
-      $skip = TRUE;
-      foreach ($this->idlist as $item) {
-        if (array_values($source_id) == $item) {
-          $skip = FALSE;
-          break;
-        }
-      }
-      if ($skip) {
-        throw new MigrateSkipRowException(NULL, FALSE);
-      }
-    }
-    if ($this->feedback && ($this->counter) && $this->counter % $this->feedback == 0) {
+    if ($this->feedback && $this->counter && $this->counter % $this->feedback == 0) {
       $this->progressMessage(FALSE);
       $this->resetCounters();
     }
@@ -389,7 +369,20 @@ public function onPrepareRow(MigratePrepareRowEvent $event) {
     if ($this->itemLimit && ($this->itemLimitCounter + 1) >= $this->itemLimit) {
       $event->getMigration()->interruptMigration(MigrationInterface::RESULT_COMPLETED);
     }
+  }
 
+  /**
+   * {@inheritdoc}
+   */
+  protected function getSource() {
+    return new SourceFilter(parent::getSource(), $this->idlist);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getIdMap() {
+    return new IdMapFilter(parent::getIdMap(), $this->idlist);
   }
 
 }
diff --git a/web/modules/migrate_tools/src/MigrateTools.php b/web/modules/migrate_tools/src/MigrateTools.php
new file mode 100644
index 0000000000000000000000000000000000000000..18b91d8bce850d459cc08b218dcf6a3b0528f1a4
--- /dev/null
+++ b/web/modules/migrate_tools/src/MigrateTools.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Drupal\migrate_tools;
+
+/**
+ * Utility functionality for use in migrate_tools.
+ */
+class MigrateTools {
+
+  /**
+   * Default ID list delimiter.
+   */
+  const DEFAULT_ID_LIST_DELIMITER = ':';
+
+  /**
+   * Build the list of specific source IDs to import.
+   *
+   * @param array $options
+   *   The migration executable options.
+   *
+   * @return array
+   *   The ID list.
+   */
+  public static function buildIdList(array $options) {
+    $options += [
+      'idlist' => NULL,
+      'idlist-delimiter' => self::DEFAULT_ID_LIST_DELIMITER,
+    ];
+    $id_list = [];
+    if ($options['idlist']) {
+      $id_list = explode(',', $options['idlist']);
+      array_walk($id_list, function (&$value) use ($options) {
+        $value = str_getcsv($value, $options['idlist-delimiter']);
+      });
+    }
+    return $id_list;
+  }
+
+}
diff --git a/web/modules/migrate_tools/src/SourceFilter.php b/web/modules/migrate_tools/src/SourceFilter.php
new file mode 100644
index 0000000000000000000000000000000000000000..28a6f9d9e72a8762bf40021b0354adcf5c4e0c68
--- /dev/null
+++ b/web/modules/migrate_tools/src/SourceFilter.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Drupal\migrate_tools;
+
+use Drupal\migrate\Plugin\migrate\source\SourcePluginBase;
+use Drupal\migrate\Plugin\MigrateSourceInterface;
+
+/**
+ * Class to filter source by an ID list.
+ */
+class SourceFilter extends \FilterIterator {
+
+  /**
+   * List of specific source IDs to import.
+   *
+   * @var array
+   */
+  protected $idList;
+
+  /**
+   * SourceFilter constructor.
+   *
+   * @param \Drupal\migrate\Plugin\MigrateSourceInterface $source
+   *   The ID map.
+   * @param array $id_list
+   *   The id list to use in the filter.
+   */
+  public function __construct(MigrateSourceInterface $source, array $id_list) {
+    parent::__construct($source);
+    $this->idList = $id_list;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function accept() {
+    // No idlist filtering, don't filter.
+    if (empty($this->idList)) {
+      return TRUE;
+    }
+    // Some source plugins do not extend SourcePluginBase. These cannot be
+    // filtered so warn and return all values.
+    if (!$this->getInnerIterator() instanceof SourcePluginBase) {
+      trigger_error(sprintf('The source plugin %s is not an instance of %s. Extend from %s to support idlist filtering.', $this->getInnerIterator()->getPluginId(), SourcePluginBase::class, SourcePluginBase::class));
+      return TRUE;
+    }
+    // Row is included.
+    if (in_array(array_values($this->getInnerIterator()->getCurrentIds()), $this->idList)) {
+      return TRUE;
+    }
+  }
+
+}
diff --git a/web/modules/migrate_tools/tests/modules/csv_source_test/config/install/migrate_plus.migration.csv_source_test.yml b/web/modules/migrate_tools/tests/modules/csv_source_test/config/install/migrate_plus.migration.csv_source_test.yml
index 0d600a9ffc0d3e06f716b72f7606bebb0e7449b4..409948c70623ccb0500812f7c9e01ab51da02055 100644
--- a/web/modules/migrate_tools/tests/modules/csv_source_test/config/install/migrate_plus.migration.csv_source_test.yml
+++ b/web/modules/migrate_tools/tests/modules/csv_source_test/config/install/migrate_plus.migration.csv_source_test.yml
@@ -1,6 +1,5 @@
 langcode: en
 status: true
-dependencies: {  }
 id: csv_source_test
 label: Test edit of column aliases for CSV source plugin
 class: null
@@ -11,17 +10,20 @@ migration_group: csv_test
 source:
   plugin: csv
   path: 'public://test.csv'
-  header_row_count: 1
+  header_offset: 0
   enclosure: '"'
-  keys:
+  ids:
     - vid
-  column_names:
-    0:
-      vid: 'Vocabulary Id'
-    1:
-      name: 'Name'
-    2:
-      description: 'Description'
+  fields:
+    -
+      name: vid
+      label: Vocabulary Id
+    -
+      name: name
+      label: Name
+    -
+      name: description
+      label: Description
 process:
   vid: vid
   name: name
@@ -31,3 +33,7 @@ destination:
 migration_dependencies:
   required: {  }
   optional: {  }
+dependencies:
+  enforced:
+    module:
+      - csv_source_test
diff --git a/web/modules/migrate_tools/tests/modules/csv_source_test/config/install/migrate_plus.migration_group.csv_test.yml b/web/modules/migrate_tools/tests/modules/csv_source_test/config/install/migrate_plus.migration_group.csv_test.yml
index 1c2b05931e417892a7956abfe0c7bde886fccaed..c18781c86b5af10005ab1457c8fd5371d7ce8343 100644
--- a/web/modules/migrate_tools/tests/modules/csv_source_test/config/install/migrate_plus.migration_group.csv_test.yml
+++ b/web/modules/migrate_tools/tests/modules/csv_source_test/config/install/migrate_plus.migration_group.csv_test.yml
@@ -1,4 +1,8 @@
 id: csv_test
 label: CSV source plugin edit test
-description: Test editting of source plugin via the UI
+description: Test editing of source plugin via the UI
 source_type: CSV
+dependencies:
+  enforced:
+    module:
+      - csv_source_test
diff --git a/web/modules/migrate_tools/tests/modules/csv_source_test/csv_source_test.info.yml b/web/modules/migrate_tools/tests/modules/csv_source_test/csv_source_test.info.yml
index f16e6bbd79a7140bf2db9b130997eea5d900c74f..f39a7876609ff4442e70a3b60d765b9be4e7b35a 100644
--- a/web/modules/migrate_tools/tests/modules/csv_source_test/csv_source_test.info.yml
+++ b/web/modules/migrate_tools/tests/modules/csv_source_test/csv_source_test.info.yml
@@ -2,14 +2,14 @@ type: module
 name: CSV Source edit test
 description: 'Test editing of source plugin via the UI.'
 package: Testing
-# core: 8.x
+core: 8.x
+core_version_requirement: ^8 || ^9
 dependencies:
-  - drupal:migrate (>=8.3)
+  - drupal:migrate
   - migrate_plus:migrate_plus
   - migrate_plus:migrate_source_csv
 
-# Information added by Drupal.org packaging script on 2018-08-27
-version: '8.x-4.0'
-core: '8.x'
+# Information added by Drupal.org packaging script on 2020-04-30
+version: '8.x-5.0'
 project: 'migrate_tools'
-datestamp: 1535380087
+datestamp: 1588260533
diff --git a/web/modules/migrate_tools/tests/modules/migrate_tools_test/config/install/migrate_plus.migration.fruit_terms.yml b/web/modules/migrate_tools/tests/modules/migrate_tools_test/config/install/migrate_plus.migration.fruit_terms.yml
index 7f606d127f01412455ceb8dc28c4b75e0456d454..498597a524a93261da534b09847afc3d3fc69168 100644
--- a/web/modules/migrate_tools/tests/modules/migrate_tools_test/config/install/migrate_plus.migration.fruit_terms.yml
+++ b/web/modules/migrate_tools/tests/modules/migrate_tools_test/config/install/migrate_plus.migration.fruit_terms.yml
@@ -1,6 +1,5 @@
 langcode: en
 status: true
-dependencies: {  }
 id: fruit_terms
 label: Fruit Terms
 class: null
@@ -30,3 +29,7 @@ destination:
 migration_dependencies:
   required: {  }
   optional: {  }
+dependencies:
+  enforced:
+    module:
+      - migrate_tools_test
diff --git a/web/modules/migrate_tools/tests/modules/migrate_tools_test/config/install/migrate_plus.migration.invalid_plugin.yml b/web/modules/migrate_tools/tests/modules/migrate_tools_test/config/install/migrate_plus.migration.invalid_plugin.yml
new file mode 100644
index 0000000000000000000000000000000000000000..7b5656e083bf32ff67f2ea5f6eaff0038c73ef3a
--- /dev/null
+++ b/web/modules/migrate_tools/tests/modules/migrate_tools_test/config/install/migrate_plus.migration.invalid_plugin.yml
@@ -0,0 +1,22 @@
+langcode: en
+status: true
+id: invalid_plugin
+label: Invalid Plugin
+class: null
+field_plugin_method: null
+cck_plugin_method: null
+migration_tags: {  }
+migration_group: default
+source:
+  plugin: does_not_exist
+process:
+  name: name
+destination:
+  plugin: entity:taxonomy_term
+migration_dependencies:
+  required: {  }
+  optional: {  }
+dependencies:
+  enforced:
+    module:
+      - migrate_tools_test
diff --git a/web/modules/migrate_tools/tests/modules/migrate_tools_test/config/install/migrate_plus.migration.source_exception.yml b/web/modules/migrate_tools/tests/modules/migrate_tools_test/config/install/migrate_plus.migration.source_exception.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d27144fe9e1a3568f4ad7132d6286ef0fe13b2a9
--- /dev/null
+++ b/web/modules/migrate_tools/tests/modules/migrate_tools_test/config/install/migrate_plus.migration.source_exception.yml
@@ -0,0 +1,22 @@
+langcode: en
+status: true
+id: source_exception
+label: Source Exception
+class: null
+field_plugin_method: null
+cck_plugin_method: null
+migration_tags: {  }
+migration_group: default
+source:
+  plugin: migrate_exception_source_test
+process:
+  name: name
+destination:
+  plugin: entity:taxonomy_term
+migration_dependencies:
+  required: {  }
+  optional: {  }
+dependencies:
+  enforced:
+    module:
+      - migrate_tools_test
diff --git a/web/modules/migrate_tools/tests/modules/migrate_tools_test/config/install/migrate_plus.migration_group.default.yml b/web/modules/migrate_tools/tests/modules/migrate_tools_test/config/install/migrate_plus.migration_group.default.yml
index 0f35a218958af5e973b980f989daeb17b3da19f4..87889152152824cef126a9c8f23a222b21b7e3d9 100644
--- a/web/modules/migrate_tools/tests/modules/migrate_tools_test/config/install/migrate_plus.migration_group.default.yml
+++ b/web/modules/migrate_tools/tests/modules/migrate_tools_test/config/install/migrate_plus.migration_group.default.yml
@@ -1,9 +1,12 @@
 langcode: en
 status: true
-dependencies: {  }
 id: default
 label: Default
 description: ''
 source_type: ''
 module: null
 shared_configuration: null
+dependencies:
+  enforced:
+    module:
+      - migrate_tools_test
diff --git a/web/modules/migrate_tools/tests/modules/migrate_tools_test/drush.services.yml b/web/modules/migrate_tools/tests/modules/migrate_tools_test/drush.services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..31955a301b7f2c4c509be6f1d211a603a6e67ef0
--- /dev/null
+++ b/web/modules/migrate_tools/tests/modules/migrate_tools_test/drush.services.yml
@@ -0,0 +1,6 @@
+services:
+  migrate_tools_test.commands:
+    class: \Drupal\migrate_tools_test\Commands\MigrateToolsTestCommands
+    arguments: ['@plugin.manager.migration']
+    tags:
+      - { name: drush.command }
diff --git a/web/modules/migrate_tools/tests/modules/migrate_tools_test/migrate_tools_test.info.yml b/web/modules/migrate_tools/tests/modules/migrate_tools_test/migrate_tools_test.info.yml
index bbeb6986786cfa9e561f5572bf743376a79202c9..637362e65ba3ad322051860b4fe3d2a0f10ee12a 100644
--- a/web/modules/migrate_tools/tests/modules/migrate_tools_test/migrate_tools_test.info.yml
+++ b/web/modules/migrate_tools/tests/modules/migrate_tools_test/migrate_tools_test.info.yml
@@ -2,13 +2,13 @@ type: module
 name: Migrate Tools Test
 description: 'Test module to test Migrate Tools.'
 package: Testing
-# core: 8.x
+core: 8.x
+core_version_requirement: ^8 || ^9
 dependencies:
-  - drupal:migrate (>=8.3)
+  - drupal:migrate
   - migrate_plus:migrate_plus
 
-# Information added by Drupal.org packaging script on 2018-08-27
-version: '8.x-4.0'
-core: '8.x'
+# Information added by Drupal.org packaging script on 2020-04-30
+version: '8.x-5.0'
 project: 'migrate_tools'
-datestamp: 1535380087
+datestamp: 1588260533
diff --git a/web/modules/migrate_tools/tests/modules/migrate_tools_test/src/Commands/MigrateToolsTestCommands.php b/web/modules/migrate_tools/tests/modules/migrate_tools_test/src/Commands/MigrateToolsTestCommands.php
new file mode 100644
index 0000000000000000000000000000000000000000..d14fe3ae5d73ef94f9b1280410178d2b2603d8f0
--- /dev/null
+++ b/web/modules/migrate_tools/tests/modules/migrate_tools_test/src/Commands/MigrateToolsTestCommands.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\migrate_tools_test\Commands;
+
+use Drupal\migrate\MigrateMessage;
+use Drupal\migrate\Plugin\MigrationPluginManager;
+use Drupal\migrate_tools\MigrateBatchExecutable;
+use Drush\Commands\DrushCommands;
+
+/**
+ * Migrate Tools Test drush commands.
+ */
+class MigrateToolsTestCommands extends DrushCommands {
+
+  /**
+   * Migration plugin manager service.
+   *
+   * @var \Drupal\migrate\Plugin\MigrationPluginManager
+   */
+  protected $migrationPluginManager;
+
+  /**
+   * MigrateToolsTestCommands constructor.
+   *
+   * @param \Drupal\migrate\Plugin\MigrationPluginManager $migrationPluginManager
+   *   The Migration Plugin Manager.
+   */
+  public function __construct(MigrationPluginManager $migrationPluginManager) {
+    parent::__construct();
+    $this->migrationPluginManager = $migrationPluginManager;
+  }
+
+  /**
+   * Run a batch import of fruit terms as a test.
+   *
+   * @command migrate:batch-import-fruit
+   */
+  public function batchImportFruit() {
+    $fruit_migration = $this->migrationPluginManager->createInstance('fruit_terms');
+    $executable = new MigrateBatchExecutable($fruit_migration, new MigrateMessage());
+    $executable->batchImport();
+    drush_backend_batch_process();
+  }
+
+}
diff --git a/web/modules/migrate_tools/tests/modules/migrate_tools_test/src/Plugin/migrate/source/ExceptionThrowingTestSource.php b/web/modules/migrate_tools/tests/modules/migrate_tools_test/src/Plugin/migrate/source/ExceptionThrowingTestSource.php
new file mode 100644
index 0000000000000000000000000000000000000000..b6f4a0dfada5395d813d4a15d8d8eef35a6433d4
--- /dev/null
+++ b/web/modules/migrate_tools/tests/modules/migrate_tools_test/src/Plugin/migrate/source/ExceptionThrowingTestSource.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Drupal\migrate_tools_test\Plugin\migrate\source;
+
+use Drupal\migrate\Plugin\migrate\source\SourcePluginBase;
+
+/**
+ * A simple migrate source for testing exception handling.
+ *
+ * @MigrateSource(
+ *   id = "migrate_exception_source_test",
+ *   source_module = "migrate_tools_test"
+ * )
+ */
+class ExceptionThrowingTestSource extends SourcePluginBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function fields() {
+    return [];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __toString() {
+    return '';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getIds() {
+    return [];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function initializeIterator() {
+    return new \ArrayIterator();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rewind() {
+    throw new \Exception('Rewind Failure');
+  }
+
+}
diff --git a/web/modules/migrate_tools/tests/modules/url_source_test/config/install/migrate_plus.migration.url_404_source_test.yml b/web/modules/migrate_tools/tests/modules/url_source_test/config/install/migrate_plus.migration.url_404_source_test.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d7167f3ce1324c96b7877e1941bd0d8d9cc0db41
--- /dev/null
+++ b/web/modules/migrate_tools/tests/modules/url_source_test/config/install/migrate_plus.migration.url_404_source_test.yml
@@ -0,0 +1,27 @@
+langcode: en
+status: true
+id: url_404_source_test
+label: Test 404 URLs in the UI
+class: null
+field_plugin_method: null
+cck_plugin_method: null
+migration_tags: {  }
+migration_group: url_test
+source:
+  plugin: url
+  data_fetcher_plugin: file
+  urls: 'http://localhost/does-not-exist.xml'
+  data_parser_plugin: simple_xml
+  item_selector: /rss/channel/item
+  fields: {  }
+  ids: {  }
+process: {  }
+destination:
+  plugin: 'entity:node'
+migration_dependencies:
+  required: {  }
+  optional: {  }
+dependencies:
+  enforced:
+    module:
+      - url_source_test
diff --git a/web/modules/migrate_tools/tests/modules/url_source_test/config/install/migrate_plus.migration_group.url_test.yml b/web/modules/migrate_tools/tests/modules/url_source_test/config/install/migrate_plus.migration_group.url_test.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d1b676365de989b5661650f92ef1b00fd1e401bb
--- /dev/null
+++ b/web/modules/migrate_tools/tests/modules/url_source_test/config/install/migrate_plus.migration_group.url_test.yml
@@ -0,0 +1,8 @@
+id: url_test
+label: URL source plugin edit test
+description: Test editing of URL source plugin via the UI
+source_type: URL
+dependencies:
+  enforced:
+    module:
+      - url_source_test
diff --git a/web/modules/migrate_tools/tests/modules/url_source_test/url_source_test.info.yml b/web/modules/migrate_tools/tests/modules/url_source_test/url_source_test.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4343b225b0ca2609926678da332e53c815eee3e5
--- /dev/null
+++ b/web/modules/migrate_tools/tests/modules/url_source_test/url_source_test.info.yml
@@ -0,0 +1,14 @@
+type: module
+name: URL Source edit test
+description: 'Test editing of URL source plugin via the UI.'
+package: Testing
+core: 8.x
+core_version_requirement: ^8 || ^9
+dependencies:
+  - drupal:migrate
+  - migrate_plus:migrate_plus
+
+# Information added by Drupal.org packaging script on 2020-04-30
+version: '8.x-5.0'
+project: 'migrate_tools'
+datestamp: 1588260533
diff --git a/web/modules/migrate_tools/tests/src/Functional/DrushBatchImportTest.php b/web/modules/migrate_tools/tests/src/Functional/DrushBatchImportTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..1f97efca9b6751b58d1b5a718aec00ab24a37a93
--- /dev/null
+++ b/web/modules/migrate_tools/tests/src/Functional/DrushBatchImportTest.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Drupal\Tests\migrate_tools\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+use Drush\TestTraits\DrushTestTrait;
+
+/**
+ * Test that batch import runs correctly in drush command.
+ *
+ * @group migrate_tools
+ */
+class DrushBatchImportTest extends BrowserTestBase {
+  use DrushTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'migrate_tools_test',
+    'migrate_tools',
+    'migrate_plus',
+    'taxonomy',
+    'text',
+    'system',
+    'user',
+  ];
+
+  /**
+   * Tests that a batch import run from a custom drush command succeeds.
+   */
+  public function testBatchImportInDrushComand(): void {
+    $this->drush('migrate:batch-import-fruit');
+    $migration = \Drupal::service('plugin.manager.migration')->createInstance('fruit_terms');
+    $id_map = $migration->getIdMap();
+    $this->assertSame(3, $id_map->importedCount());
+  }
+
+}
diff --git a/web/modules/migrate_tools/tests/src/Functional/DrushCommandsGeneratorTest.php b/web/modules/migrate_tools/tests/src/Functional/DrushCommandsGeneratorTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..0cbc3f6491181c6baf00d703219c45b556043abd
--- /dev/null
+++ b/web/modules/migrate_tools/tests/src/Functional/DrushCommandsGeneratorTest.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace Drupal\Tests\migrate_tools\Functional;
+
+use Drupal\Core\StreamWrapper\PublicStream;
+use Drupal\Core\StreamWrapper\StreamWrapperInterface;
+use Drupal\Tests\BrowserTestBase;
+use Drush\TestTraits\DrushTestTrait;
+
+/**
+ * Execute drush on fully functional website using source generators.
+ *
+ * @group migrate_tools
+ */
+class DrushCommandsGeneratorTest extends BrowserTestBase {
+  use DrushTestTrait;
+
+  /**
+   * The source CSV data.
+   *
+   * @var string
+   */
+  protected $sourceData;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'csv_source_test',
+    'migrate',
+    'migrate_plus',
+    'migrate_source_csv',
+    'migrate_tools',
+    'taxonomy',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    // Setup the file system so we create the source CSV.
+    $this->container->get('stream_wrapper_manager')->registerWrapper('public', PublicStream::class, StreamWrapperInterface::NORMAL);
+    $fs = \Drupal::service('file_system');
+    $fs->mkdir('public://sites/default/files', NULL, TRUE);
+
+    // The source data for this test.
+    $this->sourceData = <<<'EOD'
+vid,name,description,hierarchy,weight
+tags,Tags,Use tags to group articles,0,0
+forums,Sujet de discussion,Forum navigation vocabulary,1,0
+test_vocabulary,Test Vocabulary,This is the vocabulary description,1,0
+genre,Genre,Genre description,1,0
+EOD;
+
+    // Write the data to the filepath given in the test migration.
+    file_put_contents('public://test.csv', $this->sourceData);
+  }
+
+  /**
+   * Tests synced import.
+   */
+  public function testSyncImport(): void {
+    $this->drush('mim', ['csv_source_test']);
+    $this->assertStringContainsString('1/4', $this->getErrorOutput());
+    $this->assertStringContainsString('4/4', $this->getErrorOutput());
+    $this->assertStringContainsString('[notice] Processed 4 items (4 created, 0 updated, 0 failed, 0 ignored) - done with \'csv_source_test\'', $this->getErrorOutput());
+    $this->assertStringNotContainsString('5/5', $this->getErrorOutput());
+    $vocabulary = \Drupal::entityTypeManager()->getStorage('taxonomy_vocabulary')->load('genre');
+    $this->assertEquals('Genre', $vocabulary->label());
+    $this->assertEquals(4, \Drupal::entityTypeManager()->getStorage('taxonomy_vocabulary')->getQuery()->count()->execute());
+
+    // Remove one vocab and replace with another.
+    $this->sourceData = str_replace('genre,Genre,Genre description,1,0', 'fruit,Fruit,Fruit description,1,0', $this->sourceData);
+    file_put_contents('public://test.csv', $this->sourceData);
+
+    // Execute sync migration.
+    $this->drush('mim', ['csv_source_test'], ['sync' => NULL]);
+    $this->assertStringContainsString('1/4', $this->getErrorOutput());
+    $this->assertStringContainsString('25% [notice] Rolled back 1 item - done with \'csv_source_test\'', $this->getErrorOutput());
+    $this->assertStringContainsString('4/4', $this->getErrorOutput());
+    $this->assertStringContainsString('5/5', $this->getErrorOutput());
+    $this->assertStringContainsString('100% [notice] Processed 4 items (1 created, 3 updated, 0 failed, 0 ignored) - done with \'csv_source_test\'', $this->getErrorOutput());
+    $this->assertEquals(4, \Drupal::entityTypeManager()->getStorage('taxonomy_vocabulary')->getQuery()->count()->execute());
+    // Flush cache so recently deleted vocabulary actually goes away.
+    drupal_flush_all_caches();
+    $this->assertEmpty(\Drupal::entityTypeManager()->getStorage('taxonomy_vocabulary')->load('genre'));
+
+    /** @var \Drupal\migrate\Plugin\MigrateIdMapInterface $id_map */
+    $id_map = $this->container->get('plugin.manager.migration')->createInstance('csv_source_test')->getIdMap();
+    $this->assertCount(4, $id_map);
+  }
+
+}
diff --git a/web/modules/migrate_tools/tests/src/Functional/DrushCommandsTest.php b/web/modules/migrate_tools/tests/src/Functional/DrushCommandsTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..a863b332ac25dd60fd72f79ead0cb8c8916efe46
--- /dev/null
+++ b/web/modules/migrate_tools/tests/src/Functional/DrushCommandsTest.php
@@ -0,0 +1,214 @@
+<?php
+
+namespace Drupal\Tests\migrate_tools\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+use Drush\TestTraits\DrushTestTrait;
+
+/**
+ * Execute drush on fully functional website.
+ *
+ * @group migrate_tools
+ */
+class DrushCommandsTest extends BrowserTestBase {
+  use DrushTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'migrate_tools_test',
+    'migrate_tools',
+    'migrate_plus',
+    'taxonomy',
+    'text',
+    'system',
+    'user',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * Tests migrate:import with feedback.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\PluginException
+   */
+  public function testFeedback(): void {
+    $this->drush('mim', ['fruit_terms'], ['feedback' => 2]);
+    $this->assertStringContainsString('1/3', $this->getErrorOutput());
+    $this->assertStringContainsString('[notice] Processed 2 items (2 created, 0 updated, 0 failed, 0 ignored) - continuing with \'fruit_terms\'', $this->getErrorOutput());
+    $this->assertStringContainsString('3/3', $this->getErrorOutput());
+    $this->assertStringContainsString('[notice] Processed 1 item (1 created, 0 updated, 0 failed, 0 ignored) - done with \'fruit_terms\'', $this->getErrorOutput());
+    $this->assertStringNotContainsString('4', $this->getErrorOutput());
+  }
+
+  /**
+   * Tests migrate:import with limit.
+   */
+  public function testLimit(): void {
+    $this->drush('mim', ['fruit_terms'], ['limit' => 2]);
+    $this->assertStringContainsString('1/3', $this->getErrorOutput());
+    $this->assertStringContainsString('[notice] Processed 2 items (2 created, 0 updated, 0 failed, 0 ignored) - done with \'fruit_terms\'', $this->getErrorOutput());
+    $this->assertStringNotContainsString('3/3', $this->getErrorOutput());
+  }
+
+  /**
+   * Test that migrations continue after a failure if the option is set.
+   */
+  public function testContinueOnFailure(): void {
+    // Option not set, fruit_terms should not run.
+    $this->drush('mim', ['invalid_plugin,fruit_terms'], [], NULL, NULL, 1);
+    $this->assertStringNotContainsString("done with 'fruit_terms'", $this->getErrorOutput());
+    // Option set, fruit_terms should run.
+    $this->drush('mim', ['invalid_plugin,fruit_terms'], ['continue-on-failure' => NULL]);
+    $this->assertStringContainsString("done with 'fruit_terms'", $this->getErrorOutput());
+    // Option not set, fruit_terms should not run.
+    $this->drush('mr', ['invalid_plugin,fruit_terms'], [], NULL, NULL, 1);
+    $this->assertStringNotContainsString("done with 'fruit_terms'", $this->getErrorOutput());
+    // Option set, fruit_terms should run.
+    $this->drush('mr', ['invalid_plugin,fruit_terms'], ['continue-on-failure' => NULL]);
+    $this->assertStringContainsString("done with 'fruit_terms'", $this->getErrorOutput());
+    // Option not set, fruit_terms should not display.
+    $this->drush('ms', ['invalid_plugin,fruit_terms'], ['format' => 'json'], NULL, NULL, 1);
+    // This demonstrates we surface the exception but not as an error.
+    $this->assertStringNotContainsString('[error]  The "does_not_exist" plugin does not exist', $this->getErrorOutput());
+    $this->assertStringContainsString('The "does_not_exist" plugin does not exist', $this->getErrorOutput());
+    $this->assertStringNotContainsString('fruit_terms    Idle     3', $this->getOutput());
+    // Option set, fruit_terms should display.
+    $this->drush('ms', ['invalid_plugin,fruit_terms'], ['continue-on-failure' => NULL]);
+    $this->assertStringContainsString('[error]  The "does_not_exist" plugin does not exist', $this->getErrorOutput());
+    $this->assertStringContainsString('fruit_terms    Idle     3', $this->getOutput());
+  }
+
+  /**
+   * Tests many of the migrate drush commands.
+   */
+  public function testDrush(): void {
+    $this->drush('ms', [], [], NULL, NULL, 1);
+    $this->assertStringContainsString('The "does_not_exist" plugin does not exist.', $this->getErrorOutput());
+    $this->container->get('config.factory')->getEditable('migrate_plus.migration.invalid_plugin')->delete();
+    // Flush cache so the recently removed invalid migration is cleared.
+    drupal_flush_all_caches();
+    $this->drush('ms', [], ['format' => 'json']);
+    $expected = [
+      [
+        'group' => 'Default (default)',
+        'id' => 'fruit_terms',
+        'imported' => 0,
+        'status' => 'Idle',
+        'total' => 3,
+        'unprocessed' => 3,
+        'last_imported' => '',
+      ],
+      [
+        'group' => 'Default (default)',
+        'id' => 'source_exception',
+        'imported' => 0,
+        'status' => 'Idle',
+        'total' => 0,
+        'unprocessed' => 0,
+        'last_imported' => '',
+      ],
+    ];
+    $this->assertEquals($expected, $this->getOutputFromJSON());
+    $this->drush('mim', ['fruit_terms']);
+    $this->assertStringContainsString('1/3', $this->getErrorOutput());
+    $this->assertStringContainsString('3/3', $this->getErrorOutput());
+    $this->assertStringContainsString('[notice] Processed 3 items (3 created, 0 updated, 0 failed, 0 ignored) - done with \'fruit_terms\'', $this->getErrorOutput());
+    $this->assertStringNotContainsString('4', $this->getErrorOutput());
+    $this->drush('mim', ['fruit_terms'], [
+      'update' => NULL,
+      'force' => NULL,
+      'execute-dependencies' => NULL,
+    ]);
+    $this->assertStringContainsString('1/3', $this->getErrorOutput());
+    $this->assertStringContainsString('3/3', $this->getErrorOutput());
+    $this->assertStringContainsString('[notice] Processed 3 items (0 created, 3 updated, 0 failed, 0 ignored) - done with \'fruit_terms\'', $this->getErrorOutput());
+    $this->assertStringNotContainsString('4', $this->getErrorOutput());
+    $this->drush('mrs', ['fruit_terms']);
+    $this->assertErrorOutputEquals('[warning] Migration fruit_terms is already Idle');
+    $this->drush('mfs', ['fruit_terms'], ['format' => 'json']);
+    $expected = [
+      [
+        'machine_name' => 'name',
+        'description' => 'name',
+      ],
+    ];
+    $this->assertEquals($expected, $this->getOutputFromJSON());
+    $this->drush('mr', ['fruit_terms']);
+    $this->assertStringContainsString('1/3', $this->getErrorOutput());
+    $this->assertStringContainsString('3/3', $this->getErrorOutput());
+    $this->assertStringContainsString('[notice] Rolled back 3 items - done with \'fruit_terms\'', $this->getErrorOutput());
+    $this->assertStringNotContainsString('4', $this->getErrorOutput());
+    $this->drush('migrate:stop', ['fruit_terms']);
+    $this->assertErrorOutputEquals('[warning] Migration fruit_terms is idle');
+
+    $this->drush('mim', ['fruit_terms'], ['skip-progress-bar' => NULL]);
+    $this->assertErrorOutputEquals('[notice] Processed 3 items (3 created, 0 updated, 0 failed, 0 ignored) - done with \'fruit_terms\'');
+    $this->drush('mr', ['fruit_terms'], ['skip-progress-bar' => NULL]);
+    $this->assertErrorOutputEquals('[notice] Rolled back 3 items - done with \'fruit_terms\'');
+  }
+
+  /**
+   * Fully test migrate messages.
+   */
+  public function testMessages(): void {
+    $this->drush('mim', ['fruit_terms']);
+    $this->drush('mmsg', ['fruit_terms']);
+    $this->assertErrorOutputEquals('[notice] No messages for this migration');
+    /** @var \Drupal\migrate\Plugin\MigrateIdMapInterface $id_map */
+    $id_map = $this->container->get('plugin.manager.migration')->createInstance('fruit_terms')->getIdMap();
+    $id_map->saveMessage(['name' => 'Apple'], 'You picked a bad one.');
+    $this->drush('mmsg', ['fruit_terms'], ['format' => 'json']);
+    $expected = [
+      [
+        'level' => '1',
+        'message' => 'You picked a bad one.',
+        'source_ids' => 'Apple',
+        'destination_ids' => '1',
+      ],
+    ];
+    $this->assertEquals($expected, $this->getOutputFromJSON());
+    $this->drush('mmsg', ['fruit_terms'], ['format' => 'csv']);
+    $expected = <<<EOT
+"Source ID(s)","Destination ID(s)",Level,Message
+Apple,1,1,"You picked a bad one."
+EOT;
+    $this->assertEquals($expected, $this->getOutput());
+  }
+
+  /**
+   * Tests synced import.
+   */
+  public function testSyncImport(): void {
+    $this->drush('mim', ['fruit_terms']);
+    $this->assertStringContainsString('1/3', $this->getErrorOutput());
+    $this->assertStringContainsString('3/3', $this->getErrorOutput());
+    $this->assertStringContainsString('[notice] Processed 3 items (3 created, 0 updated, 0 failed, 0 ignored) - done with \'fruit_terms\'', $this->getErrorOutput());
+    $this->assertStringNotContainsString('4', $this->getErrorOutput());
+    $term = \Drupal::entityTypeManager()->getStorage('taxonomy_term')->load(2);
+    $this->assertEquals('Banana', $term->label());
+    $this->assertEquals(3, \Drupal::entityTypeManager()->getStorage('taxonomy_term')->getQuery()->count()->execute());
+    $source = $this->container->get('config.factory')->getEditable('migrate_plus.migration.fruit_terms')->get('source');
+    unset($source['data_rows'][1]);
+    $source['data_rows'][] = ['name' => 'Grape'];
+    $this->container->get('config.factory')->getEditable('migrate_plus.migration.fruit_terms')->set('source', $source)->save();
+    // Flush cache so the recently changed migration can be refreshed.
+    drupal_flush_all_caches();
+    $this->drush('mim', ['fruit_terms'], ['sync' => NULL]);
+    $this->assertStringContainsString('1/3', $this->getErrorOutput());
+    $this->assertStringContainsString('4/4', $this->getErrorOutput());
+    $this->assertStringContainsString('[notice] Processed 3 items (1 created, 2 updated, 0 failed, 0 ignored) - done with \'fruit_terms\'', $this->getErrorOutput());
+    $this->assertStringNotContainsString('5', $this->getErrorOutput());
+    $this->assertEquals(3, \Drupal::entityTypeManager()->getStorage('taxonomy_term')->getQuery()->count()->execute());
+    $this->assertEmpty(\Drupal::entityTypeManager()->getStorage('taxonomy_term')->load(2));
+
+    /** @var \Drupal\migrate\Plugin\MigrateIdMapInterface $id_map */
+    $id_map = $this->container->get('plugin.manager.migration')->createInstance('fruit_terms')->getIdMap();
+    $this->assertCount(3, $id_map);
+  }
+
+}
diff --git a/web/modules/migrate_tools/tests/src/Functional/MigrateExecutionFormTest.php b/web/modules/migrate_tools/tests/src/Functional/MigrateExecutionFormTest.php
index 2f29bf5ba29d3d1790b7438869cec3f59e4f4c35..565a6c9e7955cb661b60866125f5d98b21ddcf51 100644
--- a/web/modules/migrate_tools/tests/src/Functional/MigrateExecutionFormTest.php
+++ b/web/modules/migrate_tools/tests/src/Functional/MigrateExecutionFormTest.php
@@ -2,7 +2,9 @@
 
 namespace Drupal\Tests\migrate_tools\Functional;
 
+use Drupal\Core\StringTranslation\StringTranslationTrait;
 use Drupal\taxonomy\Entity\Vocabulary;
+use Drupal\taxonomy\VocabularyInterface;
 use Drupal\Tests\BrowserTestBase;
 
 /**
@@ -11,6 +13,7 @@
  * @group migrate_tools
  */
 class MigrateExecutionFormTest extends BrowserTestBase {
+  use StringTranslationTrait;
 
   /**
    * {@inheritdoc}
@@ -31,7 +34,7 @@ class MigrateExecutionFormTest extends BrowserTestBase {
   /**
    * {@inheritdoc}
    */
-  protected $profile = 'testing';
+  protected $defaultTheme = 'stark';
 
   /**
    * The vocabulary.
@@ -50,7 +53,7 @@ class MigrateExecutionFormTest extends BrowserTestBase {
   /**
    * {@inheritdoc}
    */
-  protected function setUp() {
+  protected function setUp(): void {
     parent::setUp();
     $this->vocabulary = $this->createVocabulary(['vid' => 'fruit', 'name' => 'Fruit']);
     $this->vocabularyQuery = $this->container->get('entity_type.manager')
@@ -65,7 +68,7 @@ protected function setUp() {
    *
    * @throws \Behat\Mink\Exception\ExpectationException
    */
-  public function testExecution() {
+  public function testExecution(): void {
     $group = 'default';
     $migration = 'fruit_terms';
     $urlPath = "/admin/structure/migrate/manage/{$group}/migrations/{$migration}/execute";
@@ -77,21 +80,21 @@ public function testExecution() {
     $edit = [
       'operation' => 'import',
     ];
-    $this->drupalPostForm($urlPath, $edit, t('Execute'));
+    $this->drupalPostForm($urlPath, $edit, $this->t('Execute'));
     $real_count = $this->vocabularyQuery->count()->execute();
     $expected_count = 3;
     $this->assertEquals($expected_count, $real_count);
     $edit = [
       'operation' => 'rollback',
     ];
-    $this->drupalPostForm($urlPath, $edit, t('Execute'));
+    $this->drupalPostForm($urlPath, $edit, $this->t('Execute'));
     $real_count = $this->vocabularyQuery->count()->execute();
     $expected_count = 0;
     $this->assertEquals($expected_count, $real_count);
     $edit = [
       'operation' => 'import',
     ];
-    $this->drupalPostForm($urlPath, $edit, t('Execute'));
+    $this->drupalPostForm($urlPath, $edit, $this->t('Execute'));
     $real_count = $this->vocabularyQuery->count()->execute();
     $expected_count = 3;
     $this->assertEquals($expected_count, $real_count);
@@ -107,7 +110,7 @@ public function testExecution() {
    * @return \Drupal\taxonomy\VocabularyInterface
    *   Created vocabulary.
    */
-  protected function createVocabulary(array $values = []) {
+  protected function createVocabulary(array $values = []): VocabularyInterface {
     // Find a non-existent random vocabulary name.
     if (!isset($values['vid'])) {
       do {
diff --git a/web/modules/migrate_tools/tests/src/Functional/SourceCsvFormTest.php b/web/modules/migrate_tools/tests/src/Functional/SourceCsvFormTest.php
deleted file mode 100644
index 767a9bfe08cb4b019491d39b0038927051914aae..0000000000000000000000000000000000000000
--- a/web/modules/migrate_tools/tests/src/Functional/SourceCsvFormTest.php
+++ /dev/null
@@ -1,241 +0,0 @@
-<?php
-
-namespace Drupal\Tests\migrate_tools\Functional;
-
-use Drupal\Core\StreamWrapper\PublicStream;
-use Drupal\Core\StreamWrapper\StreamWrapperInterface;
-use Drupal\Tests\BrowserTestBase;
-use Drupal\taxonomy\Entity\Vocabulary;
-use Drupal\taxonomy\VocabularyInterface;
-
-/**
- * Test the CSV column alias edit form.
- *
- * @requires module migrate_source_csv
- *
- * @group migrate_tools
- */
-class SourceCsvFormTest extends BrowserTestBase {
-
-  /**
-   * Temporary store for column assignment changes.
-   *
-   * @var \Drupal\Core\TempStore\PrivateTempStoreFactory
-   */
-  protected $store;
-
-  /**
-   * {@inheritdoc}
-   */
-  protected static $modules = [
-    'taxonomy',
-    'migrate',
-    'migrate_plus',
-    'migrate_tools',
-    'migrate_source_csv',
-    'csv_source_test',
-  ];
-
-  /**
-   * {@inheritdoc}
-   */
-  protected $profile = 'testing';
-
-  /**
-   * The migration group for the test migration.
-   *
-   * @var string
-   */
-  protected $group;
-
-  /**
-   * The test migration id.
-   *
-   * @var string
-   */
-  protected $migration;
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function setUp() {
-    parent::setUp();
-
-    // Log in as user 1. Migrations in the UI can only be performed as user 1.
-    $this->drupalLogin($this->rootUser);
-
-    // Setup the file system so we create the source CSV.
-    $this->container->get('stream_wrapper_manager')->registerWrapper('public', PublicStream::class, StreamWrapperInterface::NORMAL);
-    $fs = \Drupal::service('file_system');
-    $fs->mkdir('public://sites/default/files', NULL, TRUE);
-
-    // The source data for this test.
-    $source_data = <<<'EOD'
-vid,name,description,hierarchy,weight
-tags,Tags,Use tags to group articles,0,0
-forums,Sujet de discussion,Forum navigation vocabulary,1,0
-test_vocabulary,Test Vocabulary,This is the vocabulary description,1,0
-genre,Genre,Genre description,1,0
-EOD;
-
-    // Write the data to the filepath given in the test migration.
-    file_put_contents('public://test.csv', $source_data);
-
-    // Get the store.
-    $tempStoreFactory = \Drupal::service('tempstore.private');
-    $this->store = $tempStoreFactory->get('migrate_tools');
-
-    // Select the group and migration to test.
-    $this->group = 'csv_test';
-    $this->migration = 'csv_source_test';
-  }
-
-  /**
-   * Tests the form to edit CSV column aliases.
-   *
-   * @throws \Behat\Mink\Exception\ExpectationException
-   */
-  public function testSourceCsvForm() {
-    // Define the paths to be used.
-    $executeUrlPath = "/admin/structure/migrate/manage/{$this->group}/migrations/{$this->migration}/execute";
-    $editUrlPath = "/admin/structure/migrate/manage/{$this->group}/migrations/{$this->migration}/source/edit";
-
-    // Assert the test migration is listed.
-    $this->drupalGet("/admin/structure/migrate/manage/{$this->group}/migrations");
-    $session = $this->assertSession();
-    $session->responseContains('Test edit of column aliases for CSV source plugin');
-
-    // Proceed to the edit page.
-    $this->drupalGet($editUrlPath);
-    $session->responseContains('You can change the columns to be used by this migration for each source property.');
-
-    // Test that there are 3 select fields available which match the number of
-    // properties in the process pipeline.
-    $this->assertTrue($session->optionExists('edit-vid', 'vid')
-      ->isSelected());
-    $this->assertTrue($session->optionExists('edit-name', 'name')
-      ->isSelected());
-    $this->assertTrue($session->optionExists('edit-description', 'description')
-      ->isSelected());
-    $session->responseNotContains('edit-hierarchy');
-    $session->responseNotContains('edit-weight');
-
-    // Test that all 5 columns in the CSV source are available as options on
-    // one of the select fields.
-    $this->assertTrue($session->optionExists('edit-description', 'vid'));
-    $this->assertTrue($session->optionExists('edit-description', 'name'));
-    $this->assertTrue($session->optionExists('edit-description', 'description'));
-    $this->assertTrue($session->optionExists('edit-description', 'hierarchy'));
-    $this->assertTrue($session->optionExists('edit-description', 'weight'));
-
-    // Test that two aliases can not be the same.
-    $edit = [
-      'edit-vid' => 2,
-      'edit-name' => 1,
-      'edit-description' => 1,
-    ];
-    $this->drupalPostForm($editUrlPath, $edit, t('Submit'));
-    $session->responseContains('Source properties can not share the same source column.');
-    $this->assertTrue($session->optionExists('edit-vid', 'description')
-      ->isSelected());
-    $this->assertTrue($session->optionExists('edit-name', 'name')
-      ->isSelected());
-    $this->assertTrue($session->optionExists('edit-description', 'name')
-      ->isSelected());
-
-    // Test that changes to all the column aliases are saved.
-    $edit = [
-      'edit-vid' => 4,
-      'edit-name' => 0,
-      'edit-description' => 1,
-    ];
-    $this->drupalPostForm($editUrlPath, $edit, t('Submit'));
-    $this->assertTrue($session->optionExists('edit-vid', 'weight')
-      ->isSelected());
-    $this->assertTrue($session->optionExists('edit-name', 'vid')
-      ->isSelected());
-    $this->assertTrue($session->optionExists('edit-description', 'name')
-      ->isSelected());
-
-    // Test that the changes are saved to store.
-    $columnConfiguration = $this->store->get('csv_source_test');
-    $migrationsChanged = $this->store->get('migrations_changed');
-    $this->assertSame(['csv_source_test'], $migrationsChanged);
-    $expected =
-      [
-        'original' =>
-          [
-            0 => ['vid' => 'Vocabulary Id'],
-            1 => ['name' => 'Name'],
-            2 => ['description' => 'Description'],
-          ],
-        'changed' =>
-          [
-            4 => ['vid' => 'weight'],
-            0 => ['name' => 'vid'],
-            1 => ['description' => 'name'],
-          ],
-      ];
-    $this->assertSame($expected, $columnConfiguration);
-
-    // Test the migration with incorrect column aliases. Flush the cache to
-    // ensure the plugin alter is run.
-    drupal_flush_all_caches();
-    $edit = [
-      'operation' => 'import',
-    ];
-    $this->drupalPostForm($executeUrlPath, $edit, t('Execute'));
-    $session->responseContains("Processed 1 item (1 created, 0 updated, 0 failed, 0 ignored) - done with 'csv_source_test'");
-
-    // Rollback.
-    $edit = [
-      'operation' => 'rollback',
-    ];
-    $this->drupalPostForm($executeUrlPath, $edit, t('Execute'));
-
-    // Restore to an order that will succesfully migrate.
-    $edit = [
-      'edit-vid' => 0,
-      'edit-name' => 1,
-      'edit-description' => 2,
-    ];
-    $this->drupalPostForm($editUrlPath, $edit, t('Submit'));
-    $this->assertTrue($session->optionExists('edit-vid', 'vid')
-      ->isSelected());
-    $this->assertTrue($session->optionExists('edit-name', 'name')
-      ->isSelected());
-    $this->assertTrue($session->optionExists('edit-description', 'description')
-      ->isSelected());
-
-    // Test the vocabulary migration.
-    $edit = [
-      'operation' => 'import',
-    ];
-    drupal_flush_all_caches();
-    $this->drupalPostForm($executeUrlPath, $edit, t('Execute'));
-    $session->responseContains("Processed 4 items (4 created, 0 updated, 0 failed, 0 ignored) - done with 'csv_source_test'");
-    $this->assertEntity('tags', 'Tags', 'Use tags to group articles');
-    $this->assertEntity('forums', 'Sujet de discussion', 'Forum navigation vocabulary');
-    $this->assertEntity('test_vocabulary', 'Test Vocabulary', 'This is the vocabulary description');
-    $this->assertEntity('genre', 'Genre', 'Genre description');
-  }
-
-  /**
-   * Validate a migrated vocabulary contains the expected values.
-   *
-   * @param string $id
-   *   Entity ID to load and check.
-   * @param string $expected_name
-   *   The name the migrated entity should have.
-   * @param string $expected_description
-   *   The description the migrated entity should have.
-   */
-  protected function assertEntity($id, $expected_name, $expected_description) {
-    /** @var \Drupal\taxonomy\VocabularyInterface $entity */
-    $entity = Vocabulary::load($id);
-    $this->assertTrue($entity instanceof VocabularyInterface);
-    $this->assertSame($expected_name, $entity->label());
-    $this->assertSame($expected_description, $entity->getDescription());
-  }
-
-}
diff --git a/web/modules/migrate_tools/tests/src/Functional/SourceUrlFormTest.php b/web/modules/migrate_tools/tests/src/Functional/SourceUrlFormTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..15cc5a5b810b19a333228cd6fdf4db469e4f8c9f
--- /dev/null
+++ b/web/modules/migrate_tools/tests/src/Functional/SourceUrlFormTest.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Drupal\Tests\migrate_tools\Functional;
+
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Test the URL column alias edit form.
+ *
+ * @group migrate_tools
+ */
+class SourceUrlFormTest extends BrowserTestBase {
+  use StringTranslationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'migrate',
+    'migrate_plus',
+    'migrate_tools',
+    'url_source_test',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * The migration group for the test migration.
+   *
+   * @var string
+   */
+  protected $group;
+
+  /**
+   * The test migration id.
+   *
+   * @var string
+   */
+  protected $migration;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    // Log in as user 1. Migrations in the UI can only be performed as user 1.
+    $this->drupalLogin($this->rootUser);
+
+    // Select the group and migration to test.
+    $this->group = 'url_test';
+    $this->migration = 'url_404_source_test';
+  }
+
+  /**
+   * Tests the form ensure graceful 404 handling.
+   *
+   * @throws \Behat\Mink\Exception\ExpectationException
+   */
+  public function testSourceUrl404Form(): void {
+    // Assert the test migration is listed.
+    $this->drupalGet("/admin/structure/migrate/manage/{$this->group}/migrations");
+    $session = $this->assertSession();
+    $session->responseContains('Test 404 URLs in the UI');
+  }
+
+}
diff --git a/web/modules/migrate_tools/tests/src/Kernel/DrushTest.php b/web/modules/migrate_tools/tests/src/Kernel/DrushTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..4f26414f7f269e139102cd7c4ab82dc00172b7c4
--- /dev/null
+++ b/web/modules/migrate_tools/tests/src/Kernel/DrushTest.php
@@ -0,0 +1,320 @@
+<?php
+
+namespace Drupal\Tests\migrate_tools\Kernel;
+
+use Drupal\migrate\Plugin\MigrationInterface;
+use Drupal\migrate_tools\Commands\MigrateToolsCommands;
+use Drupal\migrate_tools\MigrateTools;
+use Drupal\Tests\migrate\Kernel\MigrateTestBase;
+
+/**
+ * Tests for the Drush 9 commands.
+ *
+ * @group migrate_tools
+ */
+class DrushTest extends MigrateTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'migrate_tools_test',
+    'migrate_tools',
+    'migrate_plus',
+    'taxonomy',
+    'text',
+    'system',
+    'user',
+  ];
+
+  /**
+   * Base options array for import.
+   *
+   * @var array
+   */
+  protected $importBaseOptions = [
+    'all' => NULL,
+    'group' => NULL,
+    'tag' => NULL,
+    'limit' => NULL,
+    'feedback' => NULL,
+    'idlist' => NULL,
+    'idlist-delimiter' => MigrateTools::DEFAULT_ID_LIST_DELIMITER,
+    'update' => NULL,
+    'force' => NULL,
+    'execute-dependencies' => NULL,
+    'skip-progress-bar' => FALSE,
+    'continue-on-failure' => FALSE,
+    'sync' => FALSE,
+  ];
+
+  /**
+   * The Migrate Tools Command drush service.
+   *
+   * @var \Drupal\migrate_tools\Commands\MigrateToolsCommands
+   */
+  protected $commands;
+
+  /**
+   * The migration plugin manager.
+   *
+   * @var \Drupal\migrate\Plugin\MigrationPluginManagerInterface
+   */
+  protected $migrationPluginManager;
+
+  /**
+   * The logger.
+   *
+   * @var \Psr\Log\LoggerInterface
+   */
+  protected $logger;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp(): void {
+    parent::setUp();
+    $this->installConfig('migrate_plus');
+    $this->installConfig('migrate_tools_test');
+    $this->installEntitySchema('taxonomy_term');
+    $this->installEntitySchema('user');
+    $this->installSchema('system', ['key_value', 'key_value_expire']);
+    $this->installSchema('user', ['users_data']);
+    $this->migrationPluginManager = $this->container->get('plugin.manager.migration');
+    $this->logger = $this->container->get('logger.channel.migrate_tools');
+    $this->commands = new MigrateToolsCommands(
+      $this->migrationPluginManager,
+      $this->container->get('date.formatter'),
+      $this->container->get('entity_type.manager'),
+      $this->container->get('keyvalue'));
+    $this->commands->setLogger($this->logger);
+  }
+
+  /**
+   * Tests drush ms.
+   */
+  public function testStatus(): void {
+    $this->executeMigration('fruit_terms');
+    /** @var \Consolidation\OutputFormatters\StructuredData\RowsOfFields $result */
+    $result = $this->commands->status('fruit_terms', [
+      'group' => NULL,
+      'tag' => NULL,
+      'names-only' => FALSE,
+    ]);
+    $rows = $result->getArrayCopy();
+    $this->assertCount(1, $rows);
+    $row = reset($rows);
+    $this->assertSame('fruit_terms', $row['id']);
+    $this->assertSame(3, $row['total']);
+    $this->assertSame(3, $row['imported']);
+    $this->assertSame('Idle', $row['status']);
+
+    // Migrate status should not display migrate_drupal migrations if no source
+    // database is defined.
+    \Drupal::service('module_installer')->uninstall(['migrate_tools_test']);
+    $this->enableModules(['migrate_drupal']);
+    \Drupal::configFactory()->getEditable('migrate_plus.migration.fruit_terms')->delete();
+    $rows = $this->commands->status();
+    $this->assertEmpty($rows);
+  }
+
+  /**
+   * Tests that a failing status throws an exception (i.e. exit code).
+   */
+  public function testFailingStatusThrowsException(): void {
+    $this->expectException(\Exception::class);
+    $this->expectExceptionMessage('The "does_not_exist" plugin does not exist.');
+    $this->commands->status('invalid_plugin');
+  }
+
+  /**
+   * Tests drush mim.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\PluginException
+   */
+  public function testImport(): void {
+    /** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
+    $migration = $this->migrationPluginManager->createInstance('fruit_terms');
+    $id_map = $migration->getIdMap();
+    $this->commands->import('fruit_terms', array_merge($this->importBaseOptions, ['idlist' => 'Apple']));
+    $this->assertSame(1, $id_map->importedCount());
+    $this->commands->import('fruit_terms', $this->importBaseOptions);
+    $this->assertSame(3, $id_map->importedCount());
+    $this->commands->import('fruit_terms', array_merge($this->importBaseOptions, ['idlist' => 'Apple', 'update' => TRUE]));
+    $this->assertCount(0, $id_map->getRowsNeedingUpdate(100));
+  }
+
+  /**
+   * Tests that a failing import throws an exception (i.e. exit code).
+   */
+  public function testFailingImportThrowsException(): void {
+    $this->expectException(\Exception::class);
+    $this->expectExceptionMessage('source_exception migration failed.');
+    $this->commands->import('source_exception', $this->importBaseOptions);
+  }
+
+  /**
+   * Tests drush mmsg.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\PluginException
+   */
+  public function testMessages(): void {
+    $this->executeMigration('fruit_terms');
+    $message = $this->getRandomGenerator()->string(16);
+    /** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
+    $migration = $this->migrationPluginManager->createInstance('fruit_terms');
+    $id_map = $migration->getIdMap();
+    $id_map->saveMessage(['name' => 'Apple'], $message);
+    /** @var \Consolidation\OutputFormatters\StructuredData\RowsOfFields $result */
+    $result = $this->commands->messages('fruit_terms', [
+      'csv' => FALSE,
+      'idlist' => NULL,
+      'idlist-delimiter' => MigrateTools::DEFAULT_ID_LIST_DELIMITER,
+    ]);
+    $rows = $result->getArrayCopy();
+    $this->assertSame($message, $rows[0]['message']);
+  }
+
+  /**
+   * Tests that a failing messages throws an exception (i.e. exit code).
+   */
+  public function testFailingMessagesThrowsException(): void {
+    $this->expectException(\Exception::class);
+    $this->expectExceptionMessage('Migration does_not_exist does not exist');
+    $this->commands->messages('does_not_exist', [
+      'csv' => FALSE,
+      'idlist' => NULL,
+      'idlist-delimiter' => MigrateTools::DEFAULT_ID_LIST_DELIMITER,
+    ]);
+  }
+
+  /**
+   * Tests drush mr.
+   */
+  public function testRollback(): void {
+    $this->executeMigration('fruit_terms');
+    /** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
+    $migration = $this->migrationPluginManager->createInstance('fruit_terms');
+    $id_map = $migration->getIdMap();
+    $this->assertSame(3, $id_map->importedCount());
+    $this->commands->rollback('fruit_terms', $this->importBaseOptions);
+    $this->assertSame(0, $id_map->importedCount());
+  }
+
+  /**
+   * Tests that a failing rollback throws an exception (i.e. exit code).
+   */
+  public function testFailingRollbackThrowsException(): void {
+    $this->expectException(\Exception::class);
+    $this->expectExceptionMessage('source_exception migration failed');
+    /** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
+    $migration = $this->migrationPluginManager->createInstance('source_exception');
+    $migration->setStatus(MigrationInterface::STATUS_IMPORTING);
+    $this->commands->rollback('source_exception', $this->importBaseOptions);
+  }
+
+  /**
+   * Tests drush mrs.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\PluginException
+   */
+  public function testReset(): void {
+    /** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
+    $migration = $this->migrationPluginManager->createInstance('fruit_terms');
+    $migration->setStatus(MigrationInterface::STATUS_IMPORTING);
+    $status = $this->commands->status('fruit_terms', [
+      'group' => NULL,
+      'tag' => NULL,
+      'names-only' => FALSE,
+    ])->getArrayCopy()[0]['status'];
+    $this->assertSame('Importing', $status);
+    $this->commands->resetStatus('fruit_terms');
+    $this->assertSame(MigrationInterface::STATUS_IDLE, $migration->getStatus());
+
+  }
+
+  /**
+   * Tests that a failing reset status throws an exception (i.e. exit code).
+   */
+  public function testFailingResetStatusThrowsException(): void {
+    $this->expectException(\Exception::class);
+    $this->expectExceptionMessage('Migration does_not_exist does not exist');
+    $this->commands->resetStatus('does_not_exist');
+  }
+
+  /**
+   * Tests drush mst.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\PluginException
+   */
+  public function testStop(): void {
+    /** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
+    $migration = $this->migrationPluginManager->createInstance('fruit_terms');
+    $migration->setStatus(MigrationInterface::STATUS_IMPORTING);
+    $this->commands->stop('fruit_terms');
+    $this->assertSame(MigrationInterface::STATUS_STOPPING, $migration->getStatus());
+  }
+
+  /**
+   * Tests that a failing stop throws an exception (i.e. exit code).
+   */
+  public function testFailingStopThrowsException(): void {
+    $this->expectException(\Exception::class);
+    $this->expectExceptionMessage('Migration does_not_exist does not exist');
+    $this->commands->stop('does_not_exist');
+  }
+
+  /**
+   * Tests drush mfs.
+   */
+  public function testFieldsSource(): void {
+    /** @var \Consolidation\OutputFormatters\StructuredData\RowsOfFields $result */
+    $result = $this->commands->fieldsSource('fruit_terms');
+    $rows = $result->getArrayCopy();
+    $this->assertCount(1, $rows);
+    $this->assertSame('name', $rows[0]['machine_name']);
+    $this->assertSame('name', $rows[0]['description']);
+  }
+
+  /**
+   * Tests that a failing fields source throws an exception (i.e. exit code).
+   */
+  public function testFailingFieldsSourceThrowsException(): void {
+    $this->expectException(\Exception::class);
+    $this->expectExceptionMessage('Migration does_not_exist does not exist');
+    $this->commands->fieldsSource('does_not_exist');
+  }
+
+}
+
+namespace Drupal\migrate_tools\Commands;
+
+/**
+ * Stub for drush_op.
+ *
+ * @param callable $callable
+ *   The function to call.
+ */
+function drush_op(callable $callable) {
+  $args = func_get_args();
+  array_shift($args);
+  return call_user_func_array($callable, $args);
+}
+
+/**
+ * Stub for dt().
+ *
+ * @param string $text
+ *   The text.
+ * @param array $args
+ *   An associative array of replacement items.
+ *
+ * @return string
+ *   The text.
+ */
+function dt($text, array $args = []) {
+  foreach ($args as $before => $after) {
+    $text = str_replace($before, $after, $text);
+  }
+  return $text;
+}
diff --git a/web/modules/migrate_tools/tests/src/Kernel/MigrateImportTest.php b/web/modules/migrate_tools/tests/src/Kernel/MigrateImportTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..38b8c9d296c48f6e65e27055889947619ceb5bd6
--- /dev/null
+++ b/web/modules/migrate_tools/tests/src/Kernel/MigrateImportTest.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Drupal\Tests\migrate_tools\Kernel;
+
+use Drupal\migrate_tools\MigrateExecutable;
+use Drupal\taxonomy\Entity\Vocabulary;
+use Drupal\taxonomy\VocabularyInterface;
+use Drupal\Tests\migrate\Kernel\MigrateTestBase;
+
+/**
+ * Tests imports.
+ *
+ * @group migrate
+ */
+class MigrateImportTest extends MigrateTestBase {
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = [
+    'field',
+    'system',
+    'taxonomy',
+    'text',
+    'user',
+    'system',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->installEntitySchema('user');
+    $this->installEntitySchema('taxonomy_vocabulary');
+    $this->installEntitySchema('taxonomy_term');
+    $this->installConfig(['taxonomy']);
+  }
+
+  /**
+   * Tests rolling back configuration and content entities.
+   */
+  public function testImport(): void {
+    // We use vocabularies to demonstrate importing and rolling back
+    // configuration entities.
+    $vocabulary_data_rows = [
+      ['id' => '1', 'name' => 'categories', 'weight' => '2'],
+      ['id' => '2', 'name' => 'tags', 'weight' => '1'],
+    ];
+    $ids = ['id' => ['type' => 'integer']];
+    $definition = [
+      'id' => 'vocabularies',
+      'migration_tags' => ['Import and rollback test'],
+      'source' => [
+        'plugin' => 'embedded_data',
+        'data_rows' => $vocabulary_data_rows,
+        'ids' => $ids,
+      ],
+      'process' => [
+        'vid' => 'id',
+        'name' => 'name',
+        'weight' => 'weight',
+      ],
+      'destination' => ['plugin' => 'entity:taxonomy_vocabulary'],
+    ];
+
+    /** @var \Drupal\migrate\Plugin\MigrationInterface $vocabulary_migration */
+    $vocabulary_migration = \Drupal::service('plugin.manager.migration')->createStubMigration($definition);
+    $vocabulary_id_map = $vocabulary_migration->getIdMap();
+
+    // Test id list import.
+    $executable = new MigrateExecutable($vocabulary_migration, $this, ['idlist' => 2]);
+    $executable->import();
+
+    /** @var \Drupal\taxonomy\Entity\Vocabulary $vocabulary */
+    $vocabulary = Vocabulary::load(1);
+    $this->assertEmpty($vocabulary);
+    $map_row = $vocabulary_id_map->getRowBySource(['id' => 1]);
+    $this->assertEmpty($map_row);
+    /** @var \Drupal\taxonomy\Entity\Vocabulary $vocabulary */
+    $vocabulary = Vocabulary::load(2);
+    $this->assertInstanceOf(VocabularyInterface::class, $vocabulary);
+    $map_row = $vocabulary_id_map->getRowBySource(['id' => 2]);
+    $this->assertEqual($map_row['destid1'], $vocabulary->id());
+  }
+
+}
diff --git a/web/modules/migrate_tools/tests/src/Kernel/MigrateRollbackTest.php b/web/modules/migrate_tools/tests/src/Kernel/MigrateRollbackTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..202d4f54d89b91ccb8203500e57e98a483342054
--- /dev/null
+++ b/web/modules/migrate_tools/tests/src/Kernel/MigrateRollbackTest.php
@@ -0,0 +1,99 @@
+<?php
+
+namespace Drupal\Tests\migrate_tools\Kernel;
+
+use Drupal\migrate_tools\MigrateExecutable;
+use Drupal\taxonomy\Entity\Vocabulary;
+use Drupal\taxonomy\VocabularyInterface;
+use Drupal\Tests\migrate\Kernel\MigrateTestBase;
+
+/**
+ * Tests rolling back of imports.
+ *
+ * @group migrate
+ */
+class MigrateRollbackTest extends MigrateTestBase {
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = [
+    'field',
+    'system',
+    'taxonomy',
+    'text',
+    'user',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->installEntitySchema('user');
+    $this->installEntitySchema('taxonomy_vocabulary');
+    $this->installEntitySchema('taxonomy_term');
+    $this->installConfig(['taxonomy']);
+  }
+
+  /**
+   * Tests rolling back configuration and content entities.
+   */
+  public function testRollback(): void {
+    // We use vocabularies to demonstrate importing and rolling back
+    // configuration entities.
+    $vocabulary_data_rows = [
+      ['id' => '1', 'name' => 'categories', 'weight' => '2'],
+      ['id' => '2', 'name' => 'tags', 'weight' => '1'],
+    ];
+    $ids = ['id' => ['type' => 'integer']];
+    $definition = [
+      'id' => 'vocabularies',
+      'migration_tags' => ['Import and rollback test'],
+      'source' => [
+        'plugin' => 'embedded_data',
+        'data_rows' => $vocabulary_data_rows,
+        'ids' => $ids,
+      ],
+      'process' => [
+        'vid' => 'id',
+        'name' => 'name',
+        'weight' => 'weight',
+      ],
+      'destination' => ['plugin' => 'entity:taxonomy_vocabulary'],
+    ];
+
+    /** @var \Drupal\migrate\Plugin\MigrationInterface $vocabulary_migration */
+    $vocabulary_migration = \Drupal::service('plugin.manager.migration')->createStubMigration($definition);
+    $vocabulary_id_map = $vocabulary_migration->getIdMap();
+
+    // Import and validate vocabulary config entities were created.
+    $executable = new MigrateExecutable($vocabulary_migration, $this, []);
+    $executable->import();
+    foreach ($vocabulary_data_rows as $row) {
+      /** @var \Drupal\taxonomy\Entity\Vocabulary $vocabulary */
+      $vocabulary = Vocabulary::load($row['id']);
+      $this->assertInstanceOf(VocabularyInterface::class, $vocabulary);
+      $map_row = $vocabulary_id_map->getRowBySource(['id' => $row['id']]);
+      $this->assertEqual($map_row['destid1'], $vocabulary->id());
+    }
+
+    // Test id list rollback.
+    $rollback_executable = new MigrateExecutable($vocabulary_migration, $this, ['idlist' => 1]);
+    $rollback_executable->rollback();
+    /** @var \Drupal\taxonomy\Entity\Vocabulary $vocabulary */
+    $vocabulary = Vocabulary::load(1);
+    $this->assertEmpty($vocabulary);
+    $map_row = $vocabulary_id_map->getRowBySource(['id' => 1]);
+    $this->assertEmpty($map_row);
+
+    /** @var \Drupal\taxonomy\Entity\Vocabulary $vocabulary */
+    $vocabulary = Vocabulary::load(2);
+    $this->assertInstanceOf(VocabularyInterface::class, $vocabulary);
+    $map_row = $vocabulary_id_map->getRowBySource(['id' => 2]);
+    $this->assertEqual($map_row['destid1'], $vocabulary->id());
+  }
+
+}
diff --git a/web/modules/migrate_tools/tests/src/Unit/MigrateToolsTest.php b/web/modules/migrate_tools/tests/src/Unit/MigrateToolsTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..da243e50955ad9100e4721b1df1d006575395cde
--- /dev/null
+++ b/web/modules/migrate_tools/tests/src/Unit/MigrateToolsTest.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Drupal\Tests\migrate_tools\Unit;
+
+use Drupal\migrate_tools\MigrateTools;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\migrate_tools\MigrateTools
+ * @group migrate_tools
+ */
+class MigrateToolsTest extends UnitTestCase {
+
+  /**
+   * @covers ::buildIdList
+   *
+   * @dataProvider dataProviderIdList
+   */
+  public function testBuildIdList(array $options, array $expected): void {
+    $results = MigrateTools::buildIdList($options);
+    $this->assertEquals($results, $expected);
+  }
+
+  /**
+   * Data provider for testBuildIdList.
+   */
+  public function dataProviderIdList(): array {
+    $cases = [];
+    $cases[] = [
+      'options' => [],
+      'expected' => [],
+    ];
+    $cases['single id'] = [
+      'options' => [
+        'idlist' => 123,
+      ],
+      'expected' => [[123]],
+    ];
+    $cases['multiple ids'] = [
+      'options' => [
+        'idlist' => '123, 456',
+      ],
+      'expected' => [
+        [123], [456],
+      ],
+    ];
+    $cases['default delimiter, composite key'] = [
+      'options' => [
+        'idlist' => '123:456',
+      ],
+      'expected' => [
+        [123, 456],
+      ],
+    ];
+    $cases['special delimiter, single'] = [
+      'options' => [
+        'idlist' => '123:456',
+        'idlist-delimiter' => '~',
+      ],
+      'expected' => [
+        ['123:456'],
+      ],
+    ];
+    $cases['special delimiter, multiple'] = [
+      'options' => [
+        'idlist' => '123:456~987:654',
+        'idlist-delimiter' => '~',
+      ],
+      'expected' => [
+        ['123:456', '987:654'],
+      ],
+    ];
+    $cases['space delimiter, multiple'] = [
+      'options' => [
+        'idlist' => '123:456 987:654',
+        'idlist-delimiter' => ' ',
+      ],
+      'expected' => [
+        ['123:456', '987:654'],
+      ],
+    ];
+    return $cases;
+  }
+
+}