From 91ec35d57f1437a00529ded41ca3765eacf89ea3 Mon Sep 17 00:00:00 2001
From: bcweaver <brianweaver@gmail.com>
Date: Wed, 17 Oct 2018 14:10:05 -0400
Subject: [PATCH] Adding 'migrate_tools' module (and upgrade 'migrate_plus')

---
 composer.json                                 |    3 +-
 composer.lock                                 |   95 +-
 vendor/composer/installed.json                |   97 +-
 web/modules/migrate_plus/README.txt           |   71 +-
 web/modules/migrate_plus/composer.json        |    5 +-
 .../schema/migrate_plus.data_types.schema.yml |   30 +-
 .../schema/migrate_plus.process.schema.yml    |    4 +-
 .../config/schema/migrate_plus.schema.yml     |    9 +
 .../migrate_plus/migrate_example/README.txt   |   32 +-
 .../migrate_plus.migration.beer_node.yml      |    6 +-
 .../migrate_plus.migration.beer_term.yml      |    6 +-
 .../migrate_plus.migration.beer_user.yml      |    4 +-
 .../migrate_example/migrate_example.info.yml  |   14 +-
 ...play.node.migrate_example_beer.default.yml |    6 -
 .../node.type.migrate_example_beer.yml        |    8 -
 .../migrate_example_setup.info.yml            |   16 +-
 .../migrate_example_setup.install             |  419 ++++---
 .../beer_comment.yml}                         |    6 +-
 .../src/Plugin/migrate/source/BeerComment.php |   15 +-
 .../src/Plugin/migrate/source/BeerNode.php    |   46 +-
 .../src/Plugin/migrate/source/BeerTerm.php    |   58 +-
 .../src/Plugin/migrate/source/BeerUser.php    |   45 +-
 .../migrate_plus.migration.weather_soap.yml   |   24 +-
 .../migrate_plus.migration.wine_role_json.yml |   17 +-
 .../migrate_plus.migration.wine_role_xml.yml  |    5 +-
 .../migrate_plus.migration.wine_terms.yml     |    4 +-
 ...e_plus.migration.wine_variety_list.yml.txt |    7 +-
 ..._plus.migration.wine_variety_multi_xml.yml |    4 +-
 .../migrate_example_advanced.info.yml         |   12 +-
 .../migrate_example_advanced.install          |   24 +-
 ...urce.migrate_example_advanced_position.yml |   10 +
 ...migrate_example_advanced_variety_items.yml |   10 +
 ....migrate_example_advanced_variety_list.yml |   10 +
 ...rate_example_advanced_variety_multiple.yml |   10 +
 .../migrate_example_advanced_setup.info.yml   |   16 +-
 .../migrate_example_advanced_setup.install    | 1034 +++++++++++------
 .../Plugin/rest/resource/PositionResource.php |    8 +
 .../src/Plugin/rest/resource/VarietyItems.php |   20 +-
 .../src/Plugin/rest/resource/VarietyList.php  |    8 +
 .../rest/resource/VarietyMultiFiles.php       |   20 +-
 .../src/Plugin/migrate/source/WineTerm.php    |   13 +-
 .../migrate_plus/migrate_plus.info.yml        |    9 +-
 web/modules/migrate_plus/migrate_plus.module  |   18 +
 .../migrate_plus/migrate_plus.services.yml    |    6 +-
 .../migrations/migration_config_deriver.yml   |    6 +
 web/modules/migrate_plus/phpcs.xml            |  207 ++++
 .../src/Annotation/Authentication.php         |   37 +
 .../src/Annotation/DataFetcher.php            |    2 +-
 .../src/Annotation/DataParser.php             |    2 +-
 .../src/AuthenticationPluginBase.php          |   25 +
 .../src/AuthenticationPluginInterface.php     |   25 +
 .../src/AuthenticationPluginManager.php       |   37 +
 .../src/DataFetcherPluginInterface.php        |    7 +-
 .../src/DataFetcherPluginManager.php          |    2 +-
 .../migrate_plus/src/DataParserPluginBase.php |   13 +-
 .../src/DataParserPluginManager.php           |    2 +-
 .../migrate_plus/src/Event/MigrateEvents.php  |    3 +-
 .../src/Event/MigratePrepareRowEvent.php      |    2 +-
 .../Discovery/ConfigEntityDiscovery.php       |   49 -
 .../src/Plugin/MigrationConfigDeriver.php     |   28 +
 .../MigrationConfigEntityPluginManager.php    |   25 -
 .../src/Plugin/migrate/destination/Table.php  |  143 +++
 .../src/Plugin/migrate/process/ArrayPop.php   |   44 +
 .../src/Plugin/migrate/process/ArrayShift.php |   44 +
 .../Plugin/migrate/process/EntityGenerate.php |  125 +-
 .../Plugin/migrate/process/EntityLookup.php   |  157 ++-
 .../src/Plugin/migrate/process/FileBlob.php   |  155 +++
 .../src/Plugin/migrate/process/Merge.php      |   68 ++
 .../Plugin/migrate/process/MultipleValues.php |   58 +
 .../Plugin/migrate/process/SingleValue.php    |   45 +
 .../Plugin/migrate/process/SkipOnValue.php    |  135 +++
 .../src/Plugin/migrate/process/StrReplace.php |  104 ++
 .../migrate/process/Transliteration.php       |   83 ++
 .../src/Plugin/migrate/source/Url.php         |   61 -
 .../migrate_plus/authentication/Basic.php     |   30 +
 .../migrate_plus/authentication/Digest.php    |   31 +
 .../migrate_plus/authentication/OAuth2.php    |   69 ++
 .../Plugin/migrate_plus/data_fetcher/File.php |   52 +
 .../Plugin/migrate_plus/data_fetcher/Http.php |   53 +-
 .../Plugin/migrate_plus/data_parser/Json.php  |  113 +-
 .../migrate_plus/data_parser/SimpleXml.php    |   87 ++
 .../Plugin/migrate_plus/data_parser/Soap.php  |    9 +-
 .../Plugin/migrate_plus/data_parser/Xml.php   |   94 +-
 .../migrate_plus/data_parser/XmlTrait.php     |   62 +
 .../tests/data/missing_properties.json        |   21 +
 .../data/simple_xml_reduce_single_value.xml   |   14 +
 .../tests/src/Functional/LoadTest.php         |   50 +
 .../tests/src/Kernel/MigrateTableTest.php     |  163 +++
 .../src/Kernel}/MigrationConfigEntityTest.php |   29 +-
 .../src/Kernel}/MigrationGroupTest.php        |   66 +-
 .../migrate/process/EntityGenerateTest.php    |  788 +++++++++++++
 .../migrate_plus/data_fetcher/HttpTest.php    |   59 +
 .../migrate_plus/data_parser/JsonTest.php     |  112 ++
 .../data_parser/SimpleXmlTest.php             |   77 ++
 .../tests/src/Unit/process/ArrayPopTest.php   |   71 ++
 .../tests/src/Unit/process/ArrayShiftTest.php |   71 ++
 .../src/Unit/process/MultipleValuesTest.php   |   32 +
 .../src/Unit/process/SingleValueTest.php      |   32 +
 .../src/Unit/process/SkipOnValueTest.php      |  145 +++
 .../tests/src/Unit/process/StrReplaceTest.php |  100 ++
 .../src/Unit/process/TransliterationTest.php  |   51 +
 web/modules/migrate_tools/LICENSE.txt         |  339 ++++++
 web/modules/migrate_tools/README.txt          |   15 +
 web/modules/migrate_tools/composer.json       |   27 +
 web/modules/migrate_tools/drush.services.yml  |    6 +
 .../migrate_tools/migrate_tools.drush.inc     |  561 +++++++++
 .../migrate_tools/migrate_tools.info.yml      |   16 +
 .../migrate_tools.links.action.yml            |   11 +
 .../migrate_tools.links.menu.yml              |    5 +
 .../migrate_tools.links.task.yml              |   53 +
 .../migrate_tools/migrate_tools.module        |   58 +
 .../migrate_tools.permissions.yml             |    3 +
 .../migrate_tools/migrate_tools.routing.yml   |  197 ++++
 .../migrate_tools/migrate_tools.services.yml  |    9 +
 web/modules/migrate_tools/phpcs.xml           |  207 ++++
 .../src/Commands/MigrateToolsCommands.php     |  719 ++++++++++++
 .../src/Controller/MessageController.php      |  144 +++
 .../src/Controller/MigrationController.php    |  300 +++++
 .../Controller/MigrationGroupListBuilder.php  |   68 ++
 .../src/Controller/MigrationListBuilder.php   |  244 ++++
 .../src/Drush9LogMigrateMessage.php           |   48 +
 .../src/DrushLogMigrateMessage.php            |   28 +
 .../src/Form/MigrationAddForm.php             |   37 +
 .../src/Form/MigrationDeleteForm.php          |   71 ++
 .../src/Form/MigrationEditForm.php            |   50 +
 .../src/Form/MigrationExecuteForm.php         |  212 ++++
 .../src/Form/MigrationFormBase.php            |  184 +++
 .../src/Form/MigrationGroupAddForm.php        |   37 +
 .../src/Form/MigrationGroupDeleteForm.php     |   71 ++
 .../src/Form/MigrationGroupEditForm.php       |   35 +
 .../src/Form/MigrationGroupFormBase.php       |  172 +++
 .../migrate_tools/src/Form/SourceCsvForm.php  |  453 ++++++++
 .../src/MigrateBatchExecutable.php            |  295 +++++
 .../migrate_tools/src/MigrateExecutable.php   |  395 +++++++
 .../src/Routing/RouteProcessor.php            |   27 +
 ...migrate_plus.migration.csv_source_test.yml |   33 +
 .../migrate_plus.migration_group.csv_test.yml |    4 +
 .../csv_source_test/csv_source_test.info.yml  |   15 +
 .../migrate_plus.migration.fruit_terms.yml    |   32 +
 .../migrate_plus.migration_group.default.yml  |    9 +
 .../migrate_tools_test.info.yml               |   14 +
 .../Functional/MigrateExecutionFormTest.php   |  132 +++
 .../src/Functional/SourceCsvFormTest.php      |  241 ++++
 143 files changed, 10940 insertions(+), 1096 deletions(-)
 rename web/modules/migrate_plus/migrate_example/{config/install/migrate_plus.migration.beer_comment.yml => migrations/beer_comment.yml} (90%)
 create mode 100644 web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/rest.resource.migrate_example_advanced_position.yml
 create mode 100644 web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/rest.resource.migrate_example_advanced_variety_items.yml
 create mode 100644 web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/rest.resource.migrate_example_advanced_variety_list.yml
 create mode 100644 web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/rest.resource.migrate_example_advanced_variety_multiple.yml
 create mode 100644 web/modules/migrate_plus/migrations/migration_config_deriver.yml
 create mode 100644 web/modules/migrate_plus/phpcs.xml
 create mode 100644 web/modules/migrate_plus/src/Annotation/Authentication.php
 create mode 100644 web/modules/migrate_plus/src/AuthenticationPluginBase.php
 create mode 100644 web/modules/migrate_plus/src/AuthenticationPluginInterface.php
 create mode 100644 web/modules/migrate_plus/src/AuthenticationPluginManager.php
 delete mode 100644 web/modules/migrate_plus/src/Plugin/Discovery/ConfigEntityDiscovery.php
 create mode 100644 web/modules/migrate_plus/src/Plugin/MigrationConfigDeriver.php
 delete mode 100644 web/modules/migrate_plus/src/Plugin/MigrationConfigEntityPluginManager.php
 create mode 100644 web/modules/migrate_plus/src/Plugin/migrate/destination/Table.php
 create mode 100644 web/modules/migrate_plus/src/Plugin/migrate/process/ArrayPop.php
 create mode 100644 web/modules/migrate_plus/src/Plugin/migrate/process/ArrayShift.php
 create mode 100644 web/modules/migrate_plus/src/Plugin/migrate/process/FileBlob.php
 create mode 100644 web/modules/migrate_plus/src/Plugin/migrate/process/Merge.php
 create mode 100644 web/modules/migrate_plus/src/Plugin/migrate/process/MultipleValues.php
 create mode 100644 web/modules/migrate_plus/src/Plugin/migrate/process/SingleValue.php
 create mode 100644 web/modules/migrate_plus/src/Plugin/migrate/process/SkipOnValue.php
 create mode 100644 web/modules/migrate_plus/src/Plugin/migrate/process/StrReplace.php
 create mode 100644 web/modules/migrate_plus/src/Plugin/migrate/process/Transliteration.php
 create mode 100644 web/modules/migrate_plus/src/Plugin/migrate_plus/authentication/Basic.php
 create mode 100644 web/modules/migrate_plus/src/Plugin/migrate_plus/authentication/Digest.php
 create mode 100644 web/modules/migrate_plus/src/Plugin/migrate_plus/authentication/OAuth2.php
 create mode 100644 web/modules/migrate_plus/src/Plugin/migrate_plus/data_fetcher/File.php
 create mode 100644 web/modules/migrate_plus/src/Plugin/migrate_plus/data_parser/SimpleXml.php
 create mode 100644 web/modules/migrate_plus/src/Plugin/migrate_plus/data_parser/XmlTrait.php
 create mode 100644 web/modules/migrate_plus/tests/data/missing_properties.json
 create mode 100644 web/modules/migrate_plus/tests/data/simple_xml_reduce_single_value.xml
 create mode 100644 web/modules/migrate_plus/tests/src/Functional/LoadTest.php
 create mode 100644 web/modules/migrate_plus/tests/src/Kernel/MigrateTableTest.php
 rename web/modules/migrate_plus/{src/Tests => tests/src/Kernel}/MigrationConfigEntityTest.php (50%)
 rename web/modules/migrate_plus/{src/Tests => tests/src/Kernel}/MigrationGroupTest.php (65%)
 create mode 100644 web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate/process/EntityGenerateTest.php
 create mode 100644 web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate_plus/data_fetcher/HttpTest.php
 create mode 100644 web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate_plus/data_parser/JsonTest.php
 create mode 100644 web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate_plus/data_parser/SimpleXmlTest.php
 create mode 100644 web/modules/migrate_plus/tests/src/Unit/process/ArrayPopTest.php
 create mode 100644 web/modules/migrate_plus/tests/src/Unit/process/ArrayShiftTest.php
 create mode 100644 web/modules/migrate_plus/tests/src/Unit/process/MultipleValuesTest.php
 create mode 100644 web/modules/migrate_plus/tests/src/Unit/process/SingleValueTest.php
 create mode 100644 web/modules/migrate_plus/tests/src/Unit/process/SkipOnValueTest.php
 create mode 100644 web/modules/migrate_plus/tests/src/Unit/process/StrReplaceTest.php
 create mode 100644 web/modules/migrate_plus/tests/src/Unit/process/TransliterationTest.php
 create mode 100644 web/modules/migrate_tools/LICENSE.txt
 create mode 100644 web/modules/migrate_tools/README.txt
 create mode 100644 web/modules/migrate_tools/composer.json
 create mode 100644 web/modules/migrate_tools/drush.services.yml
 create mode 100644 web/modules/migrate_tools/migrate_tools.drush.inc
 create mode 100644 web/modules/migrate_tools/migrate_tools.info.yml
 create mode 100644 web/modules/migrate_tools/migrate_tools.links.action.yml
 create mode 100644 web/modules/migrate_tools/migrate_tools.links.menu.yml
 create mode 100644 web/modules/migrate_tools/migrate_tools.links.task.yml
 create mode 100644 web/modules/migrate_tools/migrate_tools.module
 create mode 100644 web/modules/migrate_tools/migrate_tools.permissions.yml
 create mode 100644 web/modules/migrate_tools/migrate_tools.routing.yml
 create mode 100644 web/modules/migrate_tools/migrate_tools.services.yml
 create mode 100644 web/modules/migrate_tools/phpcs.xml
 create mode 100644 web/modules/migrate_tools/src/Commands/MigrateToolsCommands.php
 create mode 100644 web/modules/migrate_tools/src/Controller/MessageController.php
 create mode 100644 web/modules/migrate_tools/src/Controller/MigrationController.php
 create mode 100644 web/modules/migrate_tools/src/Controller/MigrationGroupListBuilder.php
 create mode 100644 web/modules/migrate_tools/src/Controller/MigrationListBuilder.php
 create mode 100644 web/modules/migrate_tools/src/Drush9LogMigrateMessage.php
 create mode 100644 web/modules/migrate_tools/src/DrushLogMigrateMessage.php
 create mode 100644 web/modules/migrate_tools/src/Form/MigrationAddForm.php
 create mode 100644 web/modules/migrate_tools/src/Form/MigrationDeleteForm.php
 create mode 100644 web/modules/migrate_tools/src/Form/MigrationEditForm.php
 create mode 100644 web/modules/migrate_tools/src/Form/MigrationExecuteForm.php
 create mode 100644 web/modules/migrate_tools/src/Form/MigrationFormBase.php
 create mode 100644 web/modules/migrate_tools/src/Form/MigrationGroupAddForm.php
 create mode 100644 web/modules/migrate_tools/src/Form/MigrationGroupDeleteForm.php
 create mode 100644 web/modules/migrate_tools/src/Form/MigrationGroupEditForm.php
 create mode 100644 web/modules/migrate_tools/src/Form/MigrationGroupFormBase.php
 create mode 100644 web/modules/migrate_tools/src/Form/SourceCsvForm.php
 create mode 100644 web/modules/migrate_tools/src/MigrateBatchExecutable.php
 create mode 100644 web/modules/migrate_tools/src/MigrateExecutable.php
 create mode 100644 web/modules/migrate_tools/src/Routing/RouteProcessor.php
 create mode 100644 web/modules/migrate_tools/tests/modules/csv_source_test/config/install/migrate_plus.migration.csv_source_test.yml
 create mode 100644 web/modules/migrate_tools/tests/modules/csv_source_test/config/install/migrate_plus.migration_group.csv_test.yml
 create mode 100644 web/modules/migrate_tools/tests/modules/csv_source_test/csv_source_test.info.yml
 create mode 100644 web/modules/migrate_tools/tests/modules/migrate_tools_test/config/install/migrate_plus.migration.fruit_terms.yml
 create mode 100644 web/modules/migrate_tools/tests/modules/migrate_tools_test/config/install/migrate_plus.migration_group.default.yml
 create mode 100644 web/modules/migrate_tools/tests/modules/migrate_tools_test/migrate_tools_test.info.yml
 create mode 100644 web/modules/migrate_tools/tests/src/Functional/MigrateExecutionFormTest.php
 create mode 100644 web/modules/migrate_tools/tests/src/Functional/SourceCsvFormTest.php

diff --git a/composer.json b/composer.json
index 17e5f17ba4..3cb96d7820 100644
--- a/composer.json
+++ b/composer.json
@@ -121,7 +121,8 @@
         "drupal/media_entity_browser": "2.0-alpha1",
         "drupal/media_entity_twitter": "2.0-alpha2",
         "drupal/menu_block": "1.4",
-        "drupal/migrate_plus": "^2.0",
+        "drupal/migrate_plus": "4.0",
+        "drupal/migrate_tools": "4.0",
         "drupal/nice_menus": "1.0-beta2",
         "drupal/paragraphs": "1.3",
         "drupal/pathauto": "1.0",
diff --git a/composer.lock b/composer.lock
index 7fa6e4bd7a..3309f1b75c 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
         "This file is @generated automatically"
     ],
-    "content-hash": "549076b42f1930243fcc38aef6b64e85",
+    "content-hash": "1765d49ab9251383f8e8c2d4b540106a",
     "packages": [
         {
             "name": "alchemy/zippy",
@@ -4971,36 +4971,40 @@
         },
         {
             "name": "drupal/migrate_plus",
-            "version": "2.0.0-beta2",
+            "version": "4.0.0",
             "source": {
                 "type": "git",
                 "url": "https://git.drupal.org/project/migrate_plus",
-                "reference": "8.x-2.0-beta2"
+                "reference": "8.x-4.0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://ftp.drupal.org/files/projects/migrate_plus-8.x-2.0-beta2.zip",
-                "reference": "8.x-2.0-beta2",
-                "shasum": "0b29113a3c00c7ab3ba73e811da3450edca4c0bb"
+                "url": "https://ftp.drupal.org/files/projects/migrate_plus-8.x-4.0.zip",
+                "reference": "8.x-4.0",
+                "shasum": "63dad289defe8298aa5ca5e30062fe9761d19eca"
             },
             "require": {
-                "drupal/core": "^8.1"
+                "drupal/core": "^8.3"
             },
             "require-dev": {
                 "drupal/migrate_example_advanced_setup": "*",
                 "drupal/migrate_example_setup": "*"
             },
+            "suggest": {
+                "ext-soap": "*",
+                "sainsburys/guzzle-oauth2-plugin": "3.0 required for the OAuth2 authentication plugin"
+            },
             "type": "drupal-module",
             "extra": {
                 "branch-alias": {
-                    "dev-2.x": "2.x-dev"
+                    "dev-4.x": "4.x-dev"
                 },
                 "drupal": {
-                    "version": "8.x-2.0-beta2",
-                    "datestamp": "1476307439",
+                    "version": "8.x-4.0",
+                    "datestamp": "1536264180",
                     "security-coverage": {
-                        "status": "not-covered",
-                        "message": "Beta releases are not covered by Drupal security advisories."
+                        "status": "covered",
+                        "message": "Covered by Drupal's security advisory policy"
                     }
                 }
             },
@@ -5027,6 +5031,73 @@
                 "irc": "irc://irc.freenode.org/drupal-migrate"
             }
         },
+        {
+            "name": "drupal/migrate_tools",
+            "version": "4.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://git.drupal.org/project/migrate_tools",
+                "reference": "8.x-4.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"
+            },
+            "require": {
+                "drupal/core": "^8.3",
+                "drupal/migrate_plus": "^4"
+            },
+            "require-dev": {
+                "drupal/coder": "^8",
+                "drupal/migrate_source_csv": "^2.2"
+            },
+            "type": "drupal-module",
+            "extra": {
+                "branch-alias": {
+                    "dev-4.x": "4.x-dev"
+                },
+                "drupal": {
+                    "version": "8.x-4.0",
+                    "datestamp": "1535380084",
+                    "security-coverage": {
+                        "status": "covered",
+                        "message": "Covered by Drupal's security advisory policy"
+                    }
+                },
+                "drush": {
+                    "services": {
+                        "drush.services.yml": "^9"
+                    }
+                }
+            },
+            "notification-url": "https://packages.drupal.org/8/downloads",
+            "license": [
+                "GPL-2.0+"
+            ],
+            "authors": [
+                {
+                    "name": "heddn",
+                    "homepage": "https://www.drupal.org/user/1463982"
+                },
+                {
+                    "name": "mikeryan",
+                    "homepage": "https://www.drupal.org/user/4420"
+                },
+                {
+                    "name": "moshe weitzman",
+                    "homepage": "https://www.drupal.org/user/23"
+                }
+            ],
+            "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"
+            }
+        },
         {
             "name": "drupal/nice_menus",
             "version": "1.0.0-beta2",
diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json
index 15b97d7f3c..87c4b83ce1 100644
--- a/vendor/composer/installed.json
+++ b/vendor/composer/installed.json
@@ -5125,37 +5125,41 @@
     },
     {
         "name": "drupal/migrate_plus",
-        "version": "2.0.0-beta2",
-        "version_normalized": "2.0.0.0-beta2",
+        "version": "4.0.0",
+        "version_normalized": "4.0.0.0",
         "source": {
             "type": "git",
             "url": "https://git.drupal.org/project/migrate_plus",
-            "reference": "8.x-2.0-beta2"
+            "reference": "8.x-4.0"
         },
         "dist": {
             "type": "zip",
-            "url": "https://ftp.drupal.org/files/projects/migrate_plus-8.x-2.0-beta2.zip",
-            "reference": "8.x-2.0-beta2",
-            "shasum": "0b29113a3c00c7ab3ba73e811da3450edca4c0bb"
+            "url": "https://ftp.drupal.org/files/projects/migrate_plus-8.x-4.0.zip",
+            "reference": "8.x-4.0",
+            "shasum": "63dad289defe8298aa5ca5e30062fe9761d19eca"
         },
         "require": {
-            "drupal/core": "^8.1"
+            "drupal/core": "^8.3"
         },
         "require-dev": {
             "drupal/migrate_example_advanced_setup": "*",
             "drupal/migrate_example_setup": "*"
         },
+        "suggest": {
+            "ext-soap": "*",
+            "sainsburys/guzzle-oauth2-plugin": "3.0 required for the OAuth2 authentication plugin"
+        },
         "type": "drupal-module",
         "extra": {
             "branch-alias": {
-                "dev-2.x": "2.x-dev"
+                "dev-4.x": "4.x-dev"
             },
             "drupal": {
-                "version": "8.x-2.0-beta2",
-                "datestamp": "1476307439",
+                "version": "8.x-4.0",
+                "datestamp": "1536264180",
                 "security-coverage": {
-                    "status": "not-covered",
-                    "message": "Beta releases are not covered by Drupal security advisories."
+                    "status": "covered",
+                    "message": "Covered by Drupal's security advisory policy"
                 }
             }
         },
@@ -5183,6 +5187,75 @@
             "irc": "irc://irc.freenode.org/drupal-migrate"
         }
     },
+    {
+        "name": "drupal/migrate_tools",
+        "version": "4.0.0",
+        "version_normalized": "4.0.0.0",
+        "source": {
+            "type": "git",
+            "url": "https://git.drupal.org/project/migrate_tools",
+            "reference": "8.x-4.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"
+        },
+        "require": {
+            "drupal/core": "^8.3",
+            "drupal/migrate_plus": "^4"
+        },
+        "require-dev": {
+            "drupal/coder": "^8",
+            "drupal/migrate_source_csv": "^2.2"
+        },
+        "type": "drupal-module",
+        "extra": {
+            "branch-alias": {
+                "dev-4.x": "4.x-dev"
+            },
+            "drupal": {
+                "version": "8.x-4.0",
+                "datestamp": "1535380084",
+                "security-coverage": {
+                    "status": "covered",
+                    "message": "Covered by Drupal's security advisory policy"
+                }
+            },
+            "drush": {
+                "services": {
+                    "drush.services.yml": "^9"
+                }
+            }
+        },
+        "installation-source": "dist",
+        "notification-url": "https://packages.drupal.org/8/downloads",
+        "license": [
+            "GPL-2.0+"
+        ],
+        "authors": [
+            {
+                "name": "heddn",
+                "homepage": "https://www.drupal.org/user/1463982"
+            },
+            {
+                "name": "mikeryan",
+                "homepage": "https://www.drupal.org/user/4420"
+            },
+            {
+                "name": "moshe weitzman",
+                "homepage": "https://www.drupal.org/user/23"
+            }
+        ],
+        "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"
+        }
+    },
     {
         "name": "drupal/nice_menus",
         "version": "1.0.0-beta2",
diff --git a/web/modules/migrate_plus/README.txt b/web/modules/migrate_plus/README.txt
index 8e46d4ced4..797082e388 100644
--- a/web/modules/migrate_plus/README.txt
+++ b/web/modules/migrate_plus/README.txt
@@ -4,26 +4,69 @@ and additional functionality, as well as providing practical examples.
 Extensions to base API
 ======================
 * A Migration configuration entity is provided, enabling persistance of dynamic
-migration configuration.
-* A ConfigEntityDiscovery class is implemented which enables plugin configuration
-to be based on configuration entities. This is fully general - it can be used
-for any configuration entity type, not just migrations.
-* A MigrationConfigEntityPluginManager class and corresponding
-plugin.manager.config_entity_migration service is provided, to enable discovery
-and instantiation of migration plugins based on the Migration configuration
-entity.
+  migration configuration.
 * A MigrationGroup configuration entity is provided, which enables migrations to
-be organized in groups, and to maintain shared configuration in one place.
+  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.
+  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.
+  IDs for a source plugin via configuration rather than requiring PHP code.
+
+Plugin types
+============
+migrate_plus provides the following plugin types, for use with the url source
+plugin.
+
+* A data_parser type, for parsing different formats on behalf of the url source
+  plugin.
+* A data_fetcher type, for fetching data to feed into a data_parser plugin.
+* An authentication type, for adding authentication headers with the http
+  data_fetcher plugin.
 
 Plugins
 =======
-* A Url source plugin is provided, implementing a common structure for
-file-based data providers.
-* XML and JSON fetchers and parsers for the Url source plugin are provided.
+
+Process
+-------
+* The entity_lookup process plugin allows you to populate references to entities
+  which already exist in Drupal, whether they were migrated or not.
+* The entity_generate process plugin extends entity_lookup to also create the
+  desired entity when it doesn't already exist.
+* The file_blob process plugin supports creating file entities from blob data.
+* The merge process plugin allows the merging of multiple arrays into a single
+  field.
+* The skip_on_value process plugin allows you to skip a row, or a given field,
+  for specific source values.
+
+Source
+------
+* A url source plugin is provided, implementing a common structure for
+  file-based data providers.
+
+Data parsers
+------------
+* The xml parser plugin uses PHP's XMLReader interface to incrementally parse
+  XML files. This should be used for XML sources which are potentially very
+  large.
+* The simple_xml parser plugin uses PHP's SimpleXML interface to fully parse
+  XML files. This should be used for XML sources where you need to be able to
+  use complex xpaths for your item selectors, or have to access elements outside
+  of the current item element via xpaths.
+* The json parser plugin supports JSON sources.
+* The soap parser plugin supports SOAP sources.
+
+Data fetchers
+-------------
+* The file fetcher plugin works for most URLs regardless of protocol, as well as
+  local filesystems.
+* The http fetcher plugin provides the ability to add headers to an HTTP
+  request (particularly through authentication plugins).
+
+Authentication
+--------------
+* The basic authentication plugin provides HTTP Basic authentication.
+* The digest authentication plugin provides HTTP Digest authentication.
+* The oauth2 authentication plugin provides OAuth2 authentication over HTTP.
 
 Examples
 ========
diff --git a/web/modules/migrate_plus/composer.json b/web/modules/migrate_plus/composer.json
index c4ec1be44f..54070eda95 100644
--- a/web/modules/migrate_plus/composer.json
+++ b/web/modules/migrate_plus/composer.json
@@ -17,5 +17,8 @@
     "source": "https://cgit.drupalcode.org/migrate_plus"
   },
   "minimum-stability": "dev",
-  "require": {}
+  "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.data_types.schema.yml b/web/modules/migrate_plus/config/schema/migrate_plus.data_types.schema.yml
index 27c8006f4a..92e8288ec1 100644
--- a/web/modules/migrate_plus/config/schema/migrate_plus.data_types.schema.yml
+++ b/web/modules/migrate_plus/config/schema/migrate_plus.data_types.schema.yml
@@ -6,7 +6,6 @@ migrate_plugin:
     plugin:
       type: string
       label: 'Plugin'
-
 migrate_destination:
   type: migrate_plugin
   label: 'Destination'
@@ -25,6 +24,35 @@ migrate_source:
     constants:
       type: ignore
       label: 'Constants'
+    ids:
+      type: ignore
+      label: 'Source IDs schema definition for migrate mapping table'
+    urls:
+      type: sequence
+      label: 'URLs from which to fetch'
+      sequence:
+        type: string
+    data_fetcher_plugin:
+      type: string
+      label: 'Fetcher plugin'
+    data_parser_plugin:
+      type: string
+      label: 'Parser plugin'
+    fields:
+      type: ignore
+      label: Mapping of field names to selectors
+    function:
+      type: string
+      label: 'Function to call on the service'
+    parameters:
+      type: ignore
+      label: 'Parameters to pass to function on the service'
+    response_type:
+      type: string
+      label: 'Type of response; XML string, object or array'
+    item_selector:
+      type: string
+      label: 'XPath selector'
 
 migrate_process:
   type: migrate_plugin
diff --git a/web/modules/migrate_plus/config/schema/migrate_plus.process.schema.yml b/web/modules/migrate_plus/config/schema/migrate_plus.process.schema.yml
index b1f123bc33..7b90a3d4ed 100644
--- a/web/modules/migrate_plus/config/schema/migrate_plus.process.schema.yml
+++ b/web/modules/migrate_plus/config/schema/migrate_plus.process.schema.yml
@@ -71,9 +71,9 @@ migrate_plus.process.get:
       type: string
       label: 'Source key'
 
-migrate_plus.process.iterator:
+migrate_plus.process.sub_process:
   type: migrate_process
-  label: 'Iterator process'
+  label: 'Sub process'
   mapping:
     process:
       type: ignore
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 304e4cc3b3..99aef55238 100644
--- a/web/modules/migrate_plus/config/schema/migrate_plus.schema.yml
+++ b/web/modules/migrate_plus/config/schema/migrate_plus.schema.yml
@@ -7,6 +7,15 @@ migrate_plus.migration.*:
     id:
       type: string
       label: 'ID'
+    class:
+      type: string
+      label: 'Class'
+    field_plugin_method:
+      type: string
+      label: 'Field Plugin Method'
+    cck_plugin_method:
+      type: string
+      label: 'BC layer for Field Plugin Method'
     migration_tags:
       type: sequence
       label: 'Migration Tags'
diff --git a/web/modules/migrate_plus/migrate_example/README.txt b/web/modules/migrate_plus/migrate_example/README.txt
index 125e3b0f63..431e22e9ba 100644
--- a/web/modules/migrate_plus/migrate_example/README.txt
+++ b/web/modules/migrate_plus/migrate_example/README.txt
@@ -22,12 +22,30 @@ STRUCTURE
 ---------
 There are two primary components to this example:
 
-1. Migration configuration, in the config/install directory. These YAML files
-   describe the migration process and provide the mappings from the source data
-   to Drupal's destination entities. The YAML file names are prefixed with
-   'migrate_plus.migration.' (because, reading from right to left, they define
-   "migration" configuration entities, and the configuration entity type is
-   defined by the "migrate_plus" module).
+1. Migration configuration, in the migrations and config/install directories.
+   These YAML files describe the migration process and provide the mappings from
+   the source data to Drupal's destination entities. The difference between the
+   two possible directories:
+
+   a. Files in the migrations directory provide configuration directly for the
+   migration plugins. The filenames are of the form <migration ID>.yml. This
+   approach is recommended when your migration configuration is fully hardcoded
+   and does not need to be overridden (e.g., you don't need to change the URL to
+   a source web service through an admin UI). While developing migrations,
+   changes to these files require at most a 'drush cr' to load your changes.
+
+   b. Files in the config/install directory provide migration configuration as
+   configuration entities, and have names of the form
+   migrate_plus.migration.<migration ID>.yml ("migration" because they define
+   entities of the "migration" type, and "migrate_plus" because that is the
+   module which implements the "migration" type). Migrations defined in this way
+   may have their configuration modified (in particular, through a web UI) by
+   loading the configuration entity, modifying its configuration, and saving the
+   entity. When developing, to get edits to the .yml files in config/install to
+   take effect in active configuration, use the config_devel module.
+
+   Configuration in either type of file is identical - the only differences are
+   the directories and filenames.
 
 2. Source plugins, in src/Plugin/migrate/source. These are referenced from the
    configuration files, and provide the source data to the migration processing
@@ -47,7 +65,7 @@ read the files in the following order:
 5. BeerUser.php
 6. migrate_plus.migration.beer_node.yml
 7. BeerNode.php
-8. migrate_plus.migration.beer_comment.yml
+8. beer_comment.yml
 9. BeerComment.php
 
 RUNNING THE MIGRATIONS
diff --git a/web/modules/migrate_plus/migrate_example/config/install/migrate_plus.migration.beer_node.yml b/web/modules/migrate_plus/migrate_example/config/install/migrate_plus.migration.beer_node.yml
index e2a9aefcb3..7494d1f408 100644
--- a/web/modules/migrate_plus/migrate_example/config/install/migrate_plus.migration.beer_node.yml
+++ b/web/modules/migrate_plus/migrate_example/config/install/migrate_plus.migration.beer_node.yml
@@ -2,6 +2,8 @@
 id: beer_node
 label: Beers of the world
 migration_group: beer
+migration_tags:
+  - example
 source:
   plugin: beer_node
 destination:
@@ -14,7 +16,7 @@ process:
   title: name
   nid: bid
   uid:
-    plugin: migration
+    plugin: migration_lookup
     migration: beer_user
     source: aid
   sticky:
@@ -22,7 +24,7 @@ process:
     default_value: 0
   field_migrate_example_country: countries
   field_migrate_example_beer_style:
-    plugin: migration
+    plugin: migration_lookup
     migration: beer_term
     source: terms
   # Some Drupal fields may have multiple components we may want to set
diff --git a/web/modules/migrate_plus/migrate_example/config/install/migrate_plus.migration.beer_term.yml b/web/modules/migrate_plus/migrate_example/config/install/migrate_plus.migration.beer_term.yml
index a7ca1858cb..c091b63d79 100644
--- a/web/modules/migrate_plus/migrate_example/config/install/migrate_plus.migration.beer_term.yml
+++ b/web/modules/migrate_plus/migrate_example/config/install/migrate_plus.migration.beer_term.yml
@@ -12,6 +12,10 @@ label: Migrate style categories from the source database to taxonomy terms
 # configuration to be merged with our own configuration here).
 migration_group: beer
 
+# The category or tag for the migration.
+migration_tags:
+  - example
+
 # Every migration must have a source plugin, which controls the delivery of our
 # source data. In this case, our source plugin has the name "beer_term", which
 # Drupal resolves to the PHP class defined in
@@ -60,7 +64,7 @@ process:
   # IDs in map tables, and the migration plugin is the means of performing a
   # lookup in those map tables during processing.
   parent:
-    plugin: migration
+    plugin: migration_lookup
     # Here we reference the migration whose map table we're performing a lookup
     # against. You'll note that in this case we're actually referencing this
     # migration itself, since category parents are imported by the same
diff --git a/web/modules/migrate_plus/migrate_example/config/install/migrate_plus.migration.beer_user.yml b/web/modules/migrate_plus/migrate_example/config/install/migrate_plus.migration.beer_user.yml
index 66b08682bd..d31b7253e7 100644
--- a/web/modules/migrate_plus/migrate_example/config/install/migrate_plus.migration.beer_user.yml
+++ b/web/modules/migrate_plus/migrate_example/config/install/migrate_plus.migration.beer_user.yml
@@ -4,6 +4,8 @@
 id: beer_user
 label: Beer Drinkers of the world
 migration_group: beer
+migration_tags:
+  - example
 source:
   plugin: beer_user
 destination:
@@ -90,7 +92,7 @@ process:
   # when that migration runs it knows that each incoming beer should overwrite
   # its stub instead of creating a new node.
   field_migrate_example_favbeers:
-    plugin: migration
+    plugin: migration_lookup
     source: beers
     migration: beer_node
 
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 cf80ea9c69..5ff995788d 100644
--- a/web/modules/migrate_plus/migrate_example/migrate_example.info.yml
+++ b/web/modules/migrate_plus/migrate_example/migrate_example.info.yml
@@ -4,12 +4,14 @@ description: 'Examples of how Drupal 8 migration compares to previous versions.'
 package: Examples
 # core: 8.x
 dependencies:
-  - migrate
-  - migrate_example_setup
-  - migrate_plus
+  - drupal:migrate
+  - migrate_plus:migrate_example_setup
+  - migrate_plus:migrate_plus
+  - drupal:menu_ui
+  - drupal:path
 
-# Information added by Drupal.org packaging script on 2016-08-05
-version: '8.x-2.0-beta2'
+# Information added by Drupal.org packaging script on 2018-09-06
+version: '8.x-4.0'
 core: '8.x'
 project: 'migrate_plus'
-datestamp: 1470428640
+datestamp: 1536264189
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 18ad64c0d1..306af0e3fe 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
@@ -11,7 +11,6 @@ dependencies:
   module:
     - comment
     - image
-    - path
     - text
 id: node.migrate_example_beer.default
 targetEntityType: node
@@ -58,11 +57,6 @@ content:
       preview_image_style: thumbnail
     third_party_settings: {  }
     type: image_image
-  path:
-    type: path
-    weight: 5
-    settings: {  }
-    third_party_settings: {  }
   promote:
     type: boolean_checkbox
     settings:
diff --git a/web/modules/migrate_plus/migrate_example/migrate_example_setup/config/install/node.type.migrate_example_beer.yml b/web/modules/migrate_plus/migrate_example/migrate_example_setup/config/install/node.type.migrate_example_beer.yml
index afa605210e..24c2e1da59 100644
--- a/web/modules/migrate_plus/migrate_example/migrate_example_setup/config/install/node.type.migrate_example_beer.yml
+++ b/web/modules/migrate_plus/migrate_example/migrate_example_setup/config/install/node.type.migrate_example_beer.yml
@@ -1,13 +1,5 @@
 langcode: en
 status: true
-dependencies:
-  module:
-    - menu_ui
-third_party_settings:
-  menu_ui:
-    available_menus:
-      - main
-    parent: 'main:'
 name: Beer
 type: migrate_example_beer
 description: 'Beer is what we drink.'
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 6b305596ca..b9dd2672cd 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
@@ -5,14 +5,14 @@ package: Migration
 # core: 8.x
 hidden: 1
 dependencies:
-  - comment
-  - image
-  - text
-  - options
-  - taxonomy
+  - drupal:comment
+  - drupal:image
+  - drupal:text
+  - drupal:options
+  - drupal:taxonomy
 
-# Information added by Drupal.org packaging script on 2016-08-05
-version: '8.x-2.0-beta2'
+# Information added by Drupal.org packaging script on 2018-09-06
+version: '8.x-4.0'
 core: '8.x'
 project: 'migrate_plus'
-datestamp: 1470428640
+datestamp: 1536264189
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 8e450baf08..342bc2dcfc 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
@@ -2,11 +2,16 @@
 
 /**
  * @file
+ * Install file for migrate example module.
+ *
  * Set up source data and destination configuration for the migration example
  * module. We do this in a separate module so migrate_example itself is a pure
  * migration module.
  */
 
+/**
+ * Implements hook_schema().
+ */
 function migrate_example_setup_schema() {
   $schema['migrate_example_beer_account'] = migrate_example_beer_schema_account();
   $schema['migrate_example_beer_node'] = migrate_example_beer_schema_node();
@@ -17,6 +22,9 @@ function migrate_example_setup_schema() {
   return $schema;
 }
 
+/**
+ * Implements hook_install().
+ */
 function migrate_example_setup_install() {
   // Populate our tables.
   migrate_example_beer_data_account();
@@ -26,318 +34,467 @@ function migrate_example_setup_install() {
   migrate_example_beer_data_topic_node();
 }
 
+/**
+ * The hook_schema definition for node.
+ *
+ * @return array
+ *   The schema definition.
+ */
 function migrate_example_beer_schema_node() {
-  return array(
+  return [
     'description' => 'Beers of the world.',
-    'fields' => array(
-      'bid'  => array(
+    'fields' => [
+      'bid'  => [
         'type' => 'serial',
         'not null' => TRUE,
         'description' => 'Beer ID.',
-      ),
-      'name'  => array(
+      ],
+      'name'  => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => TRUE,
-      ),
-      'body' => array(
+      ],
+      'body' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Full description of the beer.',
-      ),
-      'excerpt' => array(
+      ],
+      'excerpt' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Abstract for this beer.',
-      ),
-      'countries' => array(
+      ],
+      'countries' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Countries of origin. Multiple values, delimited by pipe',
-      ),
-      'aid' => array(
+      ],
+      'aid' => [
         'type' => 'int',
         'not null' => FALSE,
         'description' => 'Account Id of the author.',
-      ),
-      'image' => array(
+      ],
+      'image' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Image path',
-      ),
-      'image_alt' => array(
+      ],
+      'image_alt' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Image ALT',
-      ),
-      'image_title' => array(
+      ],
+      'image_title' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Image title',
-      ),
-      'image_description' => array(
+      ],
+      'image_description' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Image description',
-      ),
-    ),
-    'primary key' => array('bid'),
-  );
+      ],
+    ],
+    'primary key' => ['bid'],
+  ];
 }
 
+/**
+ * The hook_schema definition for topic.
+ *
+ * @return array
+ *   The schema definition.
+ */
 function migrate_example_beer_schema_topic() {
-  return array(
+  return [
     'description' => 'Categories',
-    'fields' => array(
-      'style'  => array(
+    'fields' => [
+      'style'  => [
         'type' => 'varchar_ascii',
         'length' => 255,
         'not null' => TRUE,
-      ),
-      'details' => array(
+      ],
+      'details' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
-      ),
-      'style_parent' => array(
+      ],
+      'style_parent' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Parent topic, if any',
-      ),
-      'region' => array(
+      ],
+      'region' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Region first associated with this style',
-      ),
-      'hoppiness' => array(
+      ],
+      'hoppiness' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Relative hoppiness of the beer',
-      ),
-    ),
-    'primary key' => array('style'),
-  );
+      ],
+    ],
+    'primary key' => ['style'],
+  ];
 }
 
+/**
+ * The hook_schema definition for topic node.
+ *
+ * @return array
+ *   The schema definition.
+ */
 function migrate_example_beer_schema_topic_node() {
-  return array(
+  return [
     'description' => 'Beers topic pairs.',
-    'fields' => array(
-      'bid'  => array(
+    'fields' => [
+      'bid'  => [
         'type' => 'int',
         'not null' => TRUE,
         'description' => 'Beer ID.',
-      ),
-      'style'  => array(
+      ],
+      'style'  => [
         'type' => 'varchar_ascii',
         'length' => 255,
         'not null' => TRUE,
         'description' => 'Topic name',
-      ),
-    ),
-    'primary key' => array('style', 'bid'),
-  );
+      ],
+    ],
+    'primary key' => ['style', 'bid'],
+  ];
 }
 
+/**
+ * The hook_schema definition for comment.
+ *
+ * @return array
+ *   The schema definition.
+ */
 function migrate_example_beer_schema_comment() {
-  return array(
+  return [
     'description' => 'Beers comments.',
-    'fields' => array(
-      'cid'  => array(
+    'fields' => [
+      'cid'  => [
         'type' => 'serial',
         'not null' => TRUE,
         'description' => 'Comment ID.',
-      ),
-      'bid'  => array(
+      ],
+      'bid'  => [
         'type' => 'int',
         'not null' => TRUE,
         'description' => 'Beer ID that is being commented upon',
-      ),
-      'cid_parent' => array(
+      ],
+      'cid_parent' => [
         'type' => 'int',
         'not null' => FALSE,
         'description' => 'Parent comment ID in case of comment replies.',
-      ),
-      'subject' => array(
+      ],
+      'subject' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Comment subject',
-      ),
-      'body' => array(
+      ],
+      'body' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Comment body',
-      ),
-      'name' => array(
+      ],
+      'name' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Comment name (if anon)',
-      ),
-      'mail' => array(
+      ],
+      'mail' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Comment email (if anon)',
-      ),
-      'aid' => array(
+      ],
+      'aid' => [
         'type' => 'int',
         'not null' => FALSE,
         'description' => 'Account ID (if any).',
-      ),
-    ),
-    'primary key' => array('cid'),
-  );
+      ],
+    ],
+    'primary key' => ['cid'],
+  ];
 }
 
+/**
+ * The hook_schema definition for account.
+ *
+ * @return array
+ *   The schema definition.
+ */
 function migrate_example_beer_schema_account() {
-  return array(
+  return [
     'description' => 'Beers accounts.',
-    'fields' => array(
-      'aid'  => array(
+    'fields' => [
+      'aid'  => [
         'type' => 'serial',
-        //'not null' => TRUE,
+        'not null' => TRUE,
         'description' => 'Account ID',
-      ),
-      'status'  => array(
+      ],
+      'status'  => [
         'type' => 'int',
         'not null' => TRUE,
         'description' => 'Blocked_Allowed',
-      ),
-      'registered' => array(
+      ],
+      'registered' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => TRUE,
         'description' => 'Registration date',
-      ),
-      'username' => array(
+      ],
+      'username' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Account name (for login)',
-      ),
-      'nickname' => array(
+      ],
+      'nickname' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Account name (for display)',
-      ),
-      'password' => array(
+      ],
+      'password' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Account password (raw)',
-      ),
-      'email' => array(
+      ],
+      'email' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Account email',
-      ),
-      'sex' => array(
+      ],
+      'sex' => [
         'type' => 'int',
         'not null' => FALSE,
         'description' => 'Gender (0 for male, 1 for female)',
-      ),
-      'beers' => array(
+      ],
+      'beers' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Favorite Beers',
-      ),
-    ),
-    'primary key' => array('aid'),
-  );
+      ],
+    ],
+    'primary key' => ['aid'],
+  ];
 }
 
+/**
+ * Populate node table.
+ */
 function migrate_example_beer_data_node() {
-  $fields = array('bid', 'name', 'body', 'excerpt', 'countries', 'aid', 'image',
-    'image_alt', 'image_title', 'image_description');
+  $fields = [
+    'bid',
+    'name',
+    'body',
+    'excerpt',
+    'countries',
+    'aid',
+    'image',
+    'image_alt',
+    'image_title',
+    'image_description',
+  ];
   $query = db_insert('migrate_example_beer_node')
-           ->fields($fields);
+    ->fields($fields);
   // Use high bid numbers to avoid overwriting an existing node id.
-  $data = array(
-    array(99999999, 'Heineken', 'Blab Blah Blah Green', 'Green', 'Netherlands|Belgium', 0, 'heineken.jpg', 'Heinekin alt', 'Heinekin title', 'Heinekin description'), // comes with migrate_example project.
-    array(99999998, 'Miller Lite', 'We love Miller Brewing', 'Tasteless', 'USA|Canada', 1, NULL, NULL, NULL, NULL),
-    array(99999997, 'Boddington', 'English occasionally get something right', 'A treat', 'United Kingdom', 1, NULL, NULL, NULL, NULL),
-  );
+  $data = [
+    // Comes with migrate_example project.
+    [
+      99999999,
+      'Heineken',
+      'Blab Blah Blah Green',
+      'Green',
+      'Netherlands|Belgium',
+      0,
+      'heineken.jpg',
+      'Heinekin alt',
+      'Heinekin title',
+      'Heinekin description',
+    ],
+    [
+      99999998,
+      'Miller Lite',
+      'We love Miller Brewing',
+      'Tasteless',
+      'USA|Canada',
+      1,
+      NULL,
+      NULL,
+      NULL,
+      NULL,
+    ],
+    [
+      99999997,
+      'Boddington',
+      'English occasionally get something right',
+      'A treat',
+      'United Kingdom',
+      1,
+      NULL,
+      NULL,
+      NULL,
+      NULL,
+    ],
+  ];
   foreach ($data as $row) {
     $query->values(array_combine($fields, $row));
   }
   $query->execute();
 }
 
-// Note that alice has duplicate username. Exercises dedupe_entity plugin.
-// @TODO duplicate email also.
+/**
+ * Populate account table.
+ *
+ * Note that alice has duplicate username. Exercises dedupe_entity plugin.
+ * TODO duplicate email also.
+ */
 function migrate_example_beer_data_account() {
-  $fields = array('status', 'registered', 'username', 'nickname', 'password', 'email', 'sex', 'beers');
+  $fields = [
+    'status',
+    'registered',
+    'username',
+    'nickname',
+    'password',
+    'email',
+    'sex',
+    'beers',
+  ];
   $query = db_insert('migrate_example_beer_account')
     ->fields($fields);
-  $data = array(
-    array(1, '2010-03-30 10:31:05', 'alice', 'alice in beerland', 'alicepass', 'alice@example.com', '1', '99999999|99999998|99999997'),
-    array(1, '2010-04-04 10:31:05', 'alice', 'alice in aleland', 'alicepass', 'alice2@example.com', '1', '99999999|99999998|99999997'),
-    array(0, '2007-03-15 10:31:05', 'bob', 'rebob', 'bobpass', 'bob@example.com', '0', '99999999|99999997'),
-    array(1, '2004-02-29 10:31:05', 'charlie', 'charlie chocolate', 'mykids', 'charlie@example.com', '0', '99999999|99999998'),
-  );
+  $data = [
+    [
+      1,
+      '2010-03-30 10:31:05',
+      'alice',
+      'alice in beerland',
+      'alicepass',
+      'alice@example.com',
+      '1',
+      '99999999|99999998|99999997',
+    ],
+    [
+      1,
+      '2010-04-04 10:31:05',
+      'alice',
+      'alice in aleland',
+      'alicepass',
+      'alice2@example.com',
+      '1',
+      '99999999|99999998|99999997',
+    ],
+    [
+      0,
+      '2007-03-15 10:31:05',
+      'bob',
+      'rebob',
+      'bobpass',
+      'bob@example.com',
+      '0',
+      '99999999|99999997',
+    ],
+    [
+      1,
+      '2004-02-29 10:31:05',
+      'charlie',
+      'charlie chocolate',
+      'mykids',
+      'charlie@example.com',
+      '0',
+      '99999999|99999998',
+    ],
+  ];
   foreach ($data as $row) {
     $query->values(array_combine($fields, $row));
   }
   $query->execute();
 }
 
+/**
+ * Populate comment table.
+ */
 function migrate_example_beer_data_comment() {
-  $fields = array('bid', 'cid_parent', 'subject', 'body', 'name', 'mail', 'aid');
+  $fields = ['bid', 'cid_parent', 'subject', 'body', 'name', 'mail', 'aid'];
   $query = db_insert('migrate_example_beer_comment')
     ->fields($fields);
-  $data = array(
-    array(99999998, NULL, 'im first', 'full body', 'alice', 'alice@example.com', 0),
-    array(99999998, NULL, 'im second', 'aromatic', 'alice', 'alice@example.com', 0),
-    array(99999999, NULL, 'im parent', 'malty', 'alice', 'alice@example.com', 0),
-    array(99999999, 1, 'im child', 'cold body', 'bob', NULL, 1),
-    array(99999999, 4, 'im grandchild', 'bitter body', 'charlie@example.com', NULL, 1),
-  );
+  $data = [
+    [99999998, NULL, 'im first', 'full body', 'alice', 'alice@example.com', 0],
+    [99999998, NULL, 'im second', 'aromatic', 'alice', 'alice@example.com', 0],
+    [99999999, NULL, 'im parent', 'malty', 'alice', 'alice@example.com', 0],
+    [99999999, 1, 'im child', 'cold body', 'bob', NULL, 1],
+    [
+      99999999,
+      4,
+      'im grandchild',
+      'bitter body',
+      'charlie@example.com',
+      NULL,
+      1,
+    ],
+  ];
   foreach ($data as $row) {
     $query->values(array_combine($fields, $row));
   }
   $query->execute();
 }
 
+/**
+ * Populate topic table.
+ */
 function migrate_example_beer_data_topic() {
-  $fields = array('style', 'details', 'style_parent', 'region', 'hoppiness');
+  $fields = ['style', 'details', 'style_parent', 'region', 'hoppiness'];
   $query = db_insert('migrate_example_beer_topic')
     ->fields($fields);
-  $data = array(
-    array('ale', 'traditional', NULL, 'Medieval British Isles', 'Medium'),
-    array('red ale', 'colorful', 'ale', NULL, NULL),
-    array('pilsner', 'refreshing', NULL, 'Pilsen, Bohemia (now Czech Republic)', 'Low'),
-  );
+  $data = [
+    ['ale', 'traditional', NULL, 'Medieval British Isles', 'Medium'],
+    ['red ale', 'colorful', 'ale', NULL, NULL],
+    [
+      'pilsner',
+      'refreshing',
+      NULL,
+      'Pilsen, Bohemia (now Czech Republic)',
+      'Low',
+    ],
+  ];
   foreach ($data as $row) {
     $query->values(array_combine($fields, $row));
   }
   $query->execute();
 }
 
+/**
+ * Populate topic node table.
+ */
 function migrate_example_beer_data_topic_node() {
-  $fields = array('bid', 'style');
+  $fields = ['bid', 'style'];
   $query = db_insert('migrate_example_beer_topic_node')
     ->fields($fields);
-  $data = array(
-    array(99999999, 'pilsner'),
-    array(99999999, 'red ale'),
-    array(99999998, 'red ale'),
-  );
+  $data = [
+    [99999999, 'pilsner'],
+    [99999999, 'red ale'],
+    [99999998, 'red ale'],
+  ];
   foreach ($data as $row) {
     $query->values(array_combine($fields, $row));
   }
diff --git a/web/modules/migrate_plus/migrate_example/config/install/migrate_plus.migration.beer_comment.yml b/web/modules/migrate_plus/migrate_example/migrations/beer_comment.yml
similarity index 90%
rename from web/modules/migrate_plus/migrate_example/config/install/migrate_plus.migration.beer_comment.yml
rename to web/modules/migrate_plus/migrate_example/migrations/beer_comment.yml
index e631e9ac86..3540ab69d0 100644
--- a/web/modules/migrate_plus/migrate_example/config/install/migrate_plus.migration.beer_comment.yml
+++ b/web/modules/migrate_plus/migrate_example/migrations/beer_comment.yml
@@ -8,11 +8,11 @@ destination:
   plugin: entity:comment
 process:
   pid:
-    plugin: migration
+    plugin: migration_lookup
     migration: beer_comment
     source: cid_parent
   entity_id:
-    plugin: migration
+    plugin: migration_lookup
     migration: beer_node
     source: bid
   entity_type:
@@ -26,7 +26,7 @@ process:
     default_value: node_comments
   subject: subject
   uid:
-    plugin: migration
+    plugin: migration_lookup
     migration: beer_user
     source: aid
   name: name
diff --git a/web/modules/migrate_plus/migrate_example/src/Plugin/migrate/source/BeerComment.php b/web/modules/migrate_plus/migrate_example/src/Plugin/migrate/source/BeerComment.php
index 217fad262f..b5d446a3ff 100644
--- a/web/modules/migrate_plus/migrate_example/src/Plugin/migrate/source/BeerComment.php
+++ b/web/modules/migrate_plus/migrate_example/src/Plugin/migrate/source/BeerComment.php
@@ -17,10 +17,19 @@ class BeerComment extends SqlBase {
    * {@inheritdoc}
    */
   public function query() {
+    $fields = [
+      'cid',
+      'cid_parent',
+      'name',
+      'mail',
+      'aid',
+      'body',
+      'bid',
+      'subject',
+    ];
     $query = $this->select('migrate_example_beer_comment', 'mec')
-                 ->fields('mec', ['cid', 'cid_parent', 'name', 'mail', 'aid',
-                   'body', 'bid', 'subject'])
-                 ->orderBy('cid_parent', 'ASC');
+      ->fields('mec', $fields)
+      ->orderBy('cid_parent', 'ASC');
     return $query;
   }
 
diff --git a/web/modules/migrate_plus/migrate_example/src/Plugin/migrate/source/BeerNode.php b/web/modules/migrate_plus/migrate_example/src/Plugin/migrate/source/BeerNode.php
index 8d9f0bbfdc..40ac4df29d 100644
--- a/web/modules/migrate_plus/migrate_example/src/Plugin/migrate/source/BeerNode.php
+++ b/web/modules/migrate_plus/migrate_example/src/Plugin/migrate/source/BeerNode.php
@@ -18,21 +18,29 @@ class BeerNode extends SqlBase {
    * {@inheritdoc}
    */
   public function query() {
-    /**
-     * An important point to note is that your query *must* return a single row
-     * for each item to be imported. Here we might be tempted to add a join to
-     * migrate_example_beer_topic_node in our query, to pull in the
-     * relationships to our categories. Doing this would cause the query to
-     * return multiple rows for a given node, once per related value, thus
-     * processing the same node multiple times, each time with only one of the
-     * multiple values that should be imported. To avoid that, we simply query
-     * the base node data here, and pull in the relationships in prepareRow()
-     * below.
-     */
+    // An important point to note is that your query *must* return a single row
+    // for each item to be imported. Here we might be tempted to add a join to
+    // migrate_example_beer_topic_node in our query, to pull in the
+    // relationships to our categories. Doing this would cause the query to
+    // return multiple rows for a given node, once per related value, thus
+    // processing the same node multiple times, each time with only one of the
+    // multiple values that should be imported. To avoid that, we simply query
+    // the base node data here, and pull in the relationships in prepareRow()
+    // below.
+    $fields = [
+      'bid',
+      'name',
+      'body',
+      'excerpt',
+      'aid',
+      'countries',
+      'image',
+      'image_alt',
+      'image_title',
+      'image_description',
+    ];
     $query = $this->select('migrate_example_beer_node', 'b')
-                 ->fields('b', ['bid', 'name', 'body', 'excerpt', 'aid',
-                   'countries', 'image', 'image_alt', 'image_title',
-                   'image_description']);
+      ->fields('b', $fields);
     return $query;
   }
 
@@ -76,13 +84,11 @@ public function getIds() {
    * {@inheritdoc}
    */
   public function prepareRow(Row $row) {
-    /**
-     * As explained above, we need to pull the style relationships into our
-     * source row here, as an array of 'style' values (the unique ID for
-     * the beer_term migration).
-     */
+    // As explained above, we need to pull the style relationships into our
+    // source row here, as an array of 'style' values (the unique ID for
+    // the beer_term migration).
     $terms = $this->select('migrate_example_beer_topic_node', 'bt')
-                 ->fields('bt', ['style'])
+      ->fields('bt', ['style'])
       ->condition('bid', $row->getSourceProperty('bid'))
       ->execute()
       ->fetchCol();
diff --git a/web/modules/migrate_plus/migrate_example/src/Plugin/migrate/source/BeerTerm.php b/web/modules/migrate_plus/migrate_example/src/Plugin/migrate/source/BeerTerm.php
index f8ea51efe9..5eb3e70f30 100644
--- a/web/modules/migrate_plus/migrate_example/src/Plugin/migrate/source/BeerTerm.php
+++ b/web/modules/migrate_plus/migrate_example/src/Plugin/migrate/source/BeerTerm.php
@@ -5,11 +5,12 @@
 use Drupal\migrate\Plugin\migrate\source\SqlBase;
 
 /**
- * This is an example of a simple SQL-based source plugin. Source plugins are
- * classes which deliver source data to the processing pipeline. For SQL
- * sources, the SqlBase class provides most of the functionality needed - for
- * a specific migration, you are required to implement the three simple public
- * methods you see below.
+ * This is an example of a simple SQL-based source plugin.
+ *
+ * Source plugins are classes which deliver source data to the processing
+ * pipeline. For SQL sources, the SqlBase class provides most of the
+ * functionality needed - for a specific migration, you are required to
+ * implement the three simple public methods you see below.
  *
  * This annotation tells Drupal that the name of the MigrateSource plugin
  * implemented by this class is "beer_term". This is the name that the migration
@@ -25,16 +26,15 @@ class BeerTerm extends SqlBase {
    * {@inheritdoc}
    */
   public function query() {
-    /**
-     * The most important part of a SQL source plugin is the SQL query to
-     * retrieve the data to be imported. Note that the query is not executed
-     * here - the migration process will control execution of the query. Also
-     * note that it is constructed from a $this->select() call - this ensures
-     * that the query is executed against the database configured for this
-     * source plugin.
-     */
+    // The most important part of a SQL source plugin is the SQL query to
+    // retrieve the data to be imported. Note that the query is not executed
+    // here - the migration process will control execution of the query. Also
+    // note that it is constructed from a $this->select() call - this ensures
+    // that the query is executed against the database configured for this
+    // source plugin.
+    $fields = ['style', 'details', 'style_parent', 'region', 'hoppiness'];
     return $this->select('migrate_example_beer_topic', 'met')
-      ->fields('met', ['style', 'details', 'style_parent', 'region', 'hoppiness'])
+      ->fields('met', $fields)
       // We sort this way to ensure parent terms are imported first.
       ->orderBy('style_parent', 'ASC');
   }
@@ -43,16 +43,14 @@ public function query() {
    * {@inheritdoc}
    */
   public function fields() {
-    /**
-     * This method simply documents the available source fields provided by
-     * the source plugin, for use by front-end tools. It returns an array keyed
-     * by field/column name, with the value being a translated string explaining
-     * to humans what the field represents. You should always
-     */
+    // This method simply documents the available source fields provided by the
+    // source plugin, for use by front-end tools. It returns an array keyed by
+    // field/column name, with the value being a translated string explaining
+    // to humans what the field represents.
     $fields = [
-      'style' => $this->t('Account ID'),
-      'details' => $this->t('Blocked/Allowed'),
-      'style_parent' => $this->t('Registered date'),
+      'style' => $this->t('Beer style'),
+      'details' => $this->t('Style details'),
+      'style_parent' => $this->t('Parent style'),
       // These values are not currently migrated - it's OK to skip fields you
       // don't need.
       'region' => $this->t('Region the style is associated with'),
@@ -66,14 +64,12 @@ public function fields() {
    * {@inheritdoc}
    */
   public function getIds() {
-    /**
-     * This method indicates what field(s) from the source row uniquely identify
-     * that source row, and what their types are. This is critical information
-     * for managing the migration. The keys of the returned array are the field
-     * names from the query which comprise the unique identifier. The values are
-     * arrays indicating the type of the field, used for creating compatible
-     * columns in the map tables that track processed items.
-     */
+    // This method indicates what field(s) from the source row uniquely identify
+    // that source row, and what their types are. This is critical information
+    // for managing the migration. The keys of the returned array are the field
+    // names from the query which comprise the unique identifier. The values are
+    // arrays indicating the type of the field, used for creating compatible
+    // columns in the map tables that track processed items.
     return [
       'style' => [
         'type' => 'string',
diff --git a/web/modules/migrate_plus/migrate_example/src/Plugin/migrate/source/BeerUser.php b/web/modules/migrate_plus/migrate_example/src/Plugin/migrate/source/BeerUser.php
index b0d963d3fd..398f87c696 100644
--- a/web/modules/migrate_plus/migrate_example/src/Plugin/migrate/source/BeerUser.php
+++ b/web/modules/migrate_plus/migrate_example/src/Plugin/migrate/source/BeerUser.php
@@ -18,9 +18,19 @@ class BeerUser extends SqlBase {
    * {@inheritdoc}
    */
   public function query() {
+    $fields = [
+      'aid',
+      'status',
+      'registered',
+      'username',
+      'nickname',
+      'password',
+      'email',
+      'sex',
+      'beers',
+    ];
     return $this->select('migrate_example_beer_account', 'mea')
-      ->fields('mea', ['aid', 'status', 'registered', 'username', 'nickname',
-                            'password', 'email', 'sex', 'beers']);
+      ->fields('mea', $fields);
   }
 
   /**
@@ -58,27 +68,22 @@ public function getIds() {
    * {@inheritdoc}
    */
   public function prepareRow(Row $row) {
-    /**
-     * prepareRow() is the most common place to perform custom run-time
-     * processing that isn't handled by an existing process plugin. It is called
-     * when the raw data has been pulled from the source, and provides the
-     * opportunity to modify or add to that data, creating the canonical set of
-     * source data that will be fed into the processing pipeline.
-     *
-     * In our particular case, the list of a user's favorite beers is a pipe-
-     * separated list of beer IDs. The processing pipeline deals with arrays
-     * representing multi-value fields naturally, so we want to explode that
-     * string to an array of individual beer IDs.
-     */
+    // A prepareRow() is the most common place to perform custom run-time
+    // processing that isn't handled by an existing process plugin. It is called
+    // when the raw data has been pulled from the source, and provides the
+    // opportunity to modify or add to that data, creating the canonical set of
+    // source data that will be fed into the processing pipeline.
+    // In our particular case, the list of a user's favorite beers is a pipe-
+    // separated list of beer IDs. The processing pipeline deals with arrays
+    // representing multi-value fields naturally, so we want to explode that
+    // string to an array of individual beer IDs.
     if ($value = $row->getSourceProperty('beers')) {
       $row->setSourceProperty('beers', explode('|', $value));
     }
-    /**
-     * Always call your parent! Essential processing is performed in the base
-     * class. Be mindful that prepareRow() returns a boolean status - if FALSE
-     * that indicates that the item being processed should be skipped. Unless
-     * we're deciding to skip an item ourselves, let the parent class decide.
-     */
+    // Always call your parent! Essential processing is performed in the base
+    // class. Be mindful that prepareRow() returns a boolean status - if FALSE
+    // that indicates that the item being processed should be skipped. Unless
+    // we're deciding to skip an item ourselves, let the parent class decide.
     return parent::prepareRow($row);
   }
 
diff --git a/web/modules/migrate_plus/migrate_example_advanced/config/install/migrate_plus.migration.weather_soap.yml b/web/modules/migrate_plus/migrate_example_advanced/config/install/migrate_plus.migration.weather_soap.yml
index da4252153e..328bcca84a 100644
--- a/web/modules/migrate_plus/migrate_example_advanced/config/install/migrate_plus.migration.weather_soap.yml
+++ b/web/modules/migrate_plus/migrate_example_advanced/config/install/migrate_plus.migration.weather_soap.yml
@@ -2,19 +2,33 @@
 id: weather_soap
 label: SOAP service providing weather.
 migration_group: wine
+migration_tags:
+  - advanced example
 source:
   # We use the SOAP parser source plugin.
   plugin: url
   data_fetcher_plugin: http # Ignored - SoapClient does the fetching.
   data_parser_plugin: soap
   # URL of a WSDL endpoint.
-  urls: http://www.webservicex.net/globalweather.asmx?WSDL
-  # The function to call on the service, and the parameters to pass.
+  urls:
+    - http://www.webservicex.net/globalweather.asmx?WSDL
+  # The function to call on the service, and the parameters to pass. See
+  # http://www.webservicex.net/New/Home/ServiceDetail/56 for the XML structure
+  # of this feed - how CountryName is passed within the GetCitiesByCountry
+  # XML element.
   function: GetCitiesByCountry
   parameters:
     CountryName: Spain
+  # Responses may be returned as an XML string, an object, or an array - specify
+  # the type of response here.
   response_type: xml
+  # Looking at the XML response at http://www.webservicex.net/globalweather.asmx/GetCitiesByCountry,
+  # we see that the data items we want are within <NewDataSet><Table>.
   item_selector: /NewDataSet/Table
+  # For each field, 'name' is the source property name to be used in the process
+  # steps below, 'label' is optional (to document the property), and selector
+  # is an xpath (-like, for array and object returns) string relative to the
+  # item_selector for retrieving that data value.
   fields:
     -
       name: Country
@@ -24,6 +38,9 @@ source:
       name: City
       label: City
       selector: City
+  # 'ids' tells us what source property ('City') holds the unique identifying
+  # value for each imported item, and what schema type to use to hold that
+  # value in the migration map an message tables.
   ids:
     City:
       type: string
@@ -34,3 +51,6 @@ process:
   name: City
 destination:
   plugin: entity:taxonomy_term
+migration_dependencies:
+  required: {}
+  optional: {}
diff --git a/web/modules/migrate_plus/migrate_example_advanced/config/install/migrate_plus.migration.wine_role_json.yml b/web/modules/migrate_plus/migrate_example_advanced/config/install/migrate_plus.migration.wine_role_json.yml
index 4824eab032..d7d3953af3 100644
--- a/web/modules/migrate_plus/migrate_example_advanced/config/install/migrate_plus.migration.wine_role_json.yml
+++ b/web/modules/migrate_plus/migrate_example_advanced/config/install/migrate_plus.migration.wine_role_json.yml
@@ -2,22 +2,33 @@
 id: wine_role_json
 label: JSON feed of roles (positions)
 migration_group: wine
+migration_tags:
+  - advanced example
 source:
   # We use the JSON source plugin.
   plugin: url
   data_fetcher_plugin: http
   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
   # 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_position?_format=json
-  item_selector: 1
+  urls:
+    - /migrate_example_advanced_position?_format=json
+  # An xpath-like selector corresponding to the items to be imported.
+  item_selector: position
   # 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.
+  # is relative to the elements retrieved by item_selector.
   fields:
     -
       name: machine_name
diff --git a/web/modules/migrate_plus/migrate_example_advanced/config/install/migrate_plus.migration.wine_role_xml.yml b/web/modules/migrate_plus/migrate_example_advanced/config/install/migrate_plus.migration.wine_role_xml.yml
index 6b7234c248..b5032a4f15 100644
--- a/web/modules/migrate_plus/migrate_example_advanced/config/install/migrate_plus.migration.wine_role_xml.yml
+++ b/web/modules/migrate_plus/migrate_example_advanced/config/install/migrate_plus.migration.wine_role_xml.yml
@@ -2,6 +2,8 @@
 id: wine_role_xml
 label: XML feed of roles (positions)
 migration_group: wine
+migration_tags:
+  - advanced example
 source:
   # We use the XML data parser plugin.
   plugin: url
@@ -10,7 +12,8 @@ source:
   # 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_position?_format=xml
+  urls:
+    - /migrate_example_advanced_position?_format=xml
   # 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
diff --git a/web/modules/migrate_plus/migrate_example_advanced/config/install/migrate_plus.migration.wine_terms.yml b/web/modules/migrate_plus/migrate_example_advanced/config/install/migrate_plus.migration.wine_terms.yml
index f11b82f844..56eb273867 100644
--- a/web/modules/migrate_plus/migrate_example_advanced/config/install/migrate_plus.migration.wine_terms.yml
+++ b/web/modules/migrate_plus/migrate_example_advanced/config/install/migrate_plus.migration.wine_terms.yml
@@ -1,6 +1,8 @@
 id: wine_terms
 label: Migrate all categories into Drupal taxonomy terms
 migration_group: wine
+migration_tags:
+  - advanced example
 source:
   plugin: wine_term
 destination:
@@ -26,7 +28,7 @@ process:
       region: migrate_example_wine_regions
       variety: migrate_example_wine_varieties
   parent:
-    plugin: migration
+    plugin: migration_lookup
     migration: wine_terms
     source: category_parent
   weight: ordering
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
index ae4d2b7471..ff88862b2f 100644
--- 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
@@ -3,13 +3,16 @@
 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
+  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
@@ -46,7 +49,7 @@ process:
   name: category_name
   description: category_details
   parent:
-    plugin: migration
+    plugin: migration_lookup
     migration: wine_terms
     source: category_parent
 destination:
diff --git a/web/modules/migrate_plus/migrate_example_advanced/config/install/migrate_plus.migration.wine_variety_multi_xml.yml b/web/modules/migrate_plus/migrate_example_advanced/config/install/migrate_plus.migration.wine_variety_multi_xml.yml
index e26579e71a..a501a7e752 100644
--- a/web/modules/migrate_plus/migrate_example_advanced/config/install/migrate_plus.migration.wine_variety_multi_xml.yml
+++ b/web/modules/migrate_plus/migrate_example_advanced/config/install/migrate_plus.migration.wine_variety_multi_xml.yml
@@ -2,6 +2,8 @@
 id: wine_variety_multi_xml
 label: XML feed of varieties
 migration_group: wine
+migration_tags:
+  - advanced example
 source:
   # We use the XML source plugin.
   plugin: url
@@ -54,7 +56,7 @@ process:
   name: category_name
   description: category_details
   parent:
-    plugin: migration
+    plugin: migration_lookup
     migration: wine_terms
     source: category_parent
   field_variety_attributes: category_attributes
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 22e24506b2..1d396e34bc 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
@@ -4,12 +4,12 @@ description: 'Specialized examples of Drupal 8 migration.'
 package: Examples
 # core: 8.x
 dependencies:
-  - migrate
-  - migrate_example_advanced_setup
-  - migrate_plus
+  - drupal:migrate
+  - migrate_plus:migrate_example_advanced_setup
+  - migrate_plus:migrate_plus
 
-# Information added by Drupal.org packaging script on 2016-08-05
-version: '8.x-2.0-beta2'
+# Information added by Drupal.org packaging script on 2018-09-06
+version: '8.x-4.0'
 core: '8.x'
 project: 'migrate_plus'
-datestamp: 1470428640
+datestamp: 1536264189
diff --git a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced.install b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced.install
index b2506e4a9a..2023bdee16 100644
--- a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced.install
+++ b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced.install
@@ -2,26 +2,30 @@
 
 /**
  * @file
- * Install, update and uninstall functions for the migrate_example_advanced module.
+ * Install, update and uninstall functions for migrate_example_advanced module.
  */
 
 use Drupal\migrate_plus\Entity\Migration;
 
 /**
  * Implements hook_install().
+ *
+ * We need the urls to be absolute for the XML source plugin to read them, but
+ * the static configuration files on disk can't know the server and port to
+ * use. So, in the .yml files we provide the REST resources relative to the
+ * site root and here rewrite them to fully-qualified paths.
  */
 function migrate_example_advanced_install() {
-  // We need the urls to be absolute for the XML source plugin to read them, but
-  // the static configuration files on disk can't know the server and port to
-  // use. So, in the .yml files we provide the REST resources relative to the
-  // site root and here rewrite them to fully-qualified paths.
-
   /** @var \Drupal\migrate_plus\Entity\MigrationInterface $wine_role_xml_migration */
   $wine_role_xml_migration = Migration::load('wine_role_xml');
   if ($wine_role_xml_migration) {
     $source = $wine_role_xml_migration->get('source');
     $request = \Drupal::request();
-    $source['urls'] = 'http://' . $request->getHttpHost() . $source['urls'];
+    $urls = [];
+    foreach ($source['urls'] as $url) {
+      $urls[] = 'http://' . $request->getHttpHost() . $url;
+    }
+    $source['urls'] = $urls;
     $wine_role_xml_migration->set('source', $source);
     $wine_role_xml_migration->save();
   }
@@ -30,7 +34,11 @@ function migrate_example_advanced_install() {
   if ($wine_role_json_migration) {
     $source = $wine_role_json_migration->get('source');
     $request = \Drupal::request();
-    $source['urls'] = 'http://' . $request->getHttpHost() . $source['urls'];
+    $urls = [];
+    foreach ($source['urls'] as $url) {
+      $urls[] = 'http://' . $request->getHttpHost() . $url;
+    }
+    $source['urls'] = $urls;
     $wine_role_json_migration->set('source', $source);
     $wine_role_json_migration->save();
   }
diff --git a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/rest.resource.migrate_example_advanced_position.yml b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/rest.resource.migrate_example_advanced_position.yml
new file mode 100644
index 0000000000..6a79fc90f4
--- /dev/null
+++ b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/rest.resource.migrate_example_advanced_position.yml
@@ -0,0 +1,10 @@
+id: migrate_example_advanced_position
+plugin_id: 'migrate_example_advanced_position'
+granularity: method
+configuration:
+  GET:
+    supported_formats:
+      - json
+      - xml
+    supported_auth:
+      - cookie
diff --git a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/rest.resource.migrate_example_advanced_variety_items.yml b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/rest.resource.migrate_example_advanced_variety_items.yml
new file mode 100644
index 0000000000..bf2518a5f5
--- /dev/null
+++ b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/rest.resource.migrate_example_advanced_variety_items.yml
@@ -0,0 +1,10 @@
+id: migrate_example_advanced_variety_items
+plugin_id: 'migrate_example_advanced_variety_items'
+granularity: method
+configuration:
+  GET:
+    supported_formats:
+      - json
+      - xml
+    supported_auth:
+      - cookie
diff --git a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/rest.resource.migrate_example_advanced_variety_list.yml b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/rest.resource.migrate_example_advanced_variety_list.yml
new file mode 100644
index 0000000000..f19dc60fe7
--- /dev/null
+++ b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/rest.resource.migrate_example_advanced_variety_list.yml
@@ -0,0 +1,10 @@
+id: migrate_example_advanced_variety_list
+plugin_id: 'migrate_example_advanced_variety_list'
+granularity: method
+configuration:
+  GET:
+    supported_formats:
+      - json
+      - xml
+    supported_auth:
+      - cookie
diff --git a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/rest.resource.migrate_example_advanced_variety_multiple.yml b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/rest.resource.migrate_example_advanced_variety_multiple.yml
new file mode 100644
index 0000000000..c8960369ff
--- /dev/null
+++ b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/config/install/rest.resource.migrate_example_advanced_variety_multiple.yml
@@ -0,0 +1,10 @@
+id: migrate_example_advanced_variety_multiple
+plugin_id: 'migrate_example_advanced_variety_multiple'
+granularity: method
+configuration:
+  GET:
+    supported_formats:
+      - json
+      - xml
+    supported_auth:
+      - cookie
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 c5c48696a0..8705865683 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
@@ -5,14 +5,14 @@ package: Migration
 # core: 8.x
 hidden: 1
 dependencies:
-  - comment
-  - image
-  - text
-  - taxonomy
-  - rest
+  - drupal:comment
+  - drupal:image
+  - drupal:text
+  - drupal:taxonomy
+  - drupal:rest
 
-# Information added by Drupal.org packaging script on 2016-08-05
-version: '8.x-2.0-beta2'
+# Information added by Drupal.org packaging script on 2018-09-06
+version: '8.x-4.0'
 core: '8.x'
 project: 'migrate_plus'
-datestamp: 1470428640
+datestamp: 1536264189
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 8c92cba356..be9031a6be 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
@@ -1,8 +1,9 @@
 <?php
-use Drupal\user\RoleInterface;
 
 /**
  * @file
+ * Install setup for the migration example module.
+ *
  * Set up source data and destination configuration for the migration example
  * module. We do this in a separate module so migrate_example_advanced itself is
  * a pure migration module.
@@ -36,39 +37,6 @@ function migrate_example_advanced_setup_schema() {
  * Implements hook_install().
  */
 function migrate_example_advanced_setup_install() {
-  // Enable and configure REST resources providing source data.
-  $config = \Drupal::configFactory()->getEditable('rest.settings');
-  $resources = $config->get('resources');
-  $resources['migrate_example_advanced_position']['GET'] = [
-    'supported_formats' => ['json', 'xml'],
-    'supported_auth' => ['cookie'],
-  ];
-  $resources['migrate_example_advanced_variety_multiple']['GET'] = [
-    'supported_formats' => ['json', 'xml'],
-    'supported_auth' => ['cookie'],
-  ];
-  $resources['migrate_example_advanced_variety_list']['GET'] = [
-    'supported_formats' => ['json', 'xml'],
-    'supported_auth' => ['cookie'],
-  ];
-  $resources['migrate_example_advanced_variety_items']['GET'] = [
-    'supported_formats' => ['json', 'xml'],
-    'supported_auth' => ['cookie'],
-  ];
-  $config->set('resources', $resources);
-  $config->save();
-
-  // Don't require authentication for the services, so the migrations can easily
-  // be run from drush.
-  user_role_grant_permissions(RoleInterface::ANONYMOUS_ID,
-    ['restful get migrate_example_advanced_position']);
-  user_role_grant_permissions(RoleInterface::ANONYMOUS_ID,
-    ['restful get migrate_example_advanced_variety_multiple']);
-  user_role_grant_permissions(RoleInterface::ANONYMOUS_ID,
-    ['restful get migrate_example_advanced_variety_list']);
-  user_role_grant_permissions(RoleInterface::ANONYMOUS_ID,
-    ['restful get migrate_example_advanced_variety_items']);
-
   // Populate our tables.
   migrate_example_advanced_data_account();
   migrate_example_advanced_data_account_updates();
@@ -87,6 +55,12 @@ function migrate_example_advanced_setup_install() {
   migrate_example_advanced_data_table_source();
 }
 
+/**
+ * The hook_schema definition for wine.
+ *
+ * @return array
+ *   The schema definition.
+ */
 function migrate_example_advanced_schema_wine() {
   return [
     'description' => 'Wines of the world',
@@ -155,8 +129,14 @@ function migrate_example_advanced_schema_wine() {
   ];
 }
 
+/**
+ * The hook_schema definition for updates.
+ *
+ * @return array
+ *   The schema definition.
+ */
 function migrate_example_advanced_schema_updates() {
-  return array(
+  return [
     'description' => 'Updated wine ratings',
     'fields' => [
       'wineid'  => [
@@ -173,754 +153,1068 @@ function migrate_example_advanced_schema_updates() {
       ],
     ],
     'primary key' => ['wineid'],
-  );
+  ];
 }
 
+/**
+ * The hook_schema definition for producer.
+ *
+ * @return array
+ *   The schema definition.
+ */
 function migrate_example_advanced_schema_producer() {
-  return array(
+  return [
     'description' => 'Wine producers of the world',
-    'fields' => array(
-      'producerid'  => array(
+    'fields' => [
+      'producerid'  => [
         'type' => 'int',
         'unsigned' => TRUE,
         'not null' => TRUE,
         'description' => 'Producer ID',
-      ),
-      'name'  => array(
+      ],
+      'name'  => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => TRUE,
-      ),
-      'body' => array(
+      ],
+      'body' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Full description of the producer.',
-      ),
-      'excerpt' => array(
+      ],
+      'excerpt' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Abstract for this producer.',
-      ),
-      'accountid' => array(
+      ],
+      'accountid' => [
         'type' => 'int',
         'unsigned' => TRUE,
         'not null' => FALSE,
         'description' => 'Account ID of the author.',
-      ),
-    ),
-    'primary key' => array('producerid'),
-  );
+      ],
+    ],
+    'primary key' => ['producerid'],
+  ];
 }
 
+/**
+ * The hook_schema definition for categories.
+ *
+ * @return array
+ *   The schema definition.
+ */
 function migrate_example_advanced_schema_categories() {
-  return array(
+  return [
     'description' => 'Categories',
-    'fields' => array(
-      'categoryid' => array(
+    'fields' => [
+      'categoryid' => [
         'type' => 'int',
         'not null' => TRUE,
         'unsigned' => TRUE,
         'description' => 'Category ID',
-      ),
-      'type' => array(
+      ],
+      'type' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => TRUE,
         'description' => 'Type of category: variety, region, best_with',
-      ),
-      'name'  => array(
+      ],
+      'name'  => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => TRUE,
-      ),
-      'details' => array(
+      ],
+      'details' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
-      ),
-      'category_parent' => array(
+      ],
+      'category_parent' => [
         'type' => 'int',
         'unsigned' => TRUE,
         'not null' => FALSE,
         'description' => 'Parent category, if any',
-      ),
-      'ordering' => array(
+      ],
+      'ordering' => [
         'type' => 'int',
         'unsigned' => FALSE,
         'not null' => FALSE,
         'description' => 'Order in which to display categories',
-      ),
-    ),
-    'primary key' => array('categoryid'),
-  );
+      ],
+    ],
+    'primary key' => ['categoryid'],
+  ];
 }
 
+/**
+ * The hook_schema definition for vintages.
+ *
+ * @return array
+ *   The schema definition.
+ */
 function migrate_example_advanced_schema_vintages() {
-  return array(
+  return [
     'description' => 'Wine vintages',
-    'fields' => array(
-      'wineid'  => array(
+    'fields' => [
+      'wineid'  => [
         'type' => 'int',
         'not null' => TRUE,
         'description' => 'Wine ID',
-      ),
-      'vintage'  => array(
+      ],
+      'vintage'  => [
         'type' => 'int',
         'unsigned' => TRUE,
         'not null' => TRUE,
         'description' => 'Vintage (year)',
-      ),
-    ),
-    'primary key' => array('wineid', 'vintage'),
-  );
+      ],
+    ],
+    'primary key' => ['wineid', 'vintage'],
+  ];
 }
 
+/**
+ * The hook_schema definition for variety updates.
+ *
+ * @return array
+ *   The schema definition.
+ */
 function migrate_example_advanced_schema_variety_updates() {
-  return array(
+  return [
     'description' => 'Variety updates',
-    'fields' => array(
-      'categoryid' => array(
+    'fields' => [
+      'categoryid' => [
         'type' => 'int',
         'not null' => TRUE,
         'unsigned' => TRUE,
         'description' => 'Category ID',
-      ),
-      'details' => array(
+      ],
+      'details' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
-      ),
-    ),
-    'primary key' => array('categoryid'),
-  );
+      ],
+    ],
+    'primary key' => ['categoryid'],
+  ];
 }
 
+/**
+ * The hook_schema definition for category wine.
+ *
+ * @return array
+ *   The schema definition.
+ */
 function migrate_example_advanced_schema_category_wine() {
-  return array(
+  return [
     'description' => 'Wine category assignments',
-    'fields' => array(
-      'wineid'  => array(
+    'fields' => [
+      'wineid'  => [
         'type' => 'int',
         'not null' => TRUE,
         'description' => 'Wine ID',
-      ),
-      'categoryid'  => array(
+      ],
+      'categoryid'  => [
         'type' => 'int',
         'unsigned' => TRUE,
         'not null' => TRUE,
         'description' => 'Category ID',
-      ),
-    ),
-    'primary key' => array('categoryid', 'wineid'),
-  );
+      ],
+    ],
+    'primary key' => ['categoryid', 'wineid'],
+  ];
 }
 
+/**
+ * The hook_schema definition for category producer.
+ *
+ * @return array
+ *   The schema definition.
+ */
 function migrate_example_advanced_schema_category_producer() {
-  return array(
+  return [
     'description' => 'Producer category assignments',
-    'fields' => array(
-      'producerid'  => array(
+    'fields' => [
+      'producerid'  => [
         'type' => 'int',
         'not null' => TRUE,
         'description' => 'Producer ID',
-      ),
-      'categoryid'  => array(
+      ],
+      'categoryid'  => [
         'type' => 'int',
         'unsigned' => TRUE,
         'not null' => TRUE,
         'description' => 'Category ID',
-      ),
-    ),
-    'primary key' => array('categoryid', 'producerid'),
-  );
+      ],
+    ],
+    'primary key' => ['categoryid', 'producerid'],
+  ];
 }
 
+/**
+ * The hook_schema definition for comment.
+ *
+ * @return array
+ *   The schema definition.
+ */
 function migrate_example_advanced_schema_comment() {
-  return array(
+  return [
     'description' => 'Wine comments',
-    'fields' => array(
-      'commentid'  => array(
+    'fields' => [
+      'commentid'  => [
         'type' => 'int',
         'unsigned' => TRUE,
         'not null' => TRUE,
         'description' => 'Comment ID',
-      ),
-      'wineid'  => array(
+      ],
+      'wineid'  => [
         'type' => 'int',
         'unsigned' => TRUE,
         'not null' => TRUE,
         'description' => 'Wine ID that is being commented upon',
-      ),
-      'comment_parent' => array(
+      ],
+      'comment_parent' => [
         'type' => 'int',
         'unsigned' => TRUE,
         'not null' => FALSE,
         'description' => 'Parent comment ID in case of comment replies.',
-      ),
-      'subject' => array(
+      ],
+      'subject' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Comment subject',
-      ),
-      'body' => array(
+      ],
+      'body' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Comment body',
-      ),
-      'name' => array(
+      ],
+      'name' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Comment name (if anon)',
-      ),
-      'mail' => array(
+      ],
+      'mail' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Comment email (if anon)',
-      ),
-      'accountid' => array(
+      ],
+      'accountid' => [
         'type' => 'int',
         'unsigned' => TRUE,
         'not null' => FALSE,
         'description' => 'Account ID (if any).',
-      ),
-      'commenthost' => array(
+      ],
+      'commenthost' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'IP/domain of host posted from',
-      ),
-      'userpage' => array(
+      ],
+      'userpage' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'User homepage',
-      ),
-      'posted' => array(
+      ],
+      'posted' => [
         'type' => 'int',
         'unsigned' => TRUE,
         'not null' => TRUE,
         'description' => 'Date comment posted',
-      ),
-      'lastchanged' => array(
+      ],
+      'lastchanged' => [
         'type' => 'int',
         'unsigned' => TRUE,
         'not null' => TRUE,
         'description' => 'Date comment last changed',
-      ),
-    ),
-    'primary key' => array('commentid'),
-  );
+      ],
+    ],
+    'primary key' => ['commentid'],
+  ];
 }
 
+/**
+ * The hook_schema definition for comment updates.
+ *
+ * @return array
+ *   The schema definition.
+ */
 function migrate_example_advanced_schema_comment_updates() {
-  return array(
+  return [
     'description' => 'Wine comment updates',
-    'fields' => array(
-      'commentid'  => array(
+    'fields' => [
+      'commentid'  => [
         'type' => 'int',
         'unsigned' => TRUE,
         'not null' => TRUE,
         'description' => 'Comment ID',
-      ),
-      'subject' => array(
+      ],
+      'subject' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Comment subject',
-      ),
-    ),
-    'primary key' => array('commentid'),
-  );
+      ],
+    ],
+    'primary key' => ['commentid'],
+  ];
 }
 
+/**
+ * The hook_schema definition for account.
+ *
+ * @return array
+ *   The schema definition.
+ */
 function migrate_example_advanced_schema_account() {
-  return array(
+  return [
     'description' => 'Wine accounts.',
-    'fields' => array(
-      'accountid'  => array(
+    'fields' => [
+      'accountid'  => [
         'type' => 'serial',
         'not null' => TRUE,
         'description' => 'Account ID',
-      ),
-      'status'  => array(
+      ],
+      'status'  => [
         'type' => 'int',
         'not null' => TRUE,
         'description' => 'Blocked_Allowed',
-      ),
-      'posted' => array(
+      ],
+      'posted' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => TRUE,
         'description' => 'Registration date',
-      ),
-      'last_access' => array(
+      ],
+      'last_access' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => TRUE,
         'description' => 'Last access date',
-      ),
-      'last_login' => array(
+      ],
+      'last_login' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => TRUE,
         'description' => 'Last login date',
-      ),
-      'name' => array(
+      ],
+      'name' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Account name (for login)',
-      ),
-      'sex' => array(
+      ],
+      'sex' => [
         'type' => 'char',
         'length' => 1,
         'not null' => FALSE,
         'description' => 'Gender',
-      ),
-      'password' => array(
+      ],
+      'password' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Account password (raw)',
-      ),
-      'mail' => array(
+      ],
+      'mail' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Account email',
-      ),
-      'original_mail' => array(
+      ],
+      'original_mail' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Original account email',
-      ),
-      'sig' => array(
+      ],
+      'sig' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => TRUE,
         'description' => 'Signature for comments',
-      ),
-      'imageid'  => array(
+      ],
+      'imageid'  => [
         'type' => 'int',
         'unsigned' => TRUE,
         'not null' => FALSE,
         'description' => 'Image ID',
-      ),
-      'positions' => array(
+      ],
+      'positions' => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Positions held',
-      ),
-    ),
-    'primary key' => array('accountid'),
-  );
+      ],
+    ],
+    'primary key' => ['accountid'],
+  ];
 }
 
+/**
+ * The hook_schema definition for account updates.
+ *
+ * @return array
+ *   The schema definition.
+ */
 function migrate_example_advanced_schema_account_updates() {
-  return array(
+  return [
     'description' => 'Wine account updates',
-    'fields' => array(
-      'accountid'  => array(
+    'fields' => [
+      'accountid'  => [
         'type' => 'serial',
         'not null' => TRUE,
         'description' => 'Account ID',
-      ),
-      'sex' => array(
+      ],
+      'sex' => [
         'type' => 'char',
         'length' => 1,
         'not null' => FALSE,
         'description' => 'Gender',
-      ),
-    ),
-    'primary key' => array('accountid'),
-  );
+      ],
+    ],
+    'primary key' => ['accountid'],
+  ];
 }
 
+/**
+ * The hook_schema definition for blobs.
+ *
+ * @return array
+ *   The schema definition.
+ */
 function migrate_example_advanced_schema_blobs() {
-  return array(
+  return [
     'description' => 'Wine blobs to be migrated to file entities',
-    'fields' => array(
-      'imageid'  => array(
+    'fields' => [
+      'imageid'  => [
         'type' => 'int',
         'unsigned' => TRUE,
         'not null' => TRUE,
         'description' => 'Image ID',
-      ),
-      'imageblob' => array(
+      ],
+      'imageblob' => [
         'type' => 'blob',
         'size' => 'normal',
         'description' => 'binary image data',
-      ),
-    ),
-    'primary key' => array('imageid'),
-  );
+      ],
+    ],
+    'primary key' => ['imageid'],
+  ];
 }
 
+/**
+ * The hook_schema definition for files.
+ *
+ * @return array
+ *   The schema definition.
+ */
 function migrate_example_advanced_schema_files() {
-  return array(
+  return [
     'description' => 'Wine and account files',
-    'fields' => array(
-      'imageid'  => array(
+    'fields' => [
+      'imageid'  => [
         'type' => 'int',
         'unsigned' => TRUE,
         'not null' => TRUE,
         'description' => 'Image ID',
-      ),
-      'url'  => array(
+      ],
+      'url'  => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => TRUE,
         'description' => 'Image URL',
-      ),
-      'image_alt'  => array(
+      ],
+      'image_alt'  => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Image alt',
-      ),
-      'image_title'  => array(
+      ],
+      'image_title'  => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => FALSE,
         'description' => 'Image title',
-      ),
-      'wineid'  => array(
+      ],
+      'wineid'  => [
         'type' => 'int',
         'unsigned' => TRUE,
         'not null' => FALSE,
         'description' => 'Wine node this is associated with',
-      ),
-    ),
-    'primary key' => array('imageid'),
-  );
+      ],
+    ],
+    'primary key' => ['imageid'],
+  ];
 }
 
+/**
+ * The hook_schema definition for table source.
+ *
+ * @return array
+ *   The schema definition.
+ */
 function migrate_example_advanced_schema_table_source() {
-  return array(
+  return [
     'description' => 'Source data to go into a custom Drupal table',
-    'fields' => array(
-      'fooid'  => array(
+    'fields' => [
+      'fooid'  => [
         'type' => 'int',
         'unsigned' => TRUE,
         'not null' => TRUE,
         'description' => 'Primary key',
-      ),
-      'field1'  => array(
+      ],
+      'field1'  => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => TRUE,
         'description' => 'First field',
-      ),
-      'field2'  => array(
+      ],
+      'field2'  => [
         'type' => 'int',
         'unsigned' => TRUE,
         'not null' => TRUE,
         'description' => 'Second field',
-      ),
-    ),
-    'primary key' => array('fooid'),
-  );
+      ],
+    ],
+    'primary key' => ['fooid'],
+  ];
 }
 
+/**
+ * The hook_schema definition for table destination.
+ *
+ * @return array
+ *   The schema definition.
+ */
 function migrate_example_advanced_schema_table_dest() {
-  return array(
+  return [
     'description' => 'Custom Drupal table to receive source data directly',
-    'fields' => array(
-      'recordid'  => array(
+    'fields' => [
+      'recordid'  => [
         'type' => 'serial',
         'unsigned' => TRUE,
         'not null' => TRUE,
         'description' => 'Primary key',
-      ),
-      'drupal_text'  => array(
+      ],
+      'drupal_text'  => [
         'type' => 'varchar',
         'length' => 255,
         'not null' => TRUE,
         'description' => 'First field',
-      ),
-      'drupal_int'  => array(
+      ],
+      'drupal_int'  => [
         'type' => 'int',
         'unsigned' => TRUE,
         'not null' => TRUE,
         'description' => 'Second field',
-      ),
-    ),
-    'primary key' => array('recordid'),
-  );
+      ],
+    ],
+    'primary key' => ['recordid'],
+  ];
 }
 
+/**
+ * Populate wine table.
+ */
 function migrate_example_advanced_data_wine() {
-  $fields = array('wineid', 'name', 'body', 'excerpt', 'accountid',
-    'posted', 'last_changed', 'variety', 'region', 'rating');
+  $fields = [
+    'wineid',
+    'name',
+    'body',
+    'excerpt',
+    'accountid',
+    'posted',
+    'last_changed',
+    'variety',
+    'region',
+    'rating',
+  ];
   $query = db_insert('migrate_example_wine')
-           ->fields($fields);
-  $data = array(
-    array(1, 'Montes Classic Cabernet Sauvignon', 'Intense ruby-red color', 'Great!', 9,
-      strtotime('2010-01-02 03:04:05'), strtotime('2010-03-04 05:06:07'), 25, 17, 95),
-    array(2, 'Archeo Ruggero di Tasso Nero d\'Avola', 'Lots of berry character', 'Pair with red sauced dishes', 3,
-      strtotime('2010-09-03 18:23:58'), strtotime('2010-09-03 18:23:58'), 26, 2, 85),
-  );
+    ->fields($fields);
+  $data = [
+    [
+      1,
+      'Montes Classic Cabernet Sauvignon',
+      'Intense ruby-red color',
+      'Great!',
+      9,
+      strtotime('2010-01-02 03:04:05'),
+      strtotime('2010-03-04 05:06:07'),
+      25,
+      17,
+      95,
+    ],
+    [
+      2,
+      'Archeo Ruggero di Tasso Nero d\'Avola',
+      'Lots of berry character',
+      'Pair with red sauced dishes',
+      3,
+      strtotime('2010-09-03 18:23:58'),
+      strtotime('2010-09-03 18:23:58'),
+      26,
+      2,
+      85,
+    ],
+  ];
   foreach ($data as $row) {
     $query->values(array_combine($fields, $row));
   }
   $query->execute();
 }
 
+/**
+ * Populate updates table.
+ */
 function migrate_example_advanced_data_updates() {
-  $fields = array('wineid', 'rating');
+  $fields = ['wineid', 'rating'];
   $query = db_insert('migrate_example_advanced_updates')
-           ->fields($fields);
-  $data = array(
-    array(1, 93),
-    array(2, NULL),
-  );
+    ->fields($fields);
+  $data = [
+    [1, 93],
+    [2, NULL],
+  ];
   foreach ($data as $row) {
     $query->values(array_combine($fields, $row));
   }
   $query->execute();
 }
 
+/**
+ * Populate producer table.
+ */
 function migrate_example_advanced_data_producer() {
-  $fields = array('producerid', 'name', 'body', 'excerpt', 'accountid');
+  $fields = ['producerid', 'name', 'body', 'excerpt', 'accountid'];
   $query = db_insert('migrate_example_advanced_producer')
-           ->fields($fields);
-  $data = array(
-    array(1, 'Montes', 'Fine Chilean winery', 'Great!', 9),
-    array(2, 'Archeo', 'Sicilia!', NULL, 3),
-  );
+    ->fields($fields);
+  $data = [
+    [1, 'Montes', 'Fine Chilean winery', 'Great!', 9],
+    [2, 'Archeo', 'Sicilia!', NULL, 3],
+  ];
   foreach ($data as $row) {
     $query->values(array_combine($fields, $row));
   }
   $query->execute();
 }
 
+/**
+ * Populate account table.
+ */
 function migrate_example_advanced_data_account() {
-  $fields = array('accountid', 'status', 'posted', 'last_access', 'last_login',
-    'name', 'sex', 'password', 'mail', 'original_mail', 'sig', 'imageid', 'positions');
+  $fields = [
+    'accountid',
+    'status',
+    'posted',
+    'last_access',
+    'last_login',
+    'name',
+    'sex',
+    'password',
+    'mail',
+    'original_mail',
+    'sig',
+    'imageid',
+    'positions',
+  ];
   $query = db_insert('migrate_example_advanced_account')
     ->fields($fields);
-  $data = array(
-    array(1, 1, '2010-03-30 10:31:05', '2010-04-30 18:25:24', '2010-04-30 14:01:02',
-      'darren', 'M', 'dpass', 'ddarren@example.com', 'darren@example.com',
-      'All about the Australians', NULL, '5'),
-    array(3, 0, '2007-03-15 10:31:05', '2007-06-10 04:11:38', '2007-06-10 04:11:38',
-      'emily', 'F', 'insecure', 'emily@example.com', 'emily@example.com',
-      'Sommelier to the stars', NULL, '18'),
-    array(9, 1, '2004-02-29 10:31:05', '2004-02-29 10:31:05', '2004-02-29 10:31:05',
-      'fonzie', NULL, 'bike', 'thefonz@example.com', 'arthur@example.com',
-      'Aaay!', 1, '5,18'),
-  );
+  $data = [
+    [
+      1,
+      1,
+      '2010-03-30 10:31:05',
+      '2010-04-30 18:25:24',
+      '2010-04-30 14:01:02',
+      'darren',
+      'M',
+      'dpass',
+      'ddarren@example.com',
+      'darren@example.com',
+      'All about the Australians',
+      NULL,
+      '5',
+    ],
+    [
+      3,
+      0,
+      '2007-03-15 10:31:05',
+      '2007-06-10 04:11:38',
+      '2007-06-10 04:11:38',
+      'emily',
+      'F',
+      'insecure',
+      'emily@example.com',
+      'emily@example.com',
+      'Sommelier to the stars',
+      NULL,
+      '18',
+    ],
+    [
+      9,
+      1,
+      '2004-02-29 10:31:05',
+      '2004-02-29 10:31:05',
+      '2004-02-29 10:31:05',
+      'fonzie',
+      NULL,
+      'bike',
+      'thefonz@example.com',
+      'arthur@example.com',
+      'Aaay!',
+      1,
+      '5,18',
+    ],
+  ];
   foreach ($data as $row) {
     $query->values(array_combine($fields, $row));
   }
   $query->execute();
 }
 
+/**
+ * Populate account updates table.
+ */
 function migrate_example_advanced_data_account_updates() {
-  $fields = array('accountid', 'sex');
+  $fields = ['accountid', 'sex'];
   $query = db_insert('migrate_example_advanced_account_updates')
-           ->fields($fields);
-  $data = array(
-    array(1, NULL),
-    array(3, 'M'),
-    array(9, 'F'),
-  );
+    ->fields($fields);
+  $data = [
+    [1, NULL],
+    [3, 'M'],
+    [9, 'F'],
+  ];
   foreach ($data as $row) {
     $query->values(array_combine($fields, $row));
   }
   $query->execute();
 }
 
+/**
+ * Populate comment table.
+ */
 function migrate_example_advanced_data_comment() {
-  $fields = array('commentid', 'wineid', 'comment_parent', 'subject', 'body',
-    'name', 'mail', 'accountid', 'commenthost', 'userpage', 'posted', 'lastchanged');
+  $fields = [
+    'commentid',
+    'wineid',
+    'comment_parent',
+    'subject',
+    'body',
+    'name',
+    'mail',
+    'accountid',
+    'commenthost',
+    'userpage',
+    'posted',
+    'lastchanged',
+  ];
   $query = db_insert('migrate_example_advanced_comment')
     ->fields($fields);
-  $data = array(
-    array(1, 1, NULL, 'im first', 'Tasty', 'grace', 'grace@example.com', 0,
-      '123.456.78.9', 'http:://grace.example.com/',
-      strtotime('2010-01-02 03:04:05'), strtotime('2010-04-05 06:07:08')),
-    array(2, 1, NULL, 'im second', 'Delicious', 'horace', 'horace@example.com', 0,
-      'example.com', NULL,
-      strtotime('2010-02-02 03:04:05'), strtotime('2010-05-05 06:07:08')),
-    array(3, 1, NULL, 'im parent', 'Don\'t care for it', 'irene', 'irene@example.com', 0,
-      '254.0.2.5', 'http:://www.example.com/irene',
-      strtotime('2010-03-02 03:04:05'), strtotime('2010-03-02 03:04:05')),
-    array(4, 1, 3, 'im child', 'But it\'s so good!', 'emily', NULL, 3,
-      '58.29.126.1', 'http:://www.wine.com/',
-      strtotime('2010-01-02 03:04:05'), strtotime('2010-01-02 03:04:05')),
-    array(5, 1, 4, 'im grandchild', 'Right on, Emily!', 'thefonz@example.com', NULL, 9,
-      '123.456.78.9', NULL,
-      strtotime('2010-06-02 03:04:05'), strtotime('2010-06-02 03:04:05')),
-  );
+  $data = [
+    [
+      1,
+      1,
+      NULL,
+      'im first',
+      'Tasty',
+      'grace',
+      'grace@example.com',
+      0,
+      '123.456.78.9',
+      'http:://grace.example.com/',
+      strtotime('2010-01-02 03:04:05'),
+      strtotime('2010-04-05 06:07:08'),
+    ],
+    [
+      2,
+      1,
+      NULL,
+      'im second',
+      'Delicious',
+      'horace',
+      'horace@example.com',
+      0,
+      'example.com',
+      NULL,
+      strtotime('2010-02-02 03:04:05'),
+      strtotime('2010-05-05 06:07:08'),
+    ],
+    [
+      3,
+      1,
+      NULL,
+      'im parent',
+      'Don\'t care for it',
+      'irene',
+      'irene@example.com',
+      0,
+      '254.0.2.5',
+      'http:://www.example.com/irene',
+      strtotime('2010-03-02 03:04:05'),
+      strtotime('2010-03-02 03:04:05'),
+    ],
+    [
+      4,
+      1,
+      3,
+      'im child',
+      'But it\'s so good!',
+      'emily',
+      NULL,
+      3,
+      '58.29.126.1',
+      'http:://www.wine.com/',
+      strtotime('2010-01-02 03:04:05'),
+      strtotime('2010-01-02 03:04:05'),
+    ],
+    [
+      5,
+      1,
+      4,
+      'im grandchild',
+      'Right on, Emily!',
+      'thefonz@example.com',
+      NULL,
+      9,
+      '123.456.78.9',
+      NULL,
+      strtotime('2010-06-02 03:04:05'),
+      strtotime('2010-06-02 03:04:05'),
+    ],
+  ];
   foreach ($data as $row) {
     $query->values(array_combine($fields, $row));
   }
   $query->execute();
 }
 
+/**
+ * Populate comment updates table.
+ */
 function migrate_example_advanced_data_comment_updates() {
-  $fields = array('commentid', 'subject');
+  $fields = ['commentid', 'subject'];
   $query = db_insert('migrate_example_advanced_comment_updates')
-           ->fields($fields);
-  $data = array(
-    array(1, 'I am first'),
-    array(2, 'I am second'),
-    array(3, 'I am parent'),
-    array(4, ''),
-    array(5, 'I am Spartacus'),
-  );
+    ->fields($fields);
+  $data = [
+    [1, 'I am first'],
+    [2, 'I am second'],
+    [3, 'I am parent'],
+    [4, ''],
+    [5, 'I am Spartacus'],
+  ];
   foreach ($data as $row) {
     $query->values(array_combine($fields, $row));
   }
   $query->execute();
 }
 
+/**
+ * Populate categories table.
+ */
 function migrate_example_advanced_data_categories() {
-  $fields = array('categoryid', 'type', 'name', 'category_parent', 'details', 'ordering');
+  $fields = [
+    'categoryid',
+    'type',
+    'name',
+    'category_parent',
+    'details',
+    'ordering',
+  ];
   $query = db_insert('migrate_example_advanced_categories')
-           ->fields($fields);
-  $data = array(
-    array(1, 'variety', 'White wine', NULL, 'White wines are generally simpler and sweeter than red', 3),
-    array(3, 'variety', 'Red wine', NULL, 'Red wines are generally more complex and "dry" than white', 1),
-    array(8, 'variety', 'Riesling', 1, 'Associated with Germany', 2),
-    array(9, 'variety', 'Chardonnay', 1, 'One of the most popular whites', 1),
-    array(13, 'variety', 'Merlot', 3, 'Very drinkable', 4),
-    array(14, 'variety', 'Syrah', 3, 'A.k.a. shiraz', -3),
-    array(25, 'variety', 'Cabernet Sauvignon', 3, 'A basic', -5),
-    array(26, 'variety', "Nero d'Avola", 3, 'Sicilian specialty', 2),
-    array(2, 'region', 'Italy', NULL, 'Largest producer of wine', 5),
-    array(11, 'region', 'Tuscany', 2, NULL, 2),
-    array(18, 'region', 'Chianti', 11, NULL, -1),
-    array(19, 'region', 'Elba', 11, NULL, 5),
-    array(4, 'region', 'France', NULL, 'C\'est bon', 6),
-    array(5, 'region', 'Bordeaux', 4, NULL, 1),
-    array(6, 'region', 'Barsac', 5, NULL, 3),
-    array(7, 'region', 'Pomerol', 5, NULL, 2),
-    array(16, 'region', 'Chile', NULL, NULL, 3),
-    array(17, 'region', 'Colchagua Valley', 16, NULL, 1),
-    array(20, 'region', 'California', NULL, NULL, 5),
-    array(21, 'region', 'Redwood Valley', 20, NULL, 1),
-    array(10, 'best_with', 'Beef', NULL, NULL, 5),
-    array(12, 'best_with', 'Pork', NULL, NULL, -3),
-    array(15, 'best_with', 'Chicken', NULL, NULL, -5),
-  );
+    ->fields($fields);
+  $data = [
+    [
+      1,
+      'variety',
+      'White wine',
+      NULL,
+      'White wines are generally simpler and sweeter than red',
+      3,
+    ],
+    [
+      3,
+      'variety',
+      'Red wine',
+      NULL,
+      'Red wines are generally more complex and "dry" than white',
+      1,
+    ],
+    [8, 'variety', 'Riesling', 1, 'Associated with Germany', 2],
+    [9, 'variety', 'Chardonnay', 1, 'One of the most popular whites', 1],
+    [13, 'variety', 'Merlot', 3, 'Very drinkable', 4],
+    [14, 'variety', 'Syrah', 3, 'A.k.a. shiraz', -3],
+    [25, 'variety', 'Cabernet Sauvignon', 3, 'A basic', -5],
+    [26, 'variety', "Nero d'Avola", 3, 'Sicilian specialty', 2],
+    [2, 'region', 'Italy', NULL, 'Largest producer of wine', 5],
+    [11, 'region', 'Tuscany', 2, NULL, 2],
+    [18, 'region', 'Chianti', 11, NULL, -1],
+    [19, 'region', 'Elba', 11, NULL, 5],
+    [4, 'region', 'France', NULL, 'C\'est bon', 6],
+    [5, 'region', 'Bordeaux', 4, NULL, 1],
+    [6, 'region', 'Barsac', 5, NULL, 3],
+    [7, 'region', 'Pomerol', 5, NULL, 2],
+    [16, 'region', 'Chile', NULL, NULL, 3],
+    [17, 'region', 'Colchagua Valley', 16, NULL, 1],
+    [20, 'region', 'California', NULL, NULL, 5],
+    [21, 'region', 'Redwood Valley', 20, NULL, 1],
+    [10, 'best_with', 'Beef', NULL, NULL, 5],
+    [12, 'best_with', 'Pork', NULL, NULL, -3],
+    [15, 'best_with', 'Chicken', NULL, NULL, -5],
+  ];
   foreach ($data as $row) {
     $query->values(array_combine($fields, $row));
   }
   $query->execute();
 }
 
+/**
+ * Populate vintages table.
+ */
 function migrate_example_advanced_data_vintages() {
-  $fields = array('wineid', 'vintage');
+  $fields = ['wineid', 'vintage'];
   $query = db_insert('migrate_example_advanced_vintages')
-           ->fields($fields);
-  $data = array(
-    array(1, 2006),
-    array(1, 2007),
-    array(2, 2001),
-  );
+    ->fields($fields);
+  $data = [
+    [1, 2006],
+    [1, 2007],
+    [2, 2001],
+  ];
   foreach ($data as $row) {
     $query->values(array_combine($fields, $row));
   }
   $query->execute();
 }
 
+/**
+ * Populate variety updates table.
+ */
 function migrate_example_advanced_data_variety_updates() {
-  $fields = array('categoryid', 'details');
+  $fields = ['categoryid', 'details'];
   $query = db_insert('migrate_example_advanced_variety_updates')
-           ->fields($fields);
-  $data = array(
-    array(1, 'White wines are simpler and sweeter than red'),
-    array(3, 'Red wines are generally more complex and dry than white'),
-    array(8, 'Usually associated with Germany'),
-    array(9, NULL),
-    array(13, 'Common, very drinakable'),
-    array(14, 'AKA Shiraz'),
-    array(25, 'Basic'),
-    array(26, 'A specialty of Sicily'),
-  );
+    ->fields($fields);
+  $data = [
+    [1, 'White wines are simpler and sweeter than red'],
+    [3, 'Red wines are generally more complex and dry than white'],
+    [8, 'Usually associated with Germany'],
+    [9, NULL],
+    [13, 'Common, very drinakable'],
+    [14, 'AKA Shiraz'],
+    [25, 'Basic'],
+    [26, 'A specialty of Sicily'],
+  ];
   foreach ($data as $row) {
     $query->values(array_combine($fields, $row));
   }
   $query->execute();
 }
 
+/**
+ * Populate category wine table.
+ */
 function migrate_example_advanced_data_category_wine() {
-  $fields = array('wineid', 'categoryid');
+  $fields = ['wineid', 'categoryid'];
   $query = db_insert('migrate_example_advanced_category_wine')
     ->fields($fields);
-  $data = array(
-    array(1, 12),
-    array(1, 15),
-    array(2, 10),
-  );
+  $data = [
+    [1, 12],
+    [1, 15],
+    [2, 10],
+  ];
   foreach ($data as $row) {
     $query->values(array_combine($fields, $row));
   }
   $query->execute();
 }
 
+/**
+ * Populate category producer table.
+ */
 function migrate_example_advanced_data_category_producer() {
-  $fields = array('producerid', 'categoryid');
+  $fields = ['producerid', 'categoryid'];
   $query = db_insert('migrate_example_advanced_category_producer')
     ->fields($fields);
-  $data = array(
-    array(1, 17),
-  );
+  $data = [
+    [1, 17],
+  ];
   foreach ($data as $row) {
     $query->values(array_combine($fields, $row));
   }
   $query->execute();
 }
 
+/**
+ * Populate files table.
+ */
 function migrate_example_advanced_data_files() {
-  $fields = array('imageid', 'url', 'image_alt', 'image_title', 'wineid');
+  $fields = ['imageid', 'url', 'image_alt', 'image_title', 'wineid'];
   $query = db_insert('migrate_example_advanced_files')
     ->fields($fields);
-  $data = array(
-    array(1, 'http://placekitten.com/200/200', NULL, NULL, NULL),
-    array(2, 'http://cyrve.com/files/penguin.jpeg', 'Penguin alt', 'Penguin title', 1),
-    array(3, 'http://cyrve.com/files/rioja.jpeg', 'Rioja alt', 'Rioja title', 2),
-    array(4, 'http://cyrve.com/files/boutisse_0.jpeg', 'Boutisse alt', 'Boutisse title', 2),
-  );
+  $data = [
+    [
+      1,
+      'http://placekitten.com/200/200',
+      NULL,
+      NULL,
+      NULL,
+    ],
+    [
+      2,
+      'http://cyrve.com/files/penguin.jpeg',
+      'Penguin alt',
+      'Penguin title',
+      1,
+    ],
+    [3, 'http://cyrve.com/files/rioja.jpeg', 'Rioja alt', 'Rioja title', 2],
+    [
+      4,
+      'http://cyrve.com/files/boutisse_0.jpeg',
+      'Boutisse alt',
+      'Boutisse title',
+      2,
+    ],
+  ];
   foreach ($data as $row) {
     $query->values(array_combine($fields, $row));
   }
   $query->execute();
 }
 
+/**
+ * Populate blobs table.
+ */
 function migrate_example_advanced_data_blobs() {
   $blob = file_get_contents('core/misc/druplicon.png');
-  $fields = array('imageid', 'imageblob');
+  $fields = ['imageid', 'imageblob'];
   $query = db_insert('migrate_example_advanced_blobs')
     ->fields($fields);
-  $data = array(
-    array(1, $blob),
-  );
+  $data = [
+    [1, $blob],
+  ];
   foreach ($data as $row) {
     $query->values(array_combine($fields, $row));
   }
   $query->execute();
 }
 
+/**
+ * Populate table source table.
+ */
 function migrate_example_advanced_data_table_source() {
-  $fields = array('fooid', 'field1', 'field2');
+  $fields = ['fooid', 'field1', 'field2'];
   $query = db_insert('migrate_example_advanced_table_source')
     ->fields($fields);
-  $data = array(
-    array(3, 'Some sample data', 58),
-    array(15, 'Whatever', 2),
-    array(646, 'More sample data', 34989),
-  );
+  $data = [
+    [3, 'Some sample data', 58],
+    [15, 'Whatever', 2],
+    [646, 'More sample data', 34989],
+  ];
   foreach ($data as $row) {
     $query->values(array_combine($fields, $row));
   }
diff --git a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/src/Plugin/rest/resource/PositionResource.php b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/src/Plugin/rest/resource/PositionResource.php
index f57e0c20ae..20fe34aadd 100644
--- a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/src/Plugin/rest/resource/PositionResource.php
+++ b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/src/Plugin/rest/resource/PositionResource.php
@@ -33,4 +33,12 @@ public function get() {
     return $response;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function permissions() {
+    // Remove permissions so the resource is available to all.
+    return [];
+  }
+
 }
diff --git a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/src/Plugin/rest/resource/VarietyItems.php b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/src/Plugin/rest/resource/VarietyItems.php
index f1045040a0..670134c345 100644
--- a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/src/Plugin/rest/resource/VarietyItems.php
+++ b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/src/Plugin/rest/resource/VarietyItems.php
@@ -31,22 +31,26 @@ public function get($variety = NULL) {
     $varieties = [
       'retsina' => [
         'name' => 'Retsina',
-        'parent' => 1,  // categoryid for 'white'.
+        // The categoryid for 'white'.
+        'parent' => 1,
         'details' => 'Greek',
       ],
       'trebbiano' => [
         'name' => 'Trebbiano',
-        'parent' => 1,  // categoryid for 'white'.
+        // The categoryid for 'white'.
+        'parent' => 1,
         'details' => 'Italian',
       ],
       'valpolicella' => [
         'name' => 'Valpolicella',
-        'parent' => 3,  // categoryid for 'red'.
+        // The categoryid for 'red'.
+        'parent' => 3,
         'details' => 'Italian Venoto region',
       ],
       'bardolino' => [
         'name' => 'Bardolino',
-        'parent' => 3,  // categoryid for 'red'.
+        // The categoryid for 'red'.
+        'parent' => 3,
         'details' => 'Italian Venoto region',
       ],
     ];
@@ -61,4 +65,12 @@ public function get($variety = NULL) {
     return $response;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function permissions() {
+    // Remove permissions so the resource is available to all.
+    return [];
+  }
+
 }
diff --git a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/src/Plugin/rest/resource/VarietyList.php b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/src/Plugin/rest/resource/VarietyList.php
index 11847676cc..bb4bb60cbc 100644
--- a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/src/Plugin/rest/resource/VarietyList.php
+++ b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/src/Plugin/rest/resource/VarietyList.php
@@ -31,4 +31,12 @@ public function get() {
     return $response;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function permissions() {
+    // Remove permissions so the resource is available to all.
+    return [];
+  }
+
 }
diff --git a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/src/Plugin/rest/resource/VarietyMultiFiles.php b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/src/Plugin/rest/resource/VarietyMultiFiles.php
index 829dfa89fe..5db35b5ce8 100644
--- a/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/src/Plugin/rest/resource/VarietyMultiFiles.php
+++ b/web/modules/migrate_plus/migrate_example_advanced/migrate_example_advanced_setup/src/Plugin/rest/resource/VarietyMultiFiles.php
@@ -32,7 +32,8 @@ public function get($type = NULL) {
     if (strtolower($type) != 'white') {
       $data['variety'][] = [
         'name' => 'Amarone',
-        'parent' => 3,  // categoryid for 'red'.
+        // The categoryid for 'red'.
+        'parent' => 3,
         'details' => 'Italian Venoto region',
         'attributes' => [
           'rich',
@@ -41,7 +42,8 @@ public function get($type = NULL) {
       ];
       $data['variety'][] = [
         'name' => 'Barbaresco',
-        'parent' => 3,  // categoryid for 'red'.
+        // The categoryid for 'red'.
+        'parent' => 3,
         'details' => 'Italian Piedmont region',
         'attributes' => [
           'smoky',
@@ -52,13 +54,15 @@ public function get($type = NULL) {
     if (strtolower($type) != 'red') {
       $data['variety'][] = [
         'name' => 'Kir',
-        'parent' => 1,  // categoryid for 'white'.
+        // The categoryid for 'white'.
+        'parent' => 1,
         'details' => 'French Burgundy region',
         'attributes' => [],
       ];
       $data['variety'][] = [
         'name' => 'Pinot Grigio',
-        'parent' => 1,  // categoryid for 'white'.
+        // The categoryid for 'white'.
+        'parent' => 1,
         'details' => 'From the northeast of Italy',
         'attributes' => [
           'fruity',
@@ -72,4 +76,12 @@ public function get($type = NULL) {
     return $response;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function permissions() {
+    // Remove permissions so the resource is available to all.
+    return [];
+  }
+
 }
diff --git a/web/modules/migrate_plus/migrate_example_advanced/src/Plugin/migrate/source/WineTerm.php b/web/modules/migrate_plus/migrate_example_advanced/src/Plugin/migrate/source/WineTerm.php
index 4bc143fcdf..3d3d235dd2 100644
--- a/web/modules/migrate_plus/migrate_example_advanced/src/Plugin/migrate/source/WineTerm.php
+++ b/web/modules/migrate_plus/migrate_example_advanced/src/Plugin/migrate/source/WineTerm.php
@@ -5,8 +5,7 @@
 use Drupal\migrate\Plugin\migrate\source\SqlBase;
 
 /**
- * A straight-forward SQL-based source plugin, to retrieve category data from
- * the source database.
+ * A SQL-based source plugin, to retrieve category data from a source database.
  *
  * @MigrateSource(
  *   id = "wine_term"
@@ -18,8 +17,16 @@ class WineTerm extends SqlBase {
    * {@inheritdoc}
    */
   public function query() {
+    $fields = [
+      'categoryid',
+      'type',
+      'name',
+      'details',
+      'category_parent',
+      'ordering',
+    ];
     return $this->select('migrate_example_advanced_categories', 'wc')
-      ->fields('wc', ['categoryid', 'type', 'name', 'details', 'category_parent', 'ordering'])
+      ->fields('wc', $fields)
       // This sort assures that parents are saved before children.
       ->orderBy('category_parent', 'ASC');
   }
diff --git a/web/modules/migrate_plus/migrate_plus.info.yml b/web/modules/migrate_plus/migrate_plus.info.yml
index 7f2eb4fd11..c5c0e29c67 100644
--- a/web/modules/migrate_plus/migrate_plus.info.yml
+++ b/web/modules/migrate_plus/migrate_plus.info.yml
@@ -4,11 +4,10 @@ description: 'Enhancements to core migration support'
 package: Migration
 # core: 8.x
 dependencies:
-  - system (>=8.1)
-  - migrate
+  - drupal:migrate (>=8.3)
 
-# Information added by Drupal.org packaging script on 2016-08-05
-version: '8.x-2.0-beta2'
+# Information added by Drupal.org packaging script on 2018-09-06
+version: '8.x-4.0'
 core: '8.x'
 project: 'migrate_plus'
-datestamp: 1470428640
+datestamp: 1536264189
diff --git a/web/modules/migrate_plus/migrate_plus.module b/web/modules/migrate_plus/migrate_plus.module
index ce7f5bee5e..0dbddc4735 100644
--- a/web/modules/migrate_plus/migrate_plus.module
+++ b/web/modules/migrate_plus/migrate_plus.module
@@ -18,6 +18,24 @@
 function migrate_plus_migration_plugins_alter(array &$migrations) {
   /** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
   foreach ($migrations as $id => $migration) {
+    // Add the default class where empty.
+    if (empty($migration['class'])) {
+      $migrations[$id]['class'] = 'Drupal\migrate\Plugin\Migration';
+    }
+
+    // For derived configuration entity-based migrations, strip the deriver
+    // prefix so we can reference migrations by the IDs they specify (i.e.,
+    // the migration that specifies "id: temp" can be referenced as "temp"
+    // rather than "migration_config_deriver:temp").
+    $prefix = 'migration_config_deriver:';
+    if (strpos($id, $prefix) === 0) {
+      $new_id = substr($id, strlen($prefix));
+      $migrations[$new_id] = $migrations[$id];
+      unset($migrations[$id]);
+      $id = $new_id;
+    }
+
+    // Integrate shared group configuration into the migration.
     if (empty($migration['migration_group'])) {
       $migration['migration_group'] = 'default';
     }
diff --git a/web/modules/migrate_plus/migrate_plus.services.yml b/web/modules/migrate_plus/migrate_plus.services.yml
index 013fa2ca59..2ca3363c19 100644
--- a/web/modules/migrate_plus/migrate_plus.services.yml
+++ b/web/modules/migrate_plus/migrate_plus.services.yml
@@ -1,10 +1,10 @@
 services:
+  plugin.manager.migrate_plus.authentication:
+    class: Drupal\migrate_plus\AuthenticationPluginManager
+    parent: default_plugin_manager
   plugin.manager.migrate_plus.data_fetcher:
     class: Drupal\migrate_plus\DataFetcherPluginManager
     parent: default_plugin_manager
   plugin.manager.migrate_plus.data_parser:
     class: Drupal\migrate_plus\DataParserPluginManager
     parent: default_plugin_manager
-  plugin.manager.config_entity_migration:
-    class: Drupal\migrate_plus\Plugin\MigrationConfigEntityPluginManager
-    parent: plugin.manager.migration
diff --git a/web/modules/migrate_plus/migrations/migration_config_deriver.yml b/web/modules/migrate_plus/migrations/migration_config_deriver.yml
new file mode 100644
index 0000000000..9fc597e7da
--- /dev/null
+++ b/web/modules/migrate_plus/migrations/migration_config_deriver.yml
@@ -0,0 +1,6 @@
+id: migration_config_deriver
+deriver: Drupal\migrate_plus\Plugin\MigrationConfigDeriver
+# @todo: Remove if/when https://www.drupal.org/node/2797421 is fixed.
+# Unused source configuration must be added to prevent errors.
+source:
+  plugin: embedded_data
diff --git a/web/modules/migrate_plus/phpcs.xml b/web/modules/migrate_plus/phpcs.xml
new file mode 100644
index 0000000000..a18054efbc
--- /dev/null
+++ b/web/modules/migrate_plus/phpcs.xml
@@ -0,0 +1,207 @@
+<?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/Annotation/Authentication.php b/web/modules/migrate_plus/src/Annotation/Authentication.php
new file mode 100644
index 0000000000..5b63e1af84
--- /dev/null
+++ b/web/modules/migrate_plus/src/Annotation/Authentication.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Drupal\migrate_plus\Annotation;
+
+use Drupal\Component\Annotation\Plugin;
+
+/**
+ * Defines an authentication annotation object.
+ *
+ * Plugin namespace: Plugin\migrate_plus\authentication.
+ *
+ * @see \Drupal\migrate_plus\AuthenticationPluginBase
+ * @see \Drupal\migrate_plus\AuthenticationPluginInterface
+ * @see \Drupal\migrate_plus\AuthenticationPluginManager
+ * @see plugin_api
+ *
+ * @Annotation
+ */
+class Authentication extends Plugin {
+
+  /**
+   * The plugin ID.
+   *
+   * @var string
+   */
+  public $id;
+
+  /**
+   * The title of the plugin.
+   *
+   * @var \Drupal\Core\Annotation\Translation
+   *
+   * @ingroup plugin_translatable
+   */
+  public $title;
+
+}
diff --git a/web/modules/migrate_plus/src/Annotation/DataFetcher.php b/web/modules/migrate_plus/src/Annotation/DataFetcher.php
index 5dba1df8b8..5c6e24ccb9 100644
--- a/web/modules/migrate_plus/src/Annotation/DataFetcher.php
+++ b/web/modules/migrate_plus/src/Annotation/DataFetcher.php
@@ -7,7 +7,7 @@
 /**
  * Defines a data fetcher annotation object.
  *
- * Plugin Namespace: Plugin\migrate_plus\data_fetcher
+ * Plugin namespace: Plugin\migrate_plus\data_fetcher.
  *
  * @see \Drupal\migrate_plus\DataFetcherPluginBase
  * @see \Drupal\migrate_plus\DataFetcherPluginInterface
diff --git a/web/modules/migrate_plus/src/Annotation/DataParser.php b/web/modules/migrate_plus/src/Annotation/DataParser.php
index 6f9de0aecc..6bc01e24aa 100644
--- a/web/modules/migrate_plus/src/Annotation/DataParser.php
+++ b/web/modules/migrate_plus/src/Annotation/DataParser.php
@@ -7,7 +7,7 @@
 /**
  * Defines a data parser annotation object.
  *
- * Plugin Namespace: Plugin\migrate_plus\data_parser
+ * Plugin namespace: Plugin\migrate_plus\data_parser.
  *
  * @see \Drupal\migrate_plus\DataParserPluginBase
  * @see \Drupal\migrate_plus\DataParserPluginInterface
diff --git a/web/modules/migrate_plus/src/AuthenticationPluginBase.php b/web/modules/migrate_plus/src/AuthenticationPluginBase.php
new file mode 100644
index 0000000000..8833512acd
--- /dev/null
+++ b/web/modules/migrate_plus/src/AuthenticationPluginBase.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Drupal\migrate_plus;
+
+use Drupal\Core\Plugin\PluginBase;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Defines a base authentication implementation.
+ *
+ * @see \Drupal\migrate_plus\Annotation\Authentication
+ * @see \Drupal\migrate_plus\AuthenticationPluginInterface
+ * @see \Drupal\migrate_plus\AuthenticationPluginManager
+ * @see plugin_api
+ */
+abstract class AuthenticationPluginBase extends PluginBase implements AuthenticationPluginInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static($configuration, $plugin_id, $plugin_definition);
+  }
+
+}
diff --git a/web/modules/migrate_plus/src/AuthenticationPluginInterface.php b/web/modules/migrate_plus/src/AuthenticationPluginInterface.php
new file mode 100644
index 0000000000..5bdc60309f
--- /dev/null
+++ b/web/modules/migrate_plus/src/AuthenticationPluginInterface.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Drupal\migrate_plus;
+
+/**
+ * Defines an interface for authenticaion handlers.
+ *
+ * @see \Drupal\migrate_plus\Annotation\Authentication
+ * @see \Drupal\migrate_plus\AuthenticationPluginBase
+ * @see \Drupal\migrate_plus\AuthenticationPluginManager
+ * @see plugin_api
+ */
+interface AuthenticationPluginInterface {
+
+  /**
+   * Performs authentication, returning any options to be added to the request.
+   *
+   * @return array
+   *   Options (such as Authentication headers) to be added to the request.
+   *
+   * @link http://docs.guzzlephp.org/en/latest/request-options.html
+   */
+  public function getAuthenticationOptions();
+
+}
diff --git a/web/modules/migrate_plus/src/AuthenticationPluginManager.php b/web/modules/migrate_plus/src/AuthenticationPluginManager.php
new file mode 100644
index 0000000000..041094e4d7
--- /dev/null
+++ b/web/modules/migrate_plus/src/AuthenticationPluginManager.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Drupal\migrate_plus;
+
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Plugin\DefaultPluginManager;
+
+/**
+ * Provides a plugin manager for authentication handlers.
+ *
+ * @see \Drupal\migrate_plus\Annotation\DataFetcher
+ * @see \Drupal\migrate_plus\DataFetcherPluginBase
+ * @see \Drupal\migrate_plus\DataFetcherPluginInterface
+ * @see plugin_api
+ */
+class AuthenticationPluginManager extends DefaultPluginManager {
+
+  /**
+   * Constructs a new AuthenticationPluginManager.
+   *
+   * @param \Traversable $namespaces
+   *   An object that implements \Traversable which contains the root paths
+   *   keyed by the corresponding namespace to look for plugin implementations.
+   * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
+   *   Cache backend instance to use.
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler to invoke the alter hook with.
+   */
+  public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
+    parent::__construct('Plugin/migrate_plus/authentication', $namespaces, $module_handler, 'Drupal\migrate_plus\AuthenticationPluginInterface', 'Drupal\migrate_plus\Annotation\Authentication');
+
+    $this->alterInfo('authentication_info');
+    $this->setCacheBackend($cache_backend, 'migrate_plus_plugins_authentication');
+  }
+
+}
diff --git a/web/modules/migrate_plus/src/DataFetcherPluginInterface.php b/web/modules/migrate_plus/src/DataFetcherPluginInterface.php
index a64b47923c..f35042fcff 100644
--- a/web/modules/migrate_plus/src/DataFetcherPluginInterface.php
+++ b/web/modules/migrate_plus/src/DataFetcherPluginInterface.php
@@ -15,7 +15,7 @@ interface DataFetcherPluginInterface {
   /**
    * Set the client headers.
    *
-   * @param $headers
+   * @param array $headers
    *   An array of the headers to set on the HTTP request.
    */
   public function setRequestHeaders(array $headers);
@@ -28,7 +28,7 @@ public function getRequestHeaders();
   /**
    * Return content.
    *
-   * @param $url
+   * @param string $url
    *   URL to retrieve from.
    *
    * @return string
@@ -39,10 +39,11 @@ public function getResponseContent($url);
   /**
    * Return Http Response object for a given url.
    *
-   * @param $url
+   * @param string $url
    *   URL to retrieve from.
    *
    * @return \Psr\Http\Message\ResponseInterface
+   *   The HTTP response message.
    */
   public function getResponse($url);
 
diff --git a/web/modules/migrate_plus/src/DataFetcherPluginManager.php b/web/modules/migrate_plus/src/DataFetcherPluginManager.php
index 9e9e238e7a..941252ed71 100644
--- a/web/modules/migrate_plus/src/DataFetcherPluginManager.php
+++ b/web/modules/migrate_plus/src/DataFetcherPluginManager.php
@@ -21,7 +21,7 @@ class DataFetcherPluginManager extends DefaultPluginManager {
    *
    * @param \Traversable $namespaces
    *   An object that implements \Traversable which contains the root paths
-   *   keyed by the corresponding namespace to look for plugin implementations,
+   *   keyed by the corresponding namespace to look for plugin implementations.
    * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
    *   Cache backend instance to use.
    * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
diff --git a/web/modules/migrate_plus/src/DataParserPluginBase.php b/web/modules/migrate_plus/src/DataParserPluginBase.php
index d133ba386f..c74c6c53ac 100644
--- a/web/modules/migrate_plus/src/DataParserPluginBase.php
+++ b/web/modules/migrate_plus/src/DataParserPluginBase.php
@@ -73,7 +73,6 @@ public static function create(ContainerInterface $container, array $configuratio
     return new static($configuration, $plugin_id, $plugin_definition);
   }
 
-
   /**
    * Returns the initialized data fetcher plugin.
    *
@@ -111,8 +110,11 @@ public function next() {
     $this->fetchNextRow();
     // If there was no valid row there, try the next url (if any).
     if (is_null($this->currentItem)) {
-      if ($this->nextSource()) {
+      while ($this->nextSource()) {
         $this->fetchNextRow();
+        if ($this->valid()) {
+          break;
+        }
       }
     }
     if ($this->valid()) {
@@ -125,7 +127,7 @@ public function next() {
   /**
    * Opens the specified URL.
    *
-   * @param $url
+   * @param string $url
    *   URL to open.
    *
    * @return bool
@@ -134,8 +136,9 @@ public function next() {
   abstract protected function openSourceUrl($url);
 
   /**
-   * Retrieves the next row of data from the open source URL, populating
-   * currentItem.
+   * Retrieves the next row of data. populating currentItem.
+   *
+   * Retrieves from the open source URL.
    */
   abstract protected function fetchNextRow();
 
diff --git a/web/modules/migrate_plus/src/DataParserPluginManager.php b/web/modules/migrate_plus/src/DataParserPluginManager.php
index b69cc148d7..65d92b1b71 100644
--- a/web/modules/migrate_plus/src/DataParserPluginManager.php
+++ b/web/modules/migrate_plus/src/DataParserPluginManager.php
@@ -21,7 +21,7 @@ class DataParserPluginManager extends DefaultPluginManager {
    *
    * @param \Traversable $namespaces
    *   An object that implements \Traversable which contains the root paths
-   *   keyed by the corresponding namespace to look for plugin implementations,
+   *   keyed by the corresponding namespace to look for plugin implementations.
    * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
    *   Cache backend instance to use.
    * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
diff --git a/web/modules/migrate_plus/src/Event/MigrateEvents.php b/web/modules/migrate_plus/src/Event/MigrateEvents.php
index 90a2155b8f..6ce44ee62e 100644
--- a/web/modules/migrate_plus/src/Event/MigrateEvents.php
+++ b/web/modules/migrate_plus/src/Event/MigrateEvents.php
@@ -16,7 +16,8 @@ final class MigrateEvents {
    * has read the inital source data into a Row object. Typically, this would be
    * used to add data to the row, manipulate the data into a canonical form, or
    * signal by exception that the row should be skipped. The event listener
-   * method receives a \Drupal\migrate_plus\Event\MigratePrepareRowEvent instance.
+   * method receives a \Drupal\migrate_plus\Event\MigratePrepareRowEvent
+   * instance.
    *
    * @Event
    *
diff --git a/web/modules/migrate_plus/src/Event/MigratePrepareRowEvent.php b/web/modules/migrate_plus/src/Event/MigratePrepareRowEvent.php
index 1c2032b28e..4727f3ee37 100644
--- a/web/modules/migrate_plus/src/Event/MigratePrepareRowEvent.php
+++ b/web/modules/migrate_plus/src/Event/MigratePrepareRowEvent.php
@@ -64,7 +64,7 @@ public function getRow() {
   /**
    * Gets the source plugin.
    *
-   * @return \Drupal\migrate\Plugin\MigrateSourceInterface $source
+   * @return \Drupal\migrate\Plugin\MigrateSourceInterface
    *   The source plugin firing the event.
    */
   public function getSource() {
diff --git a/web/modules/migrate_plus/src/Plugin/Discovery/ConfigEntityDiscovery.php b/web/modules/migrate_plus/src/Plugin/Discovery/ConfigEntityDiscovery.php
deleted file mode 100644
index 551c8a7a3c..0000000000
--- a/web/modules/migrate_plus/src/Plugin/Discovery/ConfigEntityDiscovery.php
+++ /dev/null
@@ -1,49 +0,0 @@
-<?php
-
-namespace Drupal\migrate_plus\Plugin\Discovery;
-
-use Drupal\Component\Plugin\Discovery\DiscoveryInterface;
-use Drupal\Component\Plugin\Discovery\DiscoveryTrait;
-
-/**
- * Allows configuration entities to define plugin definitions.
- */
-class ConfigEntityDiscovery implements DiscoveryInterface {
-
-  use DiscoveryTrait;
-
-  /**
-   * Entity type to query.
-   *
-   * @var string
-   */
-  protected $entityType;
-
-  /**
-   * Construct a YamlDiscovery object.
-   *
-   * @param string $entity_type
-   *   The entity type to query for.
-   */
-  function __construct($entity_type) {
-    $this->entityType = $entity_type;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getDefinitions() {
-    $definition = \Drupal::entityTypeManager()->getDefinition($this->entityType);
-    $prefix = $definition->getConfigPrefix() . '.';
-    $storage = \Drupal::service('config.storage');
-    $query = \Drupal::entityQuery($this->entityType);
-    $ids = $query->execute();
-    $definitions = [];
-    foreach ($ids as $id) {
-      $definitions[$id] = $storage->read($prefix . $id);
-    }
-
-    return $definitions;
-  }
-
-}
diff --git a/web/modules/migrate_plus/src/Plugin/MigrationConfigDeriver.php b/web/modules/migrate_plus/src/Plugin/MigrationConfigDeriver.php
new file mode 100644
index 0000000000..0135444974
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/MigrationConfigDeriver.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin;
+
+use Drupal\Component\Plugin\Derivative\DeriverBase;
+use Drupal\migrate_plus\Entity\Migration;
+
+/**
+ * Expose migration entities in the active config store as derivative plugins.
+ */
+class MigrationConfigDeriver extends DeriverBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDerivativeDefinitions($base_plugin_definition) {
+    // Always rederive from scratch, because changes may have been made without
+    // clearing our internal cache.
+    $this->derivatives = [];
+    $migrations = Migration::loadMultiple();
+    /** @var \Drupal\migrate_plus\Entity\MigrationInterface $migration */
+    foreach ($migrations as $id => $migration) {
+      $this->derivatives[$id] = $migration->toArray();
+    }
+    return $this->derivatives;
+  }
+
+}
diff --git a/web/modules/migrate_plus/src/Plugin/MigrationConfigEntityPluginManager.php b/web/modules/migrate_plus/src/Plugin/MigrationConfigEntityPluginManager.php
deleted file mode 100644
index 25009758d0..0000000000
--- a/web/modules/migrate_plus/src/Plugin/MigrationConfigEntityPluginManager.php
+++ /dev/null
@@ -1,25 +0,0 @@
-<?php
-
-namespace Drupal\migrate_plus\Plugin;
-
-use Drupal\Core\Plugin\Discovery\ContainerDerivativeDiscoveryDecorator;
-use Drupal\migrate\Plugin\MigrationPluginManager;
-use Drupal\migrate_plus\Plugin\Discovery\ConfigEntityDiscovery;
-
-/**
- * Plugin manager for migration plugins.
- */
-class MigrationConfigEntityPluginManager extends MigrationPluginManager {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function getDiscovery() {
-    if (!isset($this->discovery)) {
-      $discovery = new ConfigEntityDiscovery('migration');
-      $this->discovery = new ContainerDerivativeDiscoveryDecorator($discovery);
-    }
-    return $this->discovery;
-  }
-
-}
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/destination/Table.php b/web/modules/migrate_plus/src/Plugin/migrate/destination/Table.php
new file mode 100644
index 0000000000..f04d9f66d7
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/migrate/destination/Table.php
@@ -0,0 +1,143 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin\migrate\destination;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Database\Database;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\migrate\MigrateException;
+use Drupal\migrate\MigrateSkipProcessException;
+use Drupal\migrate\Plugin\MigrationInterface;
+use Drupal\migrate\Plugin\migrate\destination\DestinationBase;
+use Drupal\migrate\Row;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides table destination plugin.
+ *
+ * Use this plugin for a table not registered with Drupal Schema API.
+ *
+ * @MigrateDestination(
+ *   id = "table"
+ * )
+ */
+class Table extends DestinationBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * 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;
+
+  /**
+   * The database connection.
+   *
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $dbConnection;
+
+  /**
+   * Constructs a new Table.
+   *
+   * @param array $configuration
+   *   A configuration array containing information about the plugin instance.
+   * @param string $plugin_id
+   *   The plugin_id for the plugin instance.
+   * @param mixed $plugin_definition
+   *   The plugin implementation definition.
+   * @param \Drupal\migrate\Plugin\MigrationInterface $migration
+   *   The migration.
+   * @param \Drupal\Core\Database\Connection $connection
+   *   The database connection.
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, MigrationInterface $migration, Connection $connection) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition, $migration);
+    $this->dbConnection = $connection;
+    $this->tableName = $configuration['table_name'];
+    $this->idFields = $configuration['id_fields'];
+    $this->fields = isset($configuration['fields']) ? $configuration['fields'] : [];
+    $this->supportsRollback = TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  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,
+      $plugin_definition,
+      $migration,
+      Database::getConnection('default', $db_key)
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getIds() {
+    if (empty($this->idFields)) {
+      throw new MigrateException('Id fields are required for a table destination');
+    }
+    return $this->idFields;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function fields(MigrationInterface $migration = NULL) {
+    return $this->fields;
+  }
+
+  /**
+   * {@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.');
+    }
+
+    $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;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function rollback(array $destination_identifier) {
+    $delete = $this->dbConnection->delete($this->tableName);
+    foreach ($destination_identifier as $field => $value) {
+      $delete->condition($field, $value);
+    }
+    $delete->execute();
+  }
+
+}
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/process/ArrayPop.php b/web/modules/migrate_plus/src/Plugin/migrate/process/ArrayPop.php
new file mode 100644
index 0000000000..d4749caff3
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/ArrayPop.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin\migrate\process;
+
+use Drupal\migrate\MigrateException;
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\ProcessPluginBase;
+use Drupal\migrate\Row;
+
+/**
+ * Performs an array_pop() on a source array.
+ *
+ * @MigrateProcessPlugin(
+ *   id = "array_pop",
+ *   handle_multiples = TRUE
+ * )
+ *
+ * The "extract" plugin in core can extract array values when indexes are
+ * already known. This plugin helps extract the last value in an array by
+ * performing a "pop" operation.
+ *
+ * Example: Say, the migration source has an associative array of names in
+ * a property called "authors" and the keys in the array can vary, you
+ * can extract the last value like this:
+ *
+ * @code
+ *   last_author:
+ *     plugin: array_pop
+ *     source: authors
+ * @endcode
+ */
+class ArrayPop extends ProcessPluginBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
+    if (!is_array($value)) {
+      throw new MigrateException('Input should be an array.');
+    }
+    return array_pop($value);
+  }
+
+}
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/process/ArrayShift.php b/web/modules/migrate_plus/src/Plugin/migrate/process/ArrayShift.php
new file mode 100644
index 0000000000..02ef190b56
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/ArrayShift.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin\migrate\process;
+
+use Drupal\migrate\MigrateException;
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\ProcessPluginBase;
+use Drupal\migrate\Row;
+
+/**
+ * Performs an array_shift() on a source array.
+ *
+ * @MigrateProcessPlugin(
+ *   id = "array_shift",
+ *   handle_multiples = TRUE
+ * )
+ *
+ * The "extract" plugin in core can extract array values when indexes are
+ * already known. This plugin helps extract the first value in an array by
+ * performing a "shift" operation.
+ *
+ * Example: Say, the migration source has an associative array of names in
+ * a property called "authors" and the keys in the array can vary, you
+ * can extract the first value like this:
+ *
+ * @code
+ *   first_author:
+ *     plugin: array_shift
+ *     source: authors
+ * @endcode
+ */
+class ArrayShift extends ProcessPluginBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
+    if (!is_array($value)) {
+      throw new MigrateException('Input should be an array.');
+    }
+    return array_shift($value);
+  }
+
+}
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 ff14736ae8..a749a6388c 100644
--- a/web/modules/migrate_plus/src/Plugin/migrate/process/EntityGenerate.php
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/EntityGenerate.php
@@ -1,17 +1,17 @@
 <?php
 
-/**
- * @file
- * Contains Drupal\migrate_plus\Plugin\migrate\process\EntityGenerate.
- */
-
 namespace Drupal\migrate_plus\Plugin\migrate\process;
 
+use Drupal\Core\Entity\EntityManagerInterface;
+use Drupal\Core\Entity\EntityReferenceSelection\SelectionPluginManagerInterface;
 use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\Plugin\MigratePluginManager;
+use Drupal\migrate\Plugin\MigrationInterface;
 use Drupal\migrate\Row;
+use Symfony\Component\DependencyInjection\ContainerInterface;
 
 /**
- * This plugin generates entity stubs.
+ * This plugin generates entities within the process plugin.
  *
  * @MigrateProcessPlugin(
  *   id = "entity_generate"
@@ -19,12 +19,12 @@
  *
  * @see EntityLookup
  *
- * All the configuration from the lookup plugin applies here. In it's most
+ * All the configuration from the lookup plugin applies here. In its most
  * simple form, this plugin needs no configuration. If there are fields on the
- * stub entity that are required or need some default value, that can be
- * provided via a default_values configuration option.
+ * generated entity that are required or need some value, their values can be
+ * provided via values and/or default_values configuration options.
  *
- * Example usage with default_values configuration:
+ * Example usage with values and default_values configuration:
  * @code
  * destination:
  *   plugin: 'entity:node'
@@ -32,21 +32,87 @@
  *   type:
  *     plugin: default_value
  *     default_value: page
+ *   foo: bar
  *   field_tags:
  *     plugin: entity_generate
  *     source: tags
  *     default_values:
- *       description: Stub description
- *       field_long_description: Stub long description
+ *       description: Default description
+ *     values:
+ *       field_long_description: some_source_field
+ *       field_foo: '@foo'
  * @endcode
  */
 class EntityGenerate extends EntityLookup {
 
+  /**
+   * The row from the source to process.
+   *
+   * @var \Drupal\migrate\Row
+   */
+  protected $row;
+
+  /**
+   * The MigrateExecutable instance.
+   *
+   * @var \Drupal\migrate\MigrateExecutable
+   */
+  protected $migrateExecutable;
+
+  /**
+   * The get process plugin instance.
+   *
+   * @var \Drupal\migrate\Plugin\migrate\process\Get
+   */
+  protected $getProcessPlugin;
+
+  /**
+   * EntityGenerate constructor.
+   *
+   * @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.
+   */
+  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']]);
+    }
+  }
+
+  /**
+   * {@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')
+    );
+  }
+
   /**
    * {@inheritdoc}
    */
   public function transform($value, MigrateExecutableInterface $migrateExecutable, Row $row, $destinationProperty) {
-    // Creates a stub entity if one doesn't exist.
+    $this->row = $row;
+    $this->migrateExecutable = $migrateExecutable;
+    // Creates an entity if the lookup determines it doesn't exist.
     if (!($result = parent::transform($value, $migrateExecutable, $row, $destinationProperty))) {
       $result = $this->generateEntity($value);
     }
@@ -55,19 +121,19 @@ public function transform($value, MigrateExecutableInterface $migrateExecutable,
   }
 
   /**
-   * Generates stub entity for a given value.
+   * Generates an entity for a given value.
    *
    * @param string $value
-   *   Value to use in creation of stub entity.
+   *   Value to use in creation of the entity.
    *
    * @return int|string
    *   The entity id of the generated entity.
    */
   protected function generateEntity($value) {
-    if(!empty($value)) {
+    if (!empty($value)) {
       $entity = $this->entityManager
         ->getStorage($this->lookupEntityType)
-        ->create($this->stub($value));
+        ->create($this->entity($value));
       $entity->save();
 
       return $entity->id();
@@ -75,32 +141,39 @@ protected function generateEntity($value) {
   }
 
   /**
-   * Fabricate a stub entity.
+   * Fabricate an entity.
    *
    * This is intended to be extended by implementing classes to provide for more
    * dynamic default values, rather than just static ones.
    *
-   * @param $value
-   *   Value to use in creation of stub entity.
+   * @param mixed $value
+   *   Primary value to use in creation of the entity.
    *
    * @return array
-   *   The stub entity.
+   *   Entity value array.
    */
-  protected function stub($value) {
-    $stub = [$this->lookupValueKey => $value];
+  protected function entity($value) {
+    $entity_values = [$this->lookupValueKey => $value];
 
     if ($this->lookupBundleKey) {
-      $stub[$this->lookupBundleKey] = $this->lookupBundle;
+      $entity_values[$this->lookupBundleKey] = $this->lookupBundle;
     }
 
     // Gather any static default values for properties/fields.
     if (isset($this->configuration['default_values']) && is_array($this->configuration['default_values'])) {
       foreach ($this->configuration['default_values'] as $key => $value) {
-        $stub[$key] = $value;
+        $entity_values[$key] = $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;
       }
     }
 
-    return $stub;
+    return $entity_values;
   }
 
 }
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 674f5bfac2..2ae0d07c1d 100644
--- a/web/modules/migrate_plus/src/Plugin/migrate/process/EntityLookup.php
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/EntityLookup.php
@@ -1,12 +1,8 @@
 <?php
 
-/**
- * @file
- * Contains Drupal\migrate_plus\Plugin\migrate\process\EntityGenerate.
- */
-
 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;
@@ -56,34 +52,74 @@
  */
 class EntityLookup extends ProcessPluginBase implements ContainerFactoryPluginInterface {
 
-  /** @var \Drupal\Core\Entity\EntityManagerInterface */
+  /**
+   * The entity manager.
+   *
+   * @var \Drupal\Core\Entity\EntityManagerInterface
+   */
   protected $entityManager;
 
-  /** @var \Drupal\migrate\Plugin\MigrationInterface */
+  /**
+   * The migration.
+   *
+   * @var \Drupal\migrate\Plugin\MigrationInterface
+   */
   protected $migration;
 
-  /** @var \Drupal\Core\Entity\EntityReferenceSelection\SelectionPluginManagerInterface */
+  /**
+   * The selection plugin.
+   *
+   * @var \Drupal\Core\Entity\EntityReferenceSelection\SelectionPluginManagerInterface
+   */
   protected $selectionPluginManager;
 
-  /** @var string */
+  /**
+   * The destination type.
+   *
+   * @var string
+   */
   protected $destinationEntityType;
 
-  /** @var string|bool */
+  /**
+   * The destination bundle.
+   *
+   * @var string|bool
+   */
   protected $destinationBundleKey;
 
-  /** @var string */
+  /**
+   * The lookup value's key.
+   *
+   * @var string
+   */
   protected $lookupValueKey;
 
-  /** @var string */
+  /**
+   * The lookup bundle's key.
+   *
+   * @var string
+   */
   protected $lookupBundleKey;
 
-  /** @var string */
+  /**
+   * The lookup bundle.
+   *
+   * @var string
+   */
   protected $lookupBundle;
 
-  /** @var string */
+  /**
+   * The lookup entity type.
+   *
+   * @var string
+   */
   protected $lookupEntityType;
 
-  /** @var string */
+  /**
+   * The destination property or field.
+   *
+   * @var string
+   */
   protected $destinationProperty;
 
   /**
@@ -117,9 +153,18 @@ public static function create(ContainerInterface $container, array $configuratio
    * {@inheritdoc}
    */
   public function transform($value, MigrateExecutableInterface $migrateExecutable, Row $row, $destinationProperty) {
+    // If the source data is an empty array, return the same.
+    if (gettype($value) === 'array' && count($value) === 0) {
+      return [];
+    }
+
+    // In case of subfields ('field_reference/target_id'), extract the field
+    // name only.
+    $parts = explode('/', $destinationProperty);
+    $destinationProperty = reset($parts);
     $this->determineLookupProperties($destinationProperty);
 
-    $this->destinationProperty = $this->configuration['destination_field'];
+    $this->destinationProperty = isset($this->configuration['destination_field']) ? $this->configuration['destination_field'] : NULL;
 
     return $this->query($value);
   }
@@ -146,31 +191,41 @@ protected function determineLookupProperties($destinationProperty) {
     }
 
     if (empty($this->lookupValueKey) || empty($this->lookupBundleKey) || empty($this->lookupBundle) || empty($this->lookupEntityType)) {
-      // See if we can introspect the lookup properties from the destination field.
+      // 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);
-        if ($fieldConfig->getType() != 'entity_reference') {
-          throw new MigrateException('The entity_lookup plugin found no entity reference field.');
-        }
-
-        if (empty($this->lookupBundle)) {
-          $handlerSettings = $fieldConfig->getSetting('handler_settings');
-          $bundles = array_filter((array) $handlerSettings['target_bundles']);
-          if (count($bundles) == 1) {
-            $this->lookupBundle = reset($bundles);
-          }
-          // This was added in 8.1.x is not supported in 8.0.x.
-          elseif (!empty($handlerSettings['auto_create']) && !empty($handlerSettings['auto_create_bundle'])) {
-            $this->lookupBundle = reset($handlerSettings['auto_create_bundle']);
-          }
+        switch ($fieldConfig->getType()) {
+          case 'entity_reference':
+            if (empty($this->lookupBundle)) {
+              $handlerSettings = $fieldConfig->getSetting('handler_settings');
+              $bundles = array_filter((array) $handlerSettings['target_bundles']);
+              if (count($bundles) == 1) {
+                $this->lookupBundle = reset($bundles);
+              }
+              // This was added in 8.1.x is not supported in 8.0.x.
+              elseif (!empty($handlerSettings['auto_create']) && !empty($handlerSettings['auto_create_bundle'])) {
+                $this->lookupBundle = reset($handlerSettings['auto_create_bundle']);
+              }
+            }
+
+            // 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');
+            break;
+
+          case 'file':
+          case 'image':
+            $this->lookupEntityType = 'file';
+            $this->lookupValueKey = $this->lookupValueKey ?: 'uri';
+            break;
+
+          default:
+            throw new MigrateException('Destination field type ' .
+              $fieldConfig->getType() . 'is not a recognized reference type.');
         }
-
-        // 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');
       }
     }
 
@@ -189,8 +244,8 @@ protected function determineLookupProperties($destinationProperty) {
   /**
    * Checks for the existence of some value.
    *
-   * @param $value
-   * The value to query.
+   * @param mixed $value
+   *   The value to query.
    *
    * @return mixed|null
    *   Entity id if the queried entity exists. Otherwise NULL.
@@ -217,25 +272,25 @@ protected function query($value) {
       return NULL;
     }
 
-    if ($multiple && !empty($this->destinationProperty)) {
-      array_walk($results, function (&$value) {
-        $value = [$this->destinationProperty => $value];
-      });
-
-      return array_values($results);
-    }
-
     // By default do a case-sensitive comparison.
     if (!$ignoreCase) {
       // Returns the entity's identifier.
-      foreach ($results as $identifier) {
-        if ($value === $this->entityManager->getStorage($this->lookupEntityType)->load($identifier)->{$this->lookupValueKey}->value) {
-          return $identifier;
+      foreach ($results as $k => $identifier) {
+        $entity = $this->entityManager->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]);
         }
       }
     }
 
-    return reset($results);
+    if ($multiple && !empty($this->destinationProperty)) {
+      array_walk($results, function (&$value) {
+        $value = [$this->destinationProperty => $value];
+      });
+    }
+
+    return $multiple ? array_values($results) : reset($results);
   }
 
 }
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/process/FileBlob.php b/web/modules/migrate_plus/src/Plugin/migrate/process/FileBlob.php
new file mode 100644
index 0000000000..22341b6bc6
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/FileBlob.php
@@ -0,0 +1,155 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin\migrate\process;
+
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\MigrateSkipProcessException;
+use Drupal\migrate\ProcessPluginBase;
+use Drupal\migrate\Row;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Copy a file from a blob into a file.
+ *
+ * @MigrateProcessPlugin(
+ *   id = "file_blob"
+ * )
+ */
+class FileBlob extends ProcessPluginBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * The file system service.
+   *
+   * @var \Drupal\Core\File\FileSystemInterface
+   */
+  protected $fileSystem;
+
+  /**
+   * Constructs a file_blob process plugin.
+   *
+   * @param array $configuration
+   *   The plugin configuration.
+   * @param string $plugin_id
+   *   The plugin ID.
+   * @param mixed $plugin_definition
+   *   The plugin definition.
+   * @param \Drupal\Core\File\FileSystemInterface $file_system
+   *   The file system service.
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, FileSystemInterface $file_system) {
+    $configuration += [
+      'reuse' => FALSE,
+    ];
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->fileSystem = $file_system;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $configuration,
+      $plugin_id,
+      $plugin_definition,
+      $container->get('file_system')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
+    // If we're stubbing a file entity, return a URI of NULL so it will get
+    // stubbed by the general process.
+    if ($row->isStub()) {
+      return NULL;
+    }
+    list($destination, $blob) = $value;
+
+    // Determine if we going to overwrite existing files or not touch them.
+    $replace = $this->getOverwriteMode();
+
+    // Attempt to save the file to avoid calling file_prepare_directory() any
+    // more than absolutely necessary.
+    if ($this->putFile($destination, $blob, $replace)) {
+      return $destination;
+    }
+    $dir = $this->getDirectory($destination);
+    if (!file_prepare_directory($dir, FILE_CREATE_DIRECTORY)) {
+      throw new MigrateSkipProcessException("Could not create directory '$dir'");
+    }
+    if ($this->putFile($destination, $blob, $replace)) {
+      return $destination;
+    }
+    throw new MigrateSkipProcessException("Blob data could not be copied to $destination.");
+  }
+
+  /**
+   * Try to save the file.
+   *
+   * @param string $destination
+   *   The destination path or URI.
+   * @param string $blob
+   *   The base64 encoded file contents.
+   * @param int $replace
+   *   (optional) FILE_EXISTS_REPLACE (default) or FILE_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)) {
+      if (file_put_contents($path, $blob)) {
+        return $path;
+      }
+      else {
+        return FALSE;
+      }
+    }
+
+    // File was already copied.
+    return $destination;
+  }
+
+  /**
+   * Determines how to handle file conflicts.
+   *
+   * @return int
+   *   Either FILE_EXISTS_REPLACE (default) or FILE_EXISTS_ERROR, depending on
+   *   the configuration.
+   */
+  protected function getOverwriteMode() {
+    if (!empty($this->configuration['reuse'])) {
+      return FILE_EXISTS_ERROR;
+    }
+
+    return FILE_EXISTS_REPLACE;
+  }
+
+  /**
+   * Returns the directory component of a URI or path.
+   *
+   * For URIs like public://foo.txt, the full physical path of public://
+   * will be returned, since a scheme by itself will trip up certain file
+   * API functions (such as file_prepare_directory()).
+   *
+   * @param string $uri
+   *   The URI or path.
+   *
+   * @return string|false
+   *   The directory component of the path or URI, or FALSE if it could not
+   *   be determined.
+   */
+  protected function getDirectory($uri) {
+    $dir = $this->fileSystem->dirname($uri);
+    if (substr($dir, -3) == '://') {
+      return $this->fileSystem->realpath($dir);
+    }
+    return $dir;
+  }
+
+}
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/process/Merge.php b/web/modules/migrate_plus/src/Plugin/migrate/process/Merge.php
new file mode 100644
index 0000000000..463595e9d7
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/Merge.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin\migrate\process;
+
+use Drupal\migrate\ProcessPluginBase;
+use Drupal\migrate\MigrateException;
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\Row;
+
+/**
+ * This plugin merges arrays together.
+ *
+ * @MigrateProcessPlugin(
+ *   id = "merge"
+ * )
+ *
+ * Use to merge several fields into one. In the following example, imagine a D7
+ * node with a field_collections field and an image field that migrations were
+ * written for to make paragraph entities in D8. We would like to add those
+ * paragraph entities to the 'paragraphs_field'. Consider the following:
+ *
+ *  source:
+ *    plugin: d7_node
+ *  process:
+ *    temp_body:
+ *      plugin: iterator
+ *      source: field_section
+ *      process:
+ *        target_id:
+ *          plugin: migration_lookup
+ *          migration: field_collection_field_section_to_paragraph
+ *          source: value
+ *    temp_images:
+ *      plugin: iterator
+ *      source: field_image
+ *      process
+ *        target_id:
+ *          plugin: migration_lookup
+ *          migration: image_entities_to_paragraph
+ *          source: fid
+ *    paragraphs_field:
+ *      plugin: merge
+ *      source:
+ *        - '@temp_body'
+ *        - '@temp_images'
+ *  destination:
+ *    plugin: 'entity:node'
+ */
+class Merge extends ProcessPluginBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
+    if (!is_array($value)) {
+      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) {
+      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);
+    }
+    return $new_value;
+  }
+
+}
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/process/MultipleValues.php b/web/modules/migrate_plus/src/Plugin/migrate/process/MultipleValues.php
new file mode 100644
index 0000000000..be6779a124
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/MultipleValues.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin\migrate\process;
+
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\ProcessPluginBase;
+use Drupal\migrate\Row;
+
+/**
+ * Treat an array of values as a separate / individual values.
+ *
+ * @code
+ * process:
+ *   field_authors:
+ *     -
+ *       plugin: explode
+ *       delimiter: ', '
+ *       source: authors
+ *     -
+ *       plugin: single_value
+ *     -
+ *       plugin: callback
+ *       callable: custom_sort_authors
+ *     -
+ *       plugin: multiple_values
+ * @endcode
+ *
+ * Assume the "authors" field contains comma separated author names.
+ *
+ * We split the names into multiple values and then use the "single_value"
+ * plugin to treat them as a single array of author names. After that, we
+ * pass the values through a custom sort. Callback multiple setting is false. To
+ * convert from a single value to multiple, use the "multiple_values" plugin. It
+ * will make the next plugin treat the values individually instead of an array
+ * of values.
+ *
+ * @MigrateProcessPlugin(
+ *   id = "multiple_values",
+ *   handle_multiples = TRUE
+ * )
+ */
+class MultipleValues extends ProcessPluginBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
+    return $value;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function multiple() {
+    return TRUE;
+  }
+
+}
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/process/SingleValue.php b/web/modules/migrate_plus/src/Plugin/migrate/process/SingleValue.php
new file mode 100644
index 0000000000..2583a9026c
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/SingleValue.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin\migrate\process;
+
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\ProcessPluginBase;
+use Drupal\migrate\Row;
+
+/**
+ * Treat an array of values as a single value.
+ *
+ * @code
+ * process:
+ *   field_authors:
+ *     -
+ *       plugin: explode
+ *       delimiter: ', '
+ *       source: authors
+ *     -
+ *       plugin: single_value
+ * @endcode
+ *
+ * Assume the "authors" field contains comma separated author names.
+ *
+ * After the explode, we end up with each author name as an individual value.
+ * But if we want to perform a sort on all values using a callback, we will
+ * need to send all the values to a callable together as an array of author
+ * names. Calling the "single_value" plugin in such a case will combine all the
+ * values into a single array for the next plugin.
+ *
+ * @MigrateProcessPlugin(
+ *   id = "single_value",
+ *   handle_multiples = TRUE
+ * )
+ */
+class SingleValue extends ProcessPluginBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
+    return $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
new file mode 100644
index 0000000000..1a0c8f545c
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/SkipOnValue.php
@@ -0,0 +1,135 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin\migrate\process;
+
+use Drupal\migrate\MigrateException;
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\MigrateSkipProcessException;
+use Drupal\migrate\MigrateSkipRowException;
+use Drupal\migrate\ProcessPluginBase;
+use Drupal\migrate\Row;
+
+/**
+ * If the source evaluates to a configured value, skip processing or whole row.
+ *
+ * @MigrateProcessPlugin(
+ *   id = "skip_on_value"
+ * )
+ *
+ * Available configuration keys:
+ * - 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.
+ *
+ * Examples:
+ *
+ * Example usage with minimal configuration:
+ * @code
+ *   type:
+ *     plugin: skip_on_value
+ *     source: content_type
+ *     method: row
+ *     value: blog
+ * @endcode
+ *
+ * The above example will skip processing the input property if the content_type
+ * source field equals "blog".
+ *
+ * Example usage with full configuration:
+ * @code
+ *   type:
+ *     plugin: skip_on_value
+ *     not_equals: true
+ *     source: content_type
+ *     method: row
+ *     value:
+ *       - 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".
+ */
+class SkipOnValue extends ProcessPluginBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function row($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
+    if (empty($this->configuration['value']) && !array_key_exists('value', $this->configuration)) {
+      throw new MigrateException('Skip on value plugin is missing value configuration.');
+    }
+
+    if (is_array($this->configuration['value'])) {
+      $value_in_array = FALSE;
+      $not_equals = isset($this->configuration['not_equals']);
+
+      foreach ($this->configuration['value'] as $skipValue) {
+        $value_in_array |= $this->compareValue($value, $skipValue);
+      }
+
+      if (($not_equals && !$value_in_array) || (!$not_equals && $value_in_array)) {
+        throw new MigrateSkipRowException();
+      }
+    }
+    elseif ($this->compareValue($value, $this->configuration['value'], !isset($this->configuration['not_equals']))) {
+      throw new MigrateSkipRowException();
+    }
+
+    return $value;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function process($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
+    if (empty($this->configuration['value']) && !array_key_exists('value', $this->configuration)) {
+      throw new MigrateException('Skip on value plugin is missing value configuration.');
+    }
+
+    if (is_array($this->configuration['value'])) {
+      $value_in_array = FALSE;
+      $not_equals = isset($this->configuration['not_equals']);
+
+      foreach ($this->configuration['value'] as $skipValue) {
+        $value_in_array |= $this->compareValue($value, $skipValue);
+      }
+
+      if (($not_equals && !$value_in_array) || (!$not_equals && $value_in_array)) {
+        throw new MigrateSkipProcessException();
+      }
+    }
+    elseif ($this->compareValue($value, $this->configuration['value'], !isset($this->configuration['not_equals']))) {
+      throw new MigrateSkipProcessException();
+    }
+
+    return $value;
+  }
+
+  /**
+   * Compare values to see if they are equal.
+   *
+   * @param mixed $value
+   *   Actual value.
+   * @param mixed $skipValue
+   *   Value to compare against.
+   * @param bool $equal
+   *   Compare as equal or not equal.
+   *
+   * @return bool
+   *   True if the compare successfully, FALSE otherwise.
+   */
+  protected function compareValue($value, $skipValue, $equal = TRUE) {
+    if ($equal) {
+      return (string) $value == (string) $skipValue;
+    }
+
+    return (string) $value != (string) $skipValue;
+
+  }
+
+}
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/process/StrReplace.php b/web/modules/migrate_plus/src/Plugin/migrate/process/StrReplace.php
new file mode 100644
index 0000000000..2b033bebc3
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/StrReplace.php
@@ -0,0 +1,104 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin\migrate\process;
+
+use Drupal\migrate\MigrateException;
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\ProcessPluginBase;
+use Drupal\migrate\Row;
+
+/**
+ * Uses the str_replace() method on a source string.
+ *
+ * @MigrateProcessPlugin(
+ *   id = "str_replace"
+ * )
+ *
+ * To do a simple hardcoded string replace use the following:
+ *
+ * @code
+ * field_text:
+ *   plugin: str_replace
+ *   source: text
+ *   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".
+ *
+ * Case insensitive searches can be achieved using the following:
+ * @code
+ * field_text:
+ *   plugin: str_replace
+ *   case_insensitive: true
+ *   source: text
+ *   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".
+ *
+ * Also regular expressions can be matched using:
+ * @code
+ * field_text:
+ *   plugin: str_replace
+ *   regex: true
+ *   source: text
+ *   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".
+ *
+ * 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.
+ */
+class StrReplace extends ProcessPluginBase {
+
+  /**
+   * Flag indicating whether there are multiple values.
+   *
+   * @var bool
+   */
+  protected $multiple;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
+    if (!isset($this->configuration['search'])) {
+      throw new MigrateException('"search" must be configured.');
+    }
+    if (!isset($this->configuration['replace'])) {
+      throw new MigrateException('"replace" must be configured.');
+    }
+    $this->multiple = is_array($value);
+    $this->configuration += [
+      'case_insensitive' => FALSE,
+      'regex' => FALSE,
+    ];
+    $function = "str_replace";
+    if ($this->configuration['case_insensitive']) {
+      $function = 'str_ireplace';
+    }
+    if ($this->configuration['regex']) {
+      $function = 'preg_replace';
+    }
+    return $function($this->configuration['search'], $this->configuration['replace'], $value);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function multiple() {
+    return $this->multiple;
+  }
+
+}
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/process/Transliteration.php b/web/modules/migrate_plus/src/Plugin/migrate/process/Transliteration.php
new file mode 100644
index 0000000000..217869e205
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/migrate/process/Transliteration.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin\migrate\process;
+
+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\Row;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Transliterates text from Unicode to US-ASCII.
+ *
+ * The transliteration process plugin takes the source value and runs it through
+ * the transliteration service. Letters will have language decorations and
+ * accents removed.
+ *
+ * Example:
+ *
+ * @code
+ * process:
+ *   bar:
+ *     plugin: transliteration
+ *     source: foo
+ * @endcode
+ *
+ * If the value of foo in the source is 'áéí!' then the destination value of
+ * bar will be 'aei!'.
+ *
+ * @see \Drupal\migrate\Plugin\MigrateProcessInterface
+ *
+ * @MigrateProcessPlugin(
+ *   id = "transliteration"
+ * )
+ */
+class Transliteration extends ProcessPluginBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * The transliteration service.
+   *
+   * @var \Drupal\Component\Transliteration\TransliterationInterface
+   */
+  protected $transliteration;
+
+  /**
+   * Constructs a Transliteration plugin.
+   *
+   * @param array $configuration
+   *   The plugin configuration.
+   * @param string $plugin_id
+   *   The plugin ID.
+   * @param mixed $plugin_definition
+   *   The plugin definition.
+   * @param \Drupal\Component\Transliteration\TransliterationInterface $transliteration
+   *   The transliteration service.
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, TransliterationInterface $transliteration) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->transliteration = $transliteration;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $configuration,
+      $plugin_id,
+      $plugin_definition,
+      $container->get('transliteration')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
+    return $this->transliteration->transliterate($value, LanguageInterface::LANGCODE_DEFAULT, '_');
+  }
+
+}
diff --git a/web/modules/migrate_plus/src/Plugin/migrate/source/Url.php b/web/modules/migrate_plus/src/Plugin/migrate/source/Url.php
index 92f6d449db..242223b618 100644
--- a/web/modules/migrate_plus/src/Plugin/migrate/source/Url.php
+++ b/web/modules/migrate_plus/src/Plugin/migrate/source/Url.php
@@ -3,7 +3,6 @@
 namespace Drupal\migrate_plus\Plugin\migrate\source;
 
 use Drupal\migrate\Plugin\MigrationInterface;
-use Drupal\migrate_plus\DataParserPluginInterface;
 
 /**
  * Source plugin for retrieving data via URLs.
@@ -38,15 +37,6 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition
     parent::__construct($configuration, $plugin_id, $plugin_definition, $migration);
 
     $this->sourceUrls = $configuration['urls'];
-
-    // Set a default Accept header.
-/*    $this->headers = array_merge(['Accept' => 'application/json'],
-      $configuration['headers'] ?: []);*/
-
-    // See if this is a paged response with next links. If so, add to the source_urls array.
-/*    foreach ( (array) $configuration['urls'] as $url) {
-      $this->sourceUrls += $this->getNextLinks($url);
-    }*/
   }
 
   /**
@@ -85,55 +75,4 @@ protected function initializeIterator() {
     return $this->getDataParserPlugin();
   }
 
-  /**
-   * Collect an array of next links from a paged response.
-   */
-/*  protected function getNextLinks($url) {
-    $urls = array();
-    $more = TRUE;
-    while ($more == TRUE) {
-      $response = $this->dataParserPlugin->getDataFetcher()->getResponse($url);
-      if ($url = $this->getNextFromHeaders($response)) {
-        $urls[] = $url;
-      }
-      elseif ($url = $this->getNextFromLinks($response)) {
-        $urls[] = $url;
-      }
-      else {
-        $more = FALSE;
-      }
-    }
-    return $urls;
-  }
-*/
-  /**
-   * See if the next link is in a 'links' group in the response.
-   *
-   * @param \Psr\Http\Message\ResponseInterface $response
-   */
-/*  protected function getNextFromLinks(ResponseInterface $response) {
-    $body = json_decode($response->getBody(), TRUE);
-    if (!empty($body['links']) && array_key_exists('next', $body['links'])) {
-      return $body['links']['next'];
-    }
-    return FALSE;
-  }
-*/
-  /**
-   * See if the next link is in the header.
-   *
-   * @param \Psr\Http\Message\ResponseInterface $response
-   */
-/*  protected function getNextFromHeaders(ResponseInterface $response) {
-    $headers = $response->getHeader('Link');
-    foreach ($headers as $header) {
-      $matches = array();
-      preg_match('/^<(.*)>; rel="next"$/', $header, $matches);
-      if (!empty($matches) && !empty($matches[1])) {
-        return $matches[1];
-      }
-    }
-    return FALSE;
-  }
-*/
 }
diff --git a/web/modules/migrate_plus/src/Plugin/migrate_plus/authentication/Basic.php b/web/modules/migrate_plus/src/Plugin/migrate_plus/authentication/Basic.php
new file mode 100644
index 0000000000..f1ca69ab6c
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/migrate_plus/authentication/Basic.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin\migrate_plus\authentication;
+
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\migrate_plus\AuthenticationPluginBase;
+
+/**
+ * Provides basic authentication for the HTTP resource.
+ *
+ * @Authentication(
+ *   id = "basic",
+ *   title = @Translation("Basic")
+ * )
+ */
+class Basic extends AuthenticationPluginBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getAuthenticationOptions() {
+    return [
+      'auth' => [
+        $this->configuration['username'],
+        $this->configuration['password'],
+      ],
+    ];
+  }
+
+}
diff --git a/web/modules/migrate_plus/src/Plugin/migrate_plus/authentication/Digest.php b/web/modules/migrate_plus/src/Plugin/migrate_plus/authentication/Digest.php
new file mode 100644
index 0000000000..2466534306
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/migrate_plus/authentication/Digest.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin\migrate_plus\authentication;
+
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\migrate_plus\AuthenticationPluginBase;
+
+/**
+ * Provides digest authentication for the HTTP resource.
+ *
+ * @Authentication(
+ *   id = "digest",
+ *   title = @Translation("Digest")
+ * )
+ */
+class Digest extends AuthenticationPluginBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getAuthenticationOptions() {
+    return [
+      'auth' => [
+        $this->configuration['username'],
+        $this->configuration['password'],
+        'digest',
+      ],
+    ];
+  }
+
+}
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
new file mode 100644
index 0000000000..116314d6bc
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/migrate_plus/authentication/OAuth2.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin\migrate_plus\authentication;
+
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\migrate\MigrateException;
+use Drupal\migrate_plus\AuthenticationPluginBase;
+use GuzzleHttp\Client;
+use GuzzleHttp\HandlerStack;
+use Sainsburys\Guzzle\Oauth2\GrantType\AuthorizationCode;
+use Sainsburys\Guzzle\Oauth2\GrantType\ClientCredentials;
+use Sainsburys\Guzzle\Oauth2\GrantType\JwtBearer;
+use Sainsburys\Guzzle\Oauth2\GrantType\PasswordCredentials;
+use Sainsburys\Guzzle\Oauth2\GrantType\RefreshToken;
+use Sainsburys\Guzzle\Oauth2\Middleware\OAuthMiddleware;
+
+/**
+ * Provides OAuth2 authentication for the HTTP resource.
+ *
+ * @link https://packagist.org/packages/sainsburys/guzzle-oauth2-plugin
+ *
+ * @Authentication(
+ *   id = "oauth2",
+ *   title = @Translation("OAuth2")
+ * )
+ */
+class OAuth2 extends AuthenticationPluginBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getAuthenticationOptions() {
+    $handlerStack = HandlerStack::create();
+    $client = new Client([
+      'handler' => $handlerStack,
+      'base_uri' => $this->configuration['base_uri'],
+      'auth' => 'oauth2',
+    ]);
+
+    switch ($this->configuration['grant_type']) {
+      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);
+
+    return [
+      'headers' => [
+        'Authorization' => 'Bearer ' . $middleware->getAccessToken()->getToken(),
+      ],
+    ];
+  }
+
+}
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
new file mode 100644
index 0000000000..dfa02d3bc3
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/migrate_plus/data_fetcher/File.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin\migrate_plus\data_fetcher;
+
+use Drupal\migrate\MigrateException;
+use Drupal\migrate_plus\DataFetcherPluginBase;
+
+/**
+ * Retrieve data from a local path or general URL for migration.
+ *
+ * @DataFetcher(
+ *   id = "file",
+ *   title = @Translation("File")
+ * )
+ */
+class File extends DataFetcherPluginBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setRequestHeaders(array $headers) {
+    // Does nothing.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getRequestHeaders() {
+    // Does nothing.
+    return [];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getResponse($url) {
+    $response = file_get_contents($url);
+    if ($response === FALSE) {
+      throw new MigrateException('file parser plugin: could not retrieve data from ' . $url);
+    }
+    return $response;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getResponseContent($url) {
+    $response = $this->getResponse($url);
+    return $response;
+  }
+
+}
diff --git a/web/modules/migrate_plus/src/Plugin/migrate_plus/data_fetcher/Http.php b/web/modules/migrate_plus/src/Plugin/migrate_plus/data_fetcher/Http.php
index a9b72d235b..db4c423a69 100644
--- a/web/modules/migrate_plus/src/Plugin/migrate_plus/data_fetcher/Http.php
+++ b/web/modules/migrate_plus/src/Plugin/migrate_plus/data_fetcher/Http.php
@@ -10,6 +10,19 @@
 /**
  * Retrieve data over an HTTP connection for migration.
  *
+ * Example:
+ *
+ * @code
+ * source:
+ *   plugin: url
+ *   data_fetcher_plugin: http
+ *   headers:
+ *     Accept: application/json
+ *     User-Agent: Internet Explorer 6
+ *     Authorization-Key: secret
+ *     Arbitrary-Header: foobarbaz
+ * @endcode
+ *
  * @DataFetcher(
  *   id = "http",
  *   title = @Translation("HTTP")
@@ -18,7 +31,7 @@
 class Http extends DataFetcherPluginBase implements ContainerFactoryPluginInterface {
 
   /**
-   * The HTTP Client
+   * The HTTP client.
    *
    * @var \GuzzleHttp\Client
    */
@@ -31,12 +44,36 @@ class Http extends DataFetcherPluginBase implements ContainerFactoryPluginInterf
    */
   protected $headers = [];
 
+  /**
+   * The data retrieval client.
+   *
+   * @var \Drupal\migrate_plus\AuthenticationPluginInterface
+   */
+  protected $authenticationPlugin;
+
   /**
    * {@inheritdoc}
    */
   public function __construct(array $configuration, $plugin_id, $plugin_definition) {
     parent::__construct($configuration, $plugin_id, $plugin_definition);
     $this->httpClient = \Drupal::httpClient();
+
+    // Ensure there is a 'headers' key in the configuration.
+    $configuration += ['headers' => []];
+    $this->setRequestHeaders($configuration['headers']);
+  }
+
+  /**
+   * Returns the initialized authentication plugin.
+   *
+   * @return \Drupal\migrate_plus\AuthenticationPluginInterface
+   *   The authentication plugin.
+   */
+  public function getAuthenticationPlugin() {
+    if (!isset($this->authenticationPlugin)) {
+      $this->authenticationPlugin = \Drupal::service('plugin.manager.migrate_plus.authentication')->createInstance($this->configuration['authentication']['plugin'], $this->configuration['authentication']);
+    }
+    return $this->authenticationPlugin;
   }
 
   /**
@@ -50,7 +87,7 @@ public function setRequestHeaders(array $headers) {
    * {@inheritdoc}
    */
   public function getRequestHeaders() {
-    return !empty($this->headers) ? $this->headers : array();
+    return !empty($this->headers) ? $this->headers : [];
   }
 
   /**
@@ -58,17 +95,17 @@ public function getRequestHeaders() {
    */
   public function getResponse($url) {
     try {
-      $response = $this->httpClient->get($url, array(
-        'headers' => $this->getRequestHeaders(),
-        // Uncomment the following to debug the request.
-        //'debug' => true,
-      ));
+      $options = ['headers' => $this->getRequestHeaders()];
+      if (!empty($this->configuration['authentication'])) {
+        $options = array_merge($options, $this->getAuthenticationPlugin()->getAuthenticationOptions());
+      }
+      $response = $this->httpClient->get($url, $options);
       if (empty($response)) {
         throw new MigrateException('No response at ' . $url . '.');
       }
     }
     catch (RequestException $e) {
-      throw new MigrateException('Error message: ' . $e->getMessage() . ' at ' . $url .'.');
+      throw new MigrateException('Error message: ' . $e->getMessage() . ' at ' . $url . '.');
     }
     return $response;
   }
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 694bc99de8..c1220bc5f4 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
@@ -3,9 +3,7 @@
 namespace Drupal\migrate_plus\Plugin\migrate_plus\data_parser;
 
 use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
-use Drupal\migrate\MigrateException;
 use Drupal\migrate_plus\DataParserPluginBase;
-use GuzzleHttp\Exception\RequestException;
 
 /**
  * Obtain JSON data for migration.
@@ -17,13 +15,6 @@
  */
 class Json extends DataParserPluginBase implements ContainerFactoryPluginInterface {
 
-  /**
-   * The request headers passed to the data fetcher.
-   *
-   * @var array
-   */
-  protected $headers = [];
-
   /**
    * Iterator over the JSON data.
    *
@@ -32,22 +23,64 @@ class Json extends DataParserPluginBase implements ContainerFactoryPluginInterfa
   protected $iterator;
 
   /**
-   * {@inheritdoc}
+   * Retrieves the JSON data and returns it as an array.
+   *
+   * @param string $url
+   *   URL of a JSON feed.
+   *
+   * @return array
+   *   The selected data to be iterated.
+   *
+   * @throws \GuzzleHttp\Exception\RequestException
    */
-  public function __construct(array $configuration, $plugin_id, $plugin_definition) {
-    parent::__construct($configuration, $plugin_id, $plugin_definition);
+  protected function getSourceData($url) {
+    $response = $this->getDataFetcherPlugin()->getResponseContent($url);
+
+    // Convert objects to associative arrays.
+    $source_data = json_decode($response, TRUE);
+
+    // If json_decode() has returned NULL, it might be that the data isn't
+    // valid utf8 - see http://php.net/manual/en/function.json-decode.php#86997.
+    if (is_null($source_data)) {
+      $utf8response = utf8_encode($response);
+      $source_data = json_decode($utf8response, TRUE);
+    }
+
+    // Backwards-compatibility for depth selection.
+    if (is_int($this->itemSelector)) {
+      return $this->selectByDepth($source_data);
+    }
+
+    // Otherwise, we're using xpath-like selectors.
+    $selectors = explode('/', trim($this->itemSelector, '/'));
+    foreach ($selectors as $selector) {
+      if (!empty($selector)) {
+        $source_data = $source_data[$selector];
+      }
+    }
+    return $source_data;
   }
 
   /**
-   * {@inheritdoc}
+   * Get the source data for reading.
+   *
+   * @param array $raw_data
+   *   Raw data from the JSON feed.
+   *
+   * @return array
+   *   Selected items at the requested depth of the JSON feed.
    */
-  protected function getSourceData($url) {
-    $iterator = $this->getSourceIterator($url);
-
-    // Recurse through the result array. When there is an array of items at the
-    // expected depth, pull that array out as a distinct item.
-    $identifierDepth = $this->itemSelector;
+  protected function selectByDepth(array $raw_data) {
+    // Return the results in a recursive iterator that can traverse
+    // multidimensional arrays.
+    $iterator = new \RecursiveIteratorIterator(
+      new \RecursiveArrayIterator($raw_data),
+      \RecursiveIteratorIterator::SELF_FIRST);
     $items = [];
+    // Backwards-compatibility - an integer item_selector is interpreted as a
+    // depth. When there is an array of items at the expected depth, pull that
+    // array out as a distinct item.
+    $identifierDepth = $this->itemSelector;
     $iterator->rewind();
     while ($iterator->valid()) {
       $item = $iterator->current();
@@ -59,33 +92,6 @@ protected function getSourceData($url) {
     return $items;
   }
 
-  /**
-   * Get the source data for reading.
-   *
-   * @param string $url
-   *   The URL to read the source data from.
-   *
-   * @return \RecursiveIteratorIterator|resource
-   *
-   * @throws \Drupal\migrate\MigrateException
-   */
-  protected function getSourceIterator($url) {
-    try {
-      $response = $this->getDataFetcherPlugin()->getResponseContent($url);
-      // The TRUE setting means decode the response into an associative array.
-      $array = json_decode($response, TRUE);
-
-      // Return the results in a recursive iterator that
-      // can traverse multidimensional arrays.
-      return new \RecursiveIteratorIterator(
-        new \RecursiveArrayIterator($array),
-        \RecursiveIteratorIterator::SELF_FIRST);
-    }
-    catch (RequestException $e) {
-      throw new MigrateException($e->getMessage(), $e->getCode(), $e);
-    }
-  }
-
   /**
    * {@inheritdoc}
    */
@@ -103,7 +109,20 @@ protected function fetchNextRow() {
     $current = $this->iterator->current();
     if ($current) {
       foreach ($this->fieldSelectors() as $field_name => $selector) {
-        $this->currentItem[$field_name] = $current[$selector];
+        $field_data = $current;
+        $field_selectors = explode('/', trim($selector, '/'));
+        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 {
+            $field_data = '';
+          }
+        }
+        $this->currentItem[$field_name] = $field_data;
+      }
+      if (!empty($this->configuration['include_raw_data'])) {
+        $this->currentItem['raw'] = $current;
       }
       $this->iterator->next();
     }
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
new file mode 100644
index 0000000000..8a58d8b075
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/migrate_plus/data_parser/SimpleXml.php
@@ -0,0 +1,87 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin\migrate_plus\data_parser;
+
+use Drupal\migrate\MigrateException;
+use Drupal\migrate_plus\DataParserPluginBase;
+
+/**
+ * Obtain XML data for migration using the SimpleXML API.
+ *
+ * @DataParser(
+ *   id = "simple_xml",
+ *   title = @Translation("Simple XML")
+ * )
+ */
+class SimpleXml extends DataParserPluginBase {
+
+  use XmlTrait;
+
+  /**
+   * Array of matches from item_selector.
+   *
+   * @var array
+   */
+  protected $matches = [];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+
+    // Suppress errors during parsing, so we can pick them up after.
+    libxml_use_internal_errors(TRUE);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function openSourceUrl($url) {
+    // Clear XML error buffer. Other Drupal code that executed during the
+    // migration may have polluted the error buffer and could create false
+    // positives in our error check below. We are only concerned with errors
+    // that occur from attempting to load the XML string into an object here.
+    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);
+    foreach (libxml_get_errors() as $error) {
+      $error_string = self::parseLibXmlError($error);
+      throw new MigrateException($error_string);
+    }
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function fetchNextRow() {
+    $target_element = array_shift($this->matches);
+
+    // If we've found the desired element, populate the currentItem and
+    // currentId with its data.
+    if ($target_element !== FALSE && !is_null($target_element)) {
+      foreach ($this->fieldSelectors() as $field_name => $xpath) {
+        foreach ($target_element->xpath($xpath) as $value) {
+          if ($value->children() && !trim((string) $value)) {
+            $this->currentItem[$field_name] = $value;
+          }
+          else {
+            $this->currentItem[$field_name][] = (string) $value;
+          }
+        }
+      }
+      // Reduce single-value results to scalars.
+      foreach ($this->currentItem as $field_name => $values) {
+        if (is_array($values) && count($values) == 1) {
+          $this->currentItem[$field_name] = reset($values);
+        }
+      }
+    }
+  }
+
+}
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 60db902046..7f8c694e2f 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
@@ -3,6 +3,7 @@
 namespace Drupal\migrate_plus\Plugin\migrate_plus\data_parser;
 
 use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\migrate\Exception\RequirementsException;
 use Drupal\migrate\MigrateException;
 use Drupal\migrate_plus\DataParserPluginBase;
 
@@ -46,8 +47,14 @@ class Soap extends DataParserPluginBase implements ContainerFactoryPluginInterfa
 
   /**
    * {@inheritdoc}
+   *
+   * @throws \Drupal\migrate\Exception\RequirementsException
+   *   If PHP SOAP extension is not installed.
    */
   public function __construct(array $configuration, $plugin_id, $plugin_definition) {
+    if (!class_exists('\SoapClient')) {
+      throw new RequirementsException('The PHP SOAP extension is not installed');
+    }
     parent::__construct($configuration, $plugin_id, $plugin_definition);
     $this->function = $configuration['function'];
     $this->parameters = $configuration['parameters'];
@@ -63,7 +70,7 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition
    *   If we can't resolve the SOAP function or its response property.
    */
   protected function openSourceUrl($url) {
-    // Will throw SoapFault if there's
+    // Will throw SoapFault if there's an error in a SOAP call.
     $client = new \SoapClient($url);
     // Determine the response property name.
     $function_found = FALSE;
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 c27922a9c3..6c08069276 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
@@ -2,19 +2,20 @@
 
 namespace Drupal\migrate_plus\Plugin\migrate_plus\data_parser;
 
-use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
 use Drupal\migrate\MigrateException;
 use Drupal\migrate_plus\DataParserPluginBase;
 
 /**
- * Obtain XML data for migration.
+ * Obtain XML data for migration using the XMLReader pull parser.
  *
  * @DataParser(
  *   id = "xml",
  *   title = @Translation("XML")
  * )
  */
-class Xml extends DataParserPluginBase implements ContainerFactoryPluginInterface {
+class Xml extends DataParserPluginBase {
+
+  use XmlTrait;
 
   /**
    * The XMLReader we are encapsulating.
@@ -173,6 +174,13 @@ public function rewind() {
   protected function openSourceUrl($url) {
     // (Re)open the provided URL.
     $this->reader->close();
+
+    // Clear XML error buffer. Other Drupal code that executed during the
+    // migration may have polluted the error buffer and could create false
+    // positives in our error check below. We are only concerned with errors
+    // that occur from attempting to load the XML string into an object here.
+    libxml_clear_errors();
+
     return $this->reader->open($url, NULL, \LIBXML_NOWARNING);
   }
 
@@ -188,13 +196,13 @@ protected function fetchNextRow() {
       if ($this->reader->nodeType == \XMLReader::ELEMENT) {
         if ($this->prefixedName) {
           $this->currentPath[$this->reader->depth] = $this->reader->name;
-          if (array_key_exists($this->reader->name, $this->parentElementsOfInterest)) {
+          if (in_array($this->reader->name, $this->parentElementsOfInterest)) {
             $this->parentXpathCache[$this->reader->depth][$this->reader->name][] = $this->getSimpleXml();
           }
         }
         else {
           $this->currentPath[$this->reader->depth] = $this->reader->localName;
-          if (array_key_exists($this->reader->localName, $this->parentElementsOfInterest)) {
+          if (in_array($this->reader->localName, $this->parentElementsOfInterest)) {
             $this->parentXpathCache[$this->reader->depth][$this->reader->name][] = $this->getSimpleXml();
           }
         }
@@ -228,8 +236,25 @@ protected function fetchNextRow() {
     // currentId with its data.
     if ($target_element !== FALSE && !is_null($target_element)) {
       foreach ($this->fieldSelectors() as $field_name => $xpath) {
-        foreach ($target_element->xpath($xpath) as $value) {
-          $this->currentItem[$field_name][] = (string) $value;
+        $prefix = substr($xpath, 0, 3);
+        if (in_array($prefix, ['../', '..\\'])) {
+          $name = str_replace($prefix, '', $xpath);
+          $up = substr_count($xpath, $prefix);
+          $values = $this->getAncestorElements($up, $name);
+        }
+        else {
+          $values = $target_element->xpath($xpath);
+        }
+        foreach ($values as $value) {
+          // If the SimpleXMLElement doesn't render to a string of any sort,
+          // and has children then return the whole object for the process
+          // plugin or other row manipulation.
+          if ($value->children() && !trim((string) $value)) {
+            $this->currentItem[$field_name] = $value;
+          }
+          else {
+            $this->currentItem[$field_name][] = (string) $value;
+          }
         }
       }
       // Reduce single-value results to scalars.
@@ -293,59 +318,4 @@ public function getAncestorElements($levels_up, $name) {
     }
   }
 
-  /**
-   * Registers the iterator's namespaces to a SimpleXMLElement.
-   *
-   * @param \SimpleXMLElement $xml
-   *   The element to apply namespace registrations to.
-   */
-  protected function registerNamespaces(\SimpleXMLElement $xml) {
-    if (is_array($this->configuration['namespaces'])) {
-      foreach ($this->configuration['namespaces'] as $prefix => $ns) {
-        $xml->registerXPathNamespace($prefix, $ns);
-      }
-    }
-  }
-
-  /**
-   * Parses a LibXMLError to a error message string.
-   *
-   * @param \LibXMLError $error
-   *   Error thrown by the XML.
-   *
-   * @return string
-   *   Error message
-   */
-  public static function parseLibXmlError(\LibXMLError $error) {
-    $error_code_name = 'Unknown Error';
-    switch ($error->level) {
-      case LIBXML_ERR_WARNING:
-        $error_code_name = t('Warning');
-        break;
-
-      case LIBXML_ERR_ERROR:
-        $error_code_name = t('Error');
-        break;
-
-      case LIBXML_ERR_FATAL:
-        $error_code_name = t('Fatal Error');
-        break;
-    }
-
-    return t(
-      "@libxmlerrorcodename @libxmlerrorcode: @libxmlerrormessage\n" .
-      "Line: @libxmlerrorline\n" .
-      "Column: @libxmlerrorcolumn\n" .
-      "File: @libxmlerrorfile",
-      [
-        '@libxmlerrorcodename' => $error_code_name,
-        '@libxmlerrorcode' => $error->code,
-        '@libxmlerrormessage' => trim($error->message),
-        '@libxmlerrorline' => $error->line,
-        '@libxmlerrorcolumn' => $error->column,
-        '@libxmlerrorfile' => (($error->file)) ? $error->file : NULL,
-      ]
-    );
-  }
-
 }
diff --git a/web/modules/migrate_plus/src/Plugin/migrate_plus/data_parser/XmlTrait.php b/web/modules/migrate_plus/src/Plugin/migrate_plus/data_parser/XmlTrait.php
new file mode 100644
index 0000000000..28519ba516
--- /dev/null
+++ b/web/modules/migrate_plus/src/Plugin/migrate_plus/data_parser/XmlTrait.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace Drupal\migrate_plus\Plugin\migrate_plus\data_parser;
+
+/**
+ * Common functionality for XML data parsers.
+ */
+trait XmlTrait {
+
+  /**
+   * Registers the iterator's namespaces to a SimpleXMLElement.
+   *
+   * @param \SimpleXMLElement $xml
+   *   The element to apply namespace registrations to.
+   */
+  protected function registerNamespaces(\SimpleXMLElement $xml) {
+    if (isset($this->configuration['namespaces']) && is_array($this->configuration['namespaces'])) {
+      foreach ($this->configuration['namespaces'] as $prefix => $ns) {
+        $xml->registerXPathNamespace($prefix, $ns);
+      }
+    }
+  }
+
+  /**
+   * Parses a LibXMLError to a error message string.
+   *
+   * @param \LibXMLError $error
+   *   Error thrown by the XML.
+   *
+   * @return string
+   *   Error message
+   */
+  public static function parseLibXmlError(\LibXMLError $error) {
+    $error_code_name = 'Unknown Error';
+    switch ($error->level) {
+      case LIBXML_ERR_WARNING:
+        $error_code_name = t('Warning');
+        break;
+
+      case LIBXML_ERR_ERROR:
+        $error_code_name = t('Error');
+        break;
+
+      case LIBXML_ERR_FATAL:
+        $error_code_name = t('Fatal Error');
+        break;
+    }
+
+    return t(
+      "@libxmlerrorcodename @libxmlerrorcode: @libxmlerrormessage\nLine: @libxmlerrorline\nColumn: @libxmlerrorcolumn\nFile: @libxmlerrorfile",
+      [
+        '@libxmlerrorcodename' => $error_code_name,
+        '@libxmlerrorcode' => $error->code,
+        '@libxmlerrormessage' => trim($error->message),
+        '@libxmlerrorline' => $error->line,
+        '@libxmlerrorcolumn' => $error->column,
+        '@libxmlerrorfile' => (($error->file)) ? $error->file : NULL,
+      ]
+    );
+  }
+
+}
diff --git a/web/modules/migrate_plus/tests/data/missing_properties.json b/web/modules/migrate_plus/tests/data/missing_properties.json
new file mode 100644
index 0000000000..59b7d6a6ef
--- /dev/null
+++ b/web/modules/migrate_plus/tests/data/missing_properties.json
@@ -0,0 +1,21 @@
+[
+  {
+    "id": "1",
+    "title": "Title",
+    "video": {
+      "title": "Video title",
+      "url": "https://localhost/"
+    }
+  },
+  {
+    "id": "2",
+    "video": {
+      "title": "Video title",
+      "url": "https://localhost/"
+    }
+  },
+  {
+    "id": "3",
+    "title": "Title 3"
+  }
+]
diff --git a/web/modules/migrate_plus/tests/data/simple_xml_reduce_single_value.xml b/web/modules/migrate_plus/tests/data/simple_xml_reduce_single_value.xml
new file mode 100644
index 0000000000..0cffd28908
--- /dev/null
+++ b/web/modules/migrate_plus/tests/data/simple_xml_reduce_single_value.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/src/Functional/LoadTest.php b/web/modules/migrate_plus/tests/src/Functional/LoadTest.php
new file mode 100644
index 0000000000..c9aceda720
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Functional/LoadTest.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Functional;
+
+use Drupal\Core\Url;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Simple test to ensure that main page loads with module enabled.
+ *
+ * @group migrate_plus
+ */
+class LoadTest extends BrowserTestBase {
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = [
+    'migrate_plus',
+    'migrate_example',
+    'migrate_example_advanced',
+  ];
+
+  /**
+   * A user with permission to administer site configuration.
+   *
+   * @var \Drupal\user\UserInterface
+   */
+  protected $user;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->user = $this->drupalCreateUser(['administer site configuration']);
+    $this->drupalLogin($this->user);
+  }
+
+  /**
+   * Tests that the home page loads with a 200 response.
+   */
+  public function testLoad() {
+    $this->drupalGet(Url::fromRoute('<front>'));
+    $this->assertSession()->statusCodeEquals(200);
+  }
+
+}
diff --git a/web/modules/migrate_plus/tests/src/Kernel/MigrateTableTest.php b/web/modules/migrate_plus/tests/src/Kernel/MigrateTableTest.php
new file mode 100644
index 0000000000..dc471770d7
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Kernel/MigrateTableTest.php
@@ -0,0 +1,163 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Kernel;
+
+use Drupal\Core\Database\Database;
+use Drupal\migrate\MigrateExecutable;
+use Drupal\Tests\migrate\Kernel\MigrateTestBase;
+
+/**
+ * Tests migration destination table.
+ *
+ * @group migrate
+ */
+class MigrateTableTest extends MigrateTestBase {
+
+  const TABLE_NAME = 'migrate_test_destination_table';
+
+  /**
+   * The database connection.
+   *
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $connection;
+
+  public static $modules = ['migrate_plus'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    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,
+        ],
+      ],
+      'primary key' => ['data'],
+    ]);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function tearDown() {
+    $this->connection->schema()->dropTable(static::TABLE_NAME);
+    parent::tearDown();
+  }
+
+  /**
+   * Create a minimally valid migration with some source data.
+   *
+   * @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',
+          ],
+          [
+            'data' => 'dummy value2',
+            'data2' => 'dummy2 value2',
+            'data3' => 'dummy3 value2',
+          ],
+          [
+            'data' => 'dummy value3',
+            'data2' => 'dummy2 value3',
+            'data3' => 'dummy3 value3',
+          ],
+        ],
+        'ids' => [
+          'data' => ['type' => 'string'],
+        ],
+      ],
+      'destination' => [
+        'plugin' => 'table',
+        'table_name' => static::TABLE_NAME,
+        'id_fields' => ['data' => ['type' => 'string']],
+      ],
+      'process' => [
+        'data' => 'data',
+        'data2' => 'data2',
+        'data3' => 'data3',
+      ],
+    ];
+    return $definition;
+  }
+
+  /**
+   * Tests table destination.
+   */
+  public function testTableDestination() {
+    $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('dummy2 value', $values['dummy value']->data2);
+    $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)
+      ->execute()
+      ->fetchAllAssoc('data');
+
+    $this->assertEquals(0, count($values));
+  }
+
+}
diff --git a/web/modules/migrate_plus/src/Tests/MigrationConfigEntityTest.php b/web/modules/migrate_plus/tests/src/Kernel/MigrationConfigEntityTest.php
similarity index 50%
rename from web/modules/migrate_plus/src/Tests/MigrationConfigEntityTest.php
rename to web/modules/migrate_plus/tests/src/Kernel/MigrationConfigEntityTest.php
index 97573f3962..354da7628a 100644
--- a/web/modules/migrate_plus/src/Tests/MigrationConfigEntityTest.php
+++ b/web/modules/migrate_plus/tests/src/Kernel/MigrationConfigEntityTest.php
@@ -1,11 +1,9 @@
 <?php
 
-namespace Drupal\migrate_plus\Tests;
+namespace Drupal\Tests\migrate_plus\Kernel;
 
-use Drupal\Component\Plugin\Exception\PluginNotFoundException;
 use Drupal\KernelTests\KernelTestBase;
 use Drupal\migrate_plus\Entity\Migration;
-use Drupal\migrate_plus\Plugin\MigrationConfigEntityPluginManager;
 
 /**
  * Test migration config entity discovery.
@@ -17,15 +15,23 @@ class MigrationConfigEntityTest extends KernelTestBase {
   public static $modules = ['migrate', 'migrate_plus'];
 
   /**
-   * @var MigrationConfigEntityPluginManager
+   * The plugin manager.
+   *
+   * @var \Drupal\migrate\Plugin\MigrationPluginManager
    */
-  protected $pluginMananger;
+  protected $pluginManager;
 
+  /**
+   * {@inheritdoc}
+   */
   protected function setUp() {
     parent::setUp();
-    $this->pluginMananger = \Drupal::service('plugin.manager.config_entity_migration');
+    $this->pluginManager = \Drupal::service('plugin.manager.migration');
   }
 
+  /**
+   * Tests cache invalidation.
+   */
   public function testCacheInvalidation() {
     $config = Migration::create([
       'id' => 'test',
@@ -37,19 +43,18 @@ public function testCacheInvalidation() {
     ]);
     $config->save();
 
-    $this->assertTrue($this->pluginMananger->getDefinition('test'));
-    $this->assertSame('Label A', $this->pluginMananger->getDefinition('test')['label']);
+    $this->assertTrue($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
     // persistent cache.
-    $this->pluginMananger->useCaches(FALSE);
-    $this->pluginMananger->useCaches(TRUE);
+    $this->pluginManager->useCaches(FALSE);
+    $this->pluginManager->useCaches(TRUE);
 
     $config->set('label', 'Label B');
     $config->save();
 
-    $this->assertSame('Label B', $this->pluginMananger->getDefinition('test')['label']);
-    $this->assertSame('Label B', \Drupal::service('plugin.manager.migration')->getDefinition('test')['label']);
+    $this->assertSame('Label B', $this->pluginManager->getDefinition('test')['label']);
   }
 
 }
diff --git a/web/modules/migrate_plus/src/Tests/MigrationGroupTest.php b/web/modules/migrate_plus/tests/src/Kernel/MigrationGroupTest.php
similarity index 65%
rename from web/modules/migrate_plus/src/Tests/MigrationGroupTest.php
rename to web/modules/migrate_plus/tests/src/Kernel/MigrationGroupTest.php
index c84855bc78..53d3ad467d 100644
--- a/web/modules/migrate_plus/src/Tests/MigrationGroupTest.php
+++ b/web/modules/migrate_plus/tests/src/Kernel/MigrationGroupTest.php
@@ -1,11 +1,10 @@
 <?php
 
-namespace Drupal\migrate_plus\Tests;
+namespace Drupal\Tests\migrate_plus\Kernel;
 
 use Drupal\KernelTests\KernelTestBase;
 use Drupal\migrate_plus\Entity\Migration;
 use Drupal\migrate_plus\Entity\MigrationGroup;
-use Drupal\migrate_plus\Entity\MigrationGroupInterface;
 
 /**
  * Test migration groups.
@@ -22,18 +21,22 @@ class MigrationGroupTest extends KernelTestBase {
   public function testConfigurationMerge() {
     $group_id = 'test_group';
 
-    /** @var MigrationGroupInterface $migration_group */
+    /** @var \Drupal\migrate_plus\Entity\MigrationGroupInterface $migration_group */
     $group_configuration = [
       'id' => $group_id,
       'shared_configuration' => [
-        'migration_tags' => ['Drupal 6'], // In migration, so will be overridden.
+        // In migration, so will be overridden.
+        'migration_tags' => ['Drupal 6'],
         'source' => [
           'constants' => [
-            'type' => 'image',    // Not in migration, so will be added.
-            'cardinality' => '1', // In migration, so will be overridden.
+            // Not in migration, so will be added.
+            'type' => 'image',
+            // In migration, so will be overridden.
+            'cardinality' => '1',
           ],
         ],
-        'destination' => ['plugin' => 'field_storage_config'], // Not in migration, so will be added.
+        // Not in migration, so will be added.
+        'destination' => ['plugin' => 'field_storage_config'],
       ],
     ];
     $this->container->get('entity_type.manager')->getStorage('migration_group')
@@ -42,20 +45,25 @@ public function testConfigurationMerge() {
     /** @var \Drupal\migrate_plus\Entity\MigrationInterface $migration */
     $migration = $this->container->get('entity_type.manager')
       ->getStorage('migration')->create([
-      'id' => 'specific_migration',
-      'load' => [],
-      'migration_group' => $group_id,
-      'label' => 'Unaffected by the group',
-      'migration_tags' => ['Drupal 7'], // Overrides group.
-      'destination' => [],
-      'source' => [],
-      'migration_dependencies' => [],
-    ]);
+        'id' => 'specific_migration',
+        'load' => [],
+        'migration_group' => $group_id,
+        'label' => 'Unaffected by the group',
+          // Overrides group.
+        'migration_tags' => ['Drupal 7'],
+        'destination' => [],
+        'source' => [],
+        'process' => [],
+        'migration_dependencies' => [],
+      ]);
     $migration->set('source', [
-      'plugin' => 'empty',        // Not in group, persists.
+      // Not in group, persists.
+      'plugin' => 'empty',
       'constants' => [
-        'entity_type' => 'user',  // Not in group, persists.
-        'cardinality' => '3',     // Overrides group.
+        // Not in group, persists.
+        'entity_type' => 'user',
+        // Overrides group.
+        'cardinality' => '3',
       ],
     ]);
     $migration->save();
@@ -75,7 +83,7 @@ public function testConfigurationMerge() {
       'destination' => ['plugin' => 'field_storage_config'],
     ];
     /** @var \Drupal\migrate\Plugin\MigrationInterface $loaded_migration */
-    $loaded_migration = $this->container->get('plugin.manager.config_entity_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);
@@ -87,7 +95,7 @@ public function testConfigurationMerge() {
    * Test that deleting a group deletes its migrations.
    */
   public function testDelete() {
-    /** @var MigrationGroupInterface $migration_group */
+    /** @var \Drupal\migrate_plus\Entity\MigrationGroupInterface $migration_group */
     $group_configuration = [
       'id' => 'test_group',
     ];
@@ -98,14 +106,14 @@ public function testDelete() {
     /** @var \Drupal\migrate_plus\Entity\MigrationInterface $migration */
     $migration = $this->container->get('entity_type.manager')
       ->getStorage('migration')->create([
-      'id' => 'specific_migration',
-      'migration_group' => 'test_group',
-      'migration_tags' => [],
-      'load' => [],
-      'destination' => [],
-      'source' => [],
-      'migration_dependencies' => [],
-    ]);
+        'id' => 'specific_migration',
+        'migration_group' => 'test_group',
+        'migration_tags' => [],
+        'load' => [],
+        'destination' => [],
+        'source' => [],
+        'migration_dependencies' => [],
+      ]);
     $migration->save();
 
     /** @var \Drupal\migrate_plus\Entity\MigrationGroupInterface $loaded_migration_group */
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
new file mode 100644
index 0000000000..3d46496de4
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate/process/EntityGenerateTest.php
@@ -0,0 +1,788 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Kernel\Plugin\migrate\process;
+
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+use Drupal\Core\Language\LanguageInterface;
+use Drupal\field\Tests\EntityReference\EntityReferenceTestTrait;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\migrate\MigrateExecutable;
+use Drupal\migrate\MigrateMessageInterface;
+use Drupal\node\Entity\NodeType;
+use Drupal\taxonomy\Entity\Vocabulary;
+
+/**
+ * Tests the migration plugin.
+ *
+ * @coversDefaultClass \Drupal\migrate_plus\Plugin\migrate\process\EntityGenerate
+ * @group migrate_plus
+ */
+class EntityGenerateTest extends KernelTestBase implements MigrateMessageInterface {
+
+  use EntityReferenceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'migrate_plus',
+    'migrate',
+    'user',
+    'system',
+    'node',
+    'taxonomy',
+    'field',
+    'text',
+    'filter',
+  ];
+
+  /**
+   * The bundle used in this test.
+   *
+   * @var string
+   */
+  protected $bundle = 'page';
+
+  /**
+   * The name of the field used in this test.
+   *
+   * @var string
+   */
+  protected $fieldName = 'field_entity_reference';
+
+  /**
+   * The vocabulary id.
+   *
+   * @var string
+   */
+  protected $vocabulary = 'fruit';
+
+  /**
+   * The migration plugin manager.
+   *
+   * @var \Drupal\migrate\Plugin\MigrationPluginManager
+   */
+  protected $migrationPluginManager;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    // Create article content type.
+    $values = [
+      'type' => $this->bundle,
+      'name' => 'Page',
+    ];
+    $node_type = NodeType::create($values);
+    $node_type->save();
+
+    $this->installEntitySchema('node');
+    $this->installEntitySchema('taxonomy_term');
+    $this->installEntitySchema('taxonomy_vocabulary');
+    $this->installEntitySchema('user');
+    $this->installSchema('system', ['sequences']);
+    $this->installSchema('user', 'users_data');
+    $this->installConfig($this->modules);
+
+    // Create a vocabulary.
+    $vocabulary = Vocabulary::create([
+      'name' => $this->vocabulary,
+      'description' => $this->vocabulary,
+      'vid' => $this->vocabulary,
+      'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED,
+    ]);
+    $vocabulary->save();
+
+    // Create a field.
+    $this->createEntityReferenceField(
+      'node',
+      $this->bundle,
+      $this->fieldName,
+      'Term reference',
+      'taxonomy_term',
+      'default',
+      ['target_bundles' => [$this->vocabulary]],
+      FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED
+    );
+
+    $this->migrationPluginManager = \Drupal::service('plugin.manager.migration');
+  }
+
+  /**
+   * Tests generating an entity.
+   *
+   * @dataProvider transformDataProvider
+   *
+   * @covers ::transform
+   */
+  public function testTransform(array $definition, array $expected, array $preSeed = []) {
+    // Pre seed some test data.
+    foreach ($preSeed as $storageName => $values) {
+      // If the first element of $values is a non-empty array, create multiple
+      // entities. Otherwise, create just one entity.
+      if (isset($values[0])) {
+        foreach ($values as $itemValues) {
+          $this->createTestData($storageName, $itemValues);
+        }
+      }
+      else {
+        $this->createTestData($storageName, $values);
+      }
+    }
+
+    /** @var \Drupal\migrate\Plugin\Migration $migration */
+    $migration = $this->migrationPluginManager->createStubMigration($definition);
+    /** @var EntityStorageBase $storage */
+    $storage = $this->readAttribute($migration->getDestinationPlugin(), 'storage');
+    $migrationExecutable = (new MigrateExecutable($migration, $this));
+    $migrationExecutable->import();
+
+    foreach ($expected as $row) {
+      $entity = $storage->load($row['id']);
+      $properties = array_diff_key($row, array_flip(['id']));
+      foreach ($properties as $property => $value) {
+        if (is_array($value)) {
+          if (empty($value)) {
+            $this->assertEmpty($entity->{$property}->getValue(), "Expected value is 'unset' but field $property is set.");
+          }
+          else {
+            // Check if we're testing multiple values in one field. If so, loop
+            // through them one-by-one and check that they're present in the
+            // $entity.
+            if (isset($value[0])) {
+              foreach ($value as $valueID => $valueToCheck) {
+                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.");
+                    }
+                    else {
+                      $this->assertTrue($entity->{$property}->isEmpty(), "FOOBAR Expected value is empty but field $property is not empty.");
+                    }
+                  }
+                  elseif ($entity->{$property}->getValue()) {
+                    $this->assertEquals($expectedValue, $entity->{$property}[$valueID]->entity->$key->value);
+                  }
+                  else {
+                    $this->fail("Expected value: $expectedValue does not exist in $property.");
+                  }
+                }
+              }
+            }
+            // If we get to this point, we're only checking a
+            // single field value.
+            else {
+              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.");
+                  }
+                  else {
+                    $this->assertTrue($entity->{$property}->isEmpty(), "BINBAZ Expected value is empty but field $property is not empty.");
+                  }
+                }
+                elseif ($entity->{$property}->getValue()) {
+                  $referenced_entity = $entity->{$property}[0]->entity;
+                  $result_value = $referenced_entity instanceof ConfigEntityInterface ? $referenced_entity->get($key) : $referenced_entity->get($key)->value;
+                  $this->assertEquals($expectedValue, $result_value);
+                }
+                else {
+                  $this->fail("Expected value: $expectedValue does not exist in $property.");
+                }
+              }
+            }
+          }
+        }
+        else {
+          $this->assertNotEmpty($entity, 'Entity with label ' . $row[$property] . ' is empty');
+          $this->assertEquals($row[$property], $entity->label());
+        }
+      }
+    }
+  }
+
+  /**
+   * Provides multiple migration definitions for "transform" test.
+   */
+  public function transformDataProvider() {
+    return [
+      'no arguments' => [
+        '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'],
+            ],
+          ],
+          'process' => [
+            'id' => 'id',
+            'type' => [
+              'plugin' => 'default_value',
+              'default_value' => $this->bundle,
+            ],
+            'title' => 'title',
+            $this->fieldName => [
+              'plugin' => 'entity_generate',
+              'source' => 'term',
+            ],
+          ],
+          'destination' => [
+            'plugin' => 'entity:node',
+          ],
+        ],
+        'expected' => [
+          'row 1' => [
+            'id' => 1,
+            'title' => 'content item 1',
+            $this->fieldName => [
+              'tid' => 2,
+              'name' => 'Apples',
+            ],
+          ],
+          'row 2' => [
+            'id' => 2,
+            'title' => 'content item 2',
+            $this->fieldName => [
+              'tid' => 3,
+              'name' => 'Bananas',
+            ],
+          ],
+          'row 3' => [
+            'id' => 3,
+            'title' => 'content item 3',
+            $this->fieldName => [
+              'tid' => 1,
+              'name' => 'Grapes',
+            ],
+          ],
+        ],
+        'pre seed' => [
+          'taxonomy_term' => [
+            'name' => 'Grapes',
+            'vid' => $this->vocabulary,
+            'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED,
+          ],
+        ],
+      ],
+      'no arguments_lookup_only' => [
+        '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'],
+            ],
+          ],
+          'process' => [
+            'id' => 'id',
+            'type' => [
+              'plugin' => 'default_value',
+              'default_value' => $this->bundle,
+            ],
+            'title' => 'title',
+            $this->fieldName => [
+              'plugin' => 'entity_lookup',
+              'source' => 'term',
+            ],
+          ],
+          'destination' => [
+            'plugin' => 'entity:node',
+          ],
+        ],
+        'expected' => [
+          'row 1' => [
+            'id' => 1,
+            'title' => 'content item 1',
+            $this->fieldName => [
+              'tid' => NULL,
+              'name' => NULL,
+            ],
+          ],
+          'row 2' => [
+            'id' => 2,
+            'title' => 'content item 2',
+            $this->fieldName => [
+              'tid' => NULL,
+              'name' => NULL,
+            ],
+          ],
+          'row 3' => [
+            'id' => 3,
+            'title' => 'content item 3',
+            $this->fieldName => [
+              'tid' => 1,
+              'name' => 'Grapes',
+            ],
+          ],
+        ],
+        'pre seed' => [
+          'taxonomy_term' => [
+            'name' => 'Grapes',
+            'vid' => $this->vocabulary,
+            'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED,
+          ],
+        ],
+      ],
+      'provide 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'],
+            ],
+          ],
+          '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' => [
+                'description' => '@term_upper',
+              ],
+            ],
+          ],
+          'destination' => [
+            'plugin' => 'entity:node',
+          ],
+        ],
+        'expected' => [
+          'row 1' => [
+            'id' => 1,
+            'title' => 'content item 1',
+            $this->fieldName => [
+              'tid' => 2,
+              'name' => 'Apples',
+              'description' => 'APPLES',
+            ],
+          ],
+          'row 2' => [
+            'id' => 2,
+            'title' => 'content item 2',
+            $this->fieldName => [
+              'tid' => 3,
+              'name' => 'Bananas',
+              'description' => 'BANANAS',
+            ],
+          ],
+          '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' => [
+            'plugin' => 'embedded_data',
+            'data_rows' => [
+              [
+                'id' => 1,
+                'title' => 'content item 1',
+                'term' => 'Grapes',
+              ],
+            ],
+            'ids' => [
+              'id' => ['type' => 'integer'],
+            ],
+          ],
+          'process' => [
+            'id' => 'id',
+            'type' => [
+              'plugin' => 'default_value',
+              'default_value' => $this->bundle,
+            ],
+            'title' => 'title',
+            $this->fieldName => [
+              'plugin' => 'entity_lookup',
+              'source' => 'term',
+            ],
+          ],
+          'destination' => [
+            'plugin' => 'entity:node',
+          ],
+        ],
+        'expected' => [
+          'row 1' => [
+            'id' => 1,
+            'title' => 'content item 1',
+            $this->fieldName => [
+              'tid' => 1,
+              'name' => 'Grapes',
+            ],
+          ],
+        ],
+        'pre seed' => [
+          'taxonomy_term' => [
+            'name' => 'Grapes',
+            'vid' => $this->vocabulary,
+            'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED,
+          ],
+        ],
+      ],
+      'lookup single missing term returns null value' => [
+        'definition' => [
+          'source' => [
+            'plugin' => 'embedded_data',
+            'data_rows' => [
+              [
+                'id' => 1,
+                'title' => 'content item 1',
+                'term' => 'Apple',
+              ],
+            ],
+            'ids' => [
+              'id' => ['type' => 'integer'],
+            ],
+          ],
+          'process' => [
+            'id' => 'id',
+            'type' => [
+              'plugin' => 'default_value',
+              'default_value' => $this->bundle,
+            ],
+            'title' => 'title',
+            $this->fieldName => [
+              'plugin' => 'entity_lookup',
+              'source' => 'term',
+            ],
+          ],
+          'destination' => [
+            'plugin' => 'entity:node',
+          ],
+        ],
+        'expected' => [
+          'row 1' => [
+            'id' => 1,
+            'title' => 'content item 1',
+            $this->fieldName => [],
+          ],
+        ],
+        'pre seed' => [
+          'taxonomy_term' => [
+            'name' => 'Grapes',
+            'vid' => $this->vocabulary,
+            'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED,
+          ],
+        ],
+      ],
+      'lookup multiple existing terms returns correct terms' => [
+        'definition' => [
+          'source' => [
+            'plugin' => 'embedded_data',
+            'data_rows' => [
+              [
+                'id' => 1,
+                'title' => 'content item 1',
+                'term' => [
+                  'Grapes',
+                  'Apples',
+                ],
+              ],
+            ],
+            'ids' => [
+              'id' => ['type' => 'integer'],
+            ],
+          ],
+          'process' => [
+            'id' => 'id',
+            'title' => 'title',
+            'type' => [
+              'plugin' => 'default_value',
+              'default_value' => $this->bundle,
+            ],
+            $this->fieldName => [
+              'plugin' => 'entity_lookup',
+              'source' => 'term',
+            ],
+          ],
+          'destination' => [
+            'plugin' => 'entity:node',
+          ],
+        ],
+        'expected' => [
+          'row 1' => [
+            'id' => 1,
+            'title' => 'content item 1',
+            $this->fieldName => [
+              [
+                'tid' => 1,
+                'name' => 'Grapes',
+              ],
+              [
+                'tid' => 2,
+                'name' => 'Apples',
+              ],
+            ],
+          ],
+        ],
+        'pre seed' => [
+          'taxonomy_term' => [
+            [
+              'name' => 'Grapes',
+              'vid' => $this->vocabulary,
+              'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED,
+            ],
+            [
+              'name' => 'Apples',
+              'vid' => $this->vocabulary,
+              'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED,
+            ],
+          ],
+        ],
+      ],
+      'lookup multiple mixed terms returns correct terms' => [
+        'definition' => [
+          'source' => [
+            'plugin' => 'embedded_data',
+            'data_rows' => [
+              [
+                'id' => 1,
+                'title' => 'content item 1',
+                'term' => [
+                  'Grapes',
+                  'Pears',
+                ],
+              ],
+            ],
+            'ids' => [
+              'id' => ['type' => 'integer'],
+            ],
+          ],
+          'process' => [
+            'id' => 'id',
+            'title' => 'title',
+            'type' => [
+              'plugin' => 'default_value',
+              'default_value' => $this->bundle,
+            ],
+            $this->fieldName => [
+              'plugin' => 'entity_lookup',
+              'source' => 'term',
+            ],
+          ],
+          'destination' => [
+            'plugin' => 'entity:node',
+          ],
+        ],
+        'expected' => [
+          'row 1' => [
+            'id' => '1',
+            'title' => 'content item 1',
+            $this->fieldName => [
+              [
+                'tid' => 1,
+                'name' => 'Grapes',
+              ],
+            ],
+          ],
+        ],
+        'pre seed' => [
+          'taxonomy_term' => [
+            [
+              'name' => 'Grapes',
+              'vid' => $this->vocabulary,
+              'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED,
+            ],
+            [
+              'name' => 'Apples',
+              'vid' => $this->vocabulary,
+              'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED,
+            ],
+          ],
+        ],
+      ],
+      'lookup with empty term value returns no terms' => [
+        'definition' => [
+          'source' => [
+            'plugin' => 'embedded_data',
+            'data_rows' => [
+              [
+                'id' => 1,
+                'title' => 'content item 1',
+                'term' => [],
+              ],
+            ],
+            'ids' => [
+              'id' => ['type' => 'integer'],
+            ],
+          ],
+          'process' => [
+            'id' => 'id',
+            'title' => 'title',
+            'type' => [
+              'plugin' => 'default_value',
+              'default_value' => $this->bundle,
+            ],
+            $this->fieldName => [
+              'plugin' => 'entity_lookup',
+              'source' => 'term',
+            ],
+          ],
+          'destination' => [
+            'plugin' => 'entity:node',
+          ],
+        ],
+        'expected' => [
+          'row 1' => [
+            'id' => 1,
+            'title' => 'content item 1',
+            $this->fieldName => [],
+          ],
+        ],
+        'pre seed' => [
+          'taxonomy_term' => [
+            'name' => 'Grapes',
+            'vid' => $this->vocabulary,
+            'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED,
+          ],
+        ],
+      ],
+      'lookup config entity' => [
+        'definition' => [
+          'source' => [
+            'plugin' => 'embedded_data',
+            'data_rows' => [
+              [
+                'id' => 1,
+                'name' => 'user 1',
+                'mail' => 'user1@user1.com',
+                'roles' => ['role_1'],
+              ],
+            ],
+            'ids' => [
+              'id' => ['type' => 'integer'],
+            ],
+          ],
+          'process' => [
+            'id' => 'id',
+            'name' => 'name',
+            'roles' => [
+              'plugin' => 'entity_lookup',
+              'entity_type' => 'user_role',
+              'value_key' => 'id',
+              'source' => 'roles',
+            ],
+          ],
+          'destination' => [
+            'plugin' => 'entity:user',
+          ],
+        ],
+        'expected' => [
+          'row 1' => [
+            'id' => 1,
+            'name' => 'user 1',
+            'roles' => [
+              'id' => 'role_1',
+              'label' => 'Role 1',
+            ],
+          ],
+        ],
+        'pre seed' => [
+          'user_role' => [
+            'id' => 'role_1',
+            'label' => 'Role 1',
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function display($message, $type = 'status') {
+    $this->assertTrue($type == 'status', $message);
+  }
+
+  /**
+   * Create pre-seed test data.
+   *
+   * @param string $storageName
+   *   The storage manager to create.
+   * @param array $values
+   *   The values to use when creating the entity.
+   */
+  private function createTestData($storageName, array $values) {
+    /** @var \Drupal\Core\Entity\ContentEntityStorageInterface $storage */
+    $storage = $this->container
+      ->get('entity_type.manager')
+      ->getStorage($storageName);
+    $entity = $storage->create($values);
+    $entity->save();
+  }
+
+}
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
new file mode 100644
index 0000000000..76a5c18474
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate_plus/data_fetcher/HttpTest.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Kernel\Plugin\migrate_plus\data_fetcher;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\migrate_plus\Plugin\migrate_plus\data_fetcher\Http;
+
+/**
+ * Class HttpTest.
+ *
+ * @group migrate_plus
+ * @package Drupal\Tests\migrate_plus\Unit\migrate_plus\data_fetcher
+ */
+class HttpTest extends KernelTestBase {
+
+  /**
+   * Test http headers option.
+   *
+   * @dataProvider headerDataProvider
+   */
+  public function testHttpHeaders(array $definition, array $expected, array $preSeed = []) {
+    $http = new Http($definition, 'http', []);
+    $this->assertEquals($expected, $http->getRequestHeaders());
+  }
+
+  /**
+   * Provides multiple test cases for the testHttpHeaders method.
+   *
+   * @return array
+   *   The test cases
+   */
+  public function headerDataProvider() {
+    return [
+      'dummy headers specified' => [
+        'definition' => [
+          'headers' => [
+            'Accept' => 'application/json',
+            'User-Agent' => 'Internet Explorer 6',
+            'Authorization-Key' => 'secret',
+            'Arbitrary-Header' => 'foobarbaz',
+          ],
+        ],
+        'expected' => [
+          'Accept' => 'application/json',
+          'User-Agent' => 'Internet Explorer 6',
+          'Authorization-Key' => 'secret',
+          'Arbitrary-Header' => 'foobarbaz',
+        ],
+      ],
+      'no headers specified' => [
+        'definition' => [
+          'no_headers_here' => 'foo',
+        ],
+        'expected' => [],
+      ],
+    ];
+  }
+
+}
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
new file mode 100644
index 0000000000..2fb4f2458d
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate_plus/data_parser/JsonTest.php
@@ -0,0 +1,112 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Kernel\Plugin\migrate_plus\data_parser;
+
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * Test of the data_parser Json migrate_plus plugin.
+ *
+ * @group migrate_plus
+ */
+class JsonTest extends KernelTestBase {
+
+  public static $modules = ['migrate', 'migrate_plus'];
+
+  /**
+   * Tests missing properties in json file.
+   *
+   * @param string $file
+   *   File name in tests/data/ directory of this module.
+   * @param array $ids
+   *   Array of ids to pass to the plugin.
+   * @param array $fields
+   *   Array of fields to pass to the plugin.
+   * @param array $expected
+   *   Expected array from json decoded file.
+   *
+   * @dataProvider jsonBaseDataProvider
+   *
+   * @throws \Drupal\Component\Plugin\Exception\PluginException
+   * @throws \Exception
+   */
+  public function testMissingProperties($file, array $ids, array $fields, array $expected) {
+    $path = $this->container
+      ->get('module_handler')
+      ->getModule('migrate_plus')
+      ->getPath();
+    $url = $path . '/tests/data/' . $file;
+
+    /** @var \Drupal\migrate_plus\DataParserPluginManager $plugin_manager */
+    $plugin_manager = $this->container
+      ->get('plugin.manager.migrate_plus.data_parser');
+    $conf = [
+      'plugin' => 'url',
+      'data_fetcher_plugin' => 'file',
+      'data_parser_plugin' => 'json',
+      'destination' => 'node',
+      'urls' => [$url],
+      'ids' => $ids,
+      'fields' => $fields,
+      'item_selector' => NULL,
+    ];
+    $json_parser = $plugin_manager->createInstance('json', $conf);
+
+    $data = [];
+    foreach ($json_parser as $item) {
+      $data[] = $item;
+    }
+
+    $this->assertEquals($expected, $data);
+  }
+
+  /**
+   * Provides multiple test cases for the testMissingProperty method.
+   *
+   * @return array
+   *   The test cases.
+   */
+  public function jsonBaseDataProvider() {
+    return [
+      'missing properties' => [
+        'file' => 'missing_properties.json',
+        'ids' => ['id' => ['type' => 'integer']],
+        'fields' => [
+          [
+            'name' => 'id',
+            'label' => 'Id',
+            'selector' => '/id',
+          ],
+          [
+            'name' => 'title',
+            'label' => 'Title',
+            'selector' => '/title',
+          ],
+          [
+            'name' => 'video_url',
+            'label' => 'Video url',
+            'selector' => '/video/url',
+          ],
+        ],
+        'expected' => [
+          [
+            'id' => '1',
+            'title' => 'Title',
+            'video_url' => 'https://localhost/',
+          ],
+          [
+            'id' => '2',
+            'title' => '',
+            'video_url' => 'https://localhost/',
+          ],
+          [
+            'id' => '3',
+            'title' => 'Title 3',
+            'video_url' => '',
+          ],
+        ],
+      ],
+    ];
+  }
+
+}
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
new file mode 100644
index 0000000000..fe6b345e1d
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Kernel/Plugin/migrate_plus/data_parser/SimpleXmlTest.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Kernel\Plugin\migrate_plus\data_parser;
+
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * Test of the data_parser SimpleXml migrate_plus plugin.
+ *
+ * @group migrate_plus
+ */
+class SimpleXmlTest extends KernelTestBase {
+
+  public static $modules = ['migrate', 'migrate_plus'];
+
+  /**
+   * Tests reducing single values.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\PluginException
+   * @throws \Exception
+   */
+  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
+      ->get('plugin.manager.migrate_plus.data_parser');
+    $conf = [
+      'plugin' => 'url',
+      'data_fetcher_plugin' => 'file',
+      'data_parser_plugin' => 'simple_xml',
+      'destination' => 'node',
+      'urls' => [$url],
+      'ids' => ['id' => ['type' => 'integer']],
+      'fields' => [
+        [
+          'name' => 'id',
+          'label' => 'Id',
+          'selector' => '@id',
+        ],
+        [
+          'name' => 'values',
+          'label' => 'Values',
+          'selector' => 'values',
+        ],
+      ],
+      '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 = [
+      [
+        'Value 1',
+        'Value 2',
+      ],
+      [
+        'Value 1 (single)',
+      ],
+    ];
+
+    $this->assertEquals($expected, $data);
+  }
+
+}
diff --git a/web/modules/migrate_plus/tests/src/Unit/process/ArrayPopTest.php b/web/modules/migrate_plus/tests/src/Unit/process/ArrayPopTest.php
new file mode 100644
index 0000000000..84316d799a
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Unit/process/ArrayPopTest.php
@@ -0,0 +1,71 @@
+<?php
+
+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;
+
+/**
+ * Tests the array pop process plugin.
+ *
+ * @group migrate
+ * @coversDefaultClass \Drupal\migrate_plus\Plugin\migrate\process\ArrayPop
+ */
+class ArrayPopTest extends MigrateProcessTestCase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    $this->plugin = new ArrayPop([], 'array_pop', []);
+    parent::setUp();
+  }
+
+  /**
+   * Data provider for testArrayPop().
+   *
+   * @return array
+   *   An array containing input values and expected output values.
+   */
+  public function arrayPopDataProvider() {
+    return [
+      'indexed array' => [
+        'input' => ['v1', 'v2', 'v3'],
+        'expected_output' => 'v3',
+      ],
+      'associative array' => [
+        'input' => ['i1' => 'v1', 'i2' => 'v2', 'i3' => 'v3'],
+        'expected_output' => 'v3',
+      ],
+      'empty array' => [
+        'input' => [],
+        'expected_output' => NULL,
+      ],
+    ];
+  }
+
+  /**
+   * Test array pop plugin.
+   *
+   * @param array $input
+   *   The input values.
+   * @param mixed $expected_output
+   *   The expected output.
+   *
+   * @dataProvider arrayPopDataProvider
+   */
+  public function testArrayPop(array $input, $expected_output) {
+    $output = $this->plugin->transform($input, $this->migrateExecutable, $this->row, 'destinationproperty');
+    $this->assertSame($output, $expected_output);
+  }
+
+  /**
+   * Test invalid input.
+   */
+  public function testArrayPopFromString() {
+    $this->setExpectedException(MigrateException::class, '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
new file mode 100644
index 0000000000..6c6950fee1
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Unit/process/ArrayShiftTest.php
@@ -0,0 +1,71 @@
+<?php
+
+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;
+
+/**
+ * Tests the array shift process plugin.
+ *
+ * @group migrate
+ * @coversDefaultClass \Drupal\migrate_plus\Plugin\migrate\process\ArrayShift
+ */
+class ArrayShiftTest extends MigrateProcessTestCase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    $this->plugin = new ArrayShift([], 'array_shift', []);
+    parent::setUp();
+  }
+
+  /**
+   * Data provider for testArrayShift().
+   *
+   * @return array
+   *   An array containing input values and expected output values.
+   */
+  public function arrayShiftDataProvider() {
+    return [
+      'indexed array' => [
+        'input' => ['v1', 'v2', 'v3'],
+        'expected_output' => 'v1',
+      ],
+      'associative array' => [
+        'input' => ['i1' => 'v1', 'i2' => 'v2', 'i3' => 'v3'],
+        'expected_output' => 'v1',
+      ],
+      'empty array' => [
+        'input' => [],
+        'expected_output' => NULL,
+      ],
+    ];
+  }
+
+  /**
+   * Test array shift plugin.
+   *
+   * @param array $input
+   *   The input values.
+   * @param mixed $expected_output
+   *   The expected output.
+   *
+   * @dataProvider arrayShiftDataProvider
+   */
+  public function testArrayShift(array $input, $expected_output) {
+    $output = $this->plugin->transform($input, $this->migrateExecutable, $this->row, 'destinationproperty');
+    $this->assertSame($output, $expected_output);
+  }
+
+  /**
+   * Test invalid input.
+   */
+  public function testArrayShiftFromString() {
+    $this->setExpectedException(MigrateException::class, '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/MultipleValuesTest.php b/web/modules/migrate_plus/tests/src/Unit/process/MultipleValuesTest.php
new file mode 100644
index 0000000000..60a520c0df
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Unit/process/MultipleValuesTest.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Unit\process;
+
+use Drupal\Tests\migrate\Unit\process\MigrateProcessTestCase;
+use Drupal\migrate_plus\Plugin\migrate\process\MultipleValues;
+
+/**
+ * @coversDefaultClass \Drupal\migrate_plus\Plugin\migrate\process\MultipleValues
+ * @group migrate
+ */
+class MultipleValuesTest extends MigrateProcessTestCase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    $this->plugin = new MultipleValues([], 'multiple_values', []);
+    parent::setUp();
+  }
+
+  /**
+   * Test input treated as multiple value output.
+   */
+  public function testTreatAsMultiple() {
+    $value = ['v1', 'v2', 'v3'];
+    $output = $this->plugin->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
+    $this->assertSame($output, $value);
+    $this->assertTrue($this->plugin->multiple());
+  }
+
+}
diff --git a/web/modules/migrate_plus/tests/src/Unit/process/SingleValueTest.php b/web/modules/migrate_plus/tests/src/Unit/process/SingleValueTest.php
new file mode 100644
index 0000000000..203b1c5cb3
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Unit/process/SingleValueTest.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Unit\process;
+
+use Drupal\Tests\migrate\Unit\process\MigrateProcessTestCase;
+use Drupal\migrate_plus\Plugin\migrate\process\SingleValue;
+
+/**
+ * @coversDefaultClass \Drupal\migrate_plus\Plugin\migrate\process\SingleValue
+ * @group migrate
+ */
+class SingleValueTest extends MigrateProcessTestCase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    $this->plugin = new SingleValue([], 'single_value', []);
+    parent::setUp();
+  }
+
+  /**
+   * Test input treated as single value output.
+   */
+  public function testTreatAsSingle() {
+    $value = ['v1', 'v2', 'v3'];
+    $output = $this->plugin->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
+    $this->assertSame($output, $value);
+    $this->assertFalse($this->plugin->multiple());
+  }
+
+}
diff --git a/web/modules/migrate_plus/tests/src/Unit/process/SkipOnValueTest.php b/web/modules/migrate_plus/tests/src/Unit/process/SkipOnValueTest.php
new file mode 100644
index 0000000000..968e6f0fbc
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Unit/process/SkipOnValueTest.php
@@ -0,0 +1,145 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Unit\process;
+
+use Drupal\migrate\MigrateException;
+use Drupal\migrate\MigrateSkipProcessException;
+use Drupal\migrate\MigrateSkipRowException;
+use Drupal\migrate_plus\Plugin\migrate\process\SkipOnValue;
+use Drupal\Tests\migrate\Unit\process\MigrateProcessTestCase;
+
+/**
+ * Tests the skip on value process plugin.
+ *
+ * @group migrate
+ * @coversDefaultClass \Drupal\migrate_plus\Plugin\migrate\process\SkipOnValue
+ */
+class SkipOnValueTest extends MigrateProcessTestCase {
+
+  /**
+   * @covers ::process
+   */
+  public function testProcessSkipsOnValue() {
+    $configuration['method'] = 'process';
+    $configuration['value'] = 86;
+    $this->setExpectedException(MigrateSkipProcessException::class);
+    (new SkipOnValue($configuration, 'skip_on_value', []))
+      ->transform('86', $this->migrateExecutable, $this->row, 'destinationproperty');
+  }
+
+  /**
+   * @covers ::process
+   */
+  public function testProcessSkipsOnMultipleValue() {
+    $configuration['method'] = 'process';
+    $configuration['value'] = [1, 1, 2, 3, 5, 8];
+    $this->setExpectedException(MigrateSkipProcessException::class);
+    (new SkipOnValue($configuration, 'skip_on_value', []))
+      ->transform('5', $this->migrateExecutable, $this->row, 'destinationproperty');
+  }
+
+  /**
+   * @covers ::process
+   */
+  public function testProcessBypassesOnNonValue() {
+    $configuration['method'] = 'process';
+    $configuration['value'] = 'sourcevalue';
+    $configuration['not_equals'] = TRUE;
+    $value = (new SkipOnValue($configuration, 'skip_on_value', []))
+      ->transform('sourcevalue', $this->migrateExecutable, $this->row, 'destinationproperty');
+    $this->assertEquals($value, 'sourcevalue');
+    $configuration['value'] = 86;
+    $value = (new SkipOnValue($configuration, 'skip_on_value', []))
+      ->transform('86', $this->migrateExecutable, $this->row, 'destinationproperty');
+    $this->assertEquals($value, '86');
+  }
+
+  /**
+   * @covers ::process
+   */
+  public function testProcessSkipsOnMultipleNonValue() {
+    $configuration['method'] = 'process';
+    $configuration['value'] = [1, 1, 2, 3, 5, 8];
+    $value = (new SkipOnValue($configuration, 'skip_on_value', []))
+      ->transform(4, $this->migrateExecutable, $this->row, 'destinationproperty');
+    $this->assertEquals($value, '4');
+  }
+
+  /**
+   * @covers ::process
+   */
+  public function testProcessBypassesOnMultipleNonValue() {
+    $configuration['method'] = 'process';
+    $configuration['value'] = [1, 1, 2, 3, 5, 8];
+    $configuration['not_equals'] = TRUE;
+    $value = (new SkipOnValue($configuration, 'skip_on_value', []))
+      ->transform(5, $this->migrateExecutable, $this->row, 'destinationproperty');
+    $this->assertEquals($value, '5');
+    $value = (new SkipOnValue($configuration, 'skip_on_value', []))
+      ->transform(1, $this->migrateExecutable, $this->row, 'destinationproperty');
+    $this->assertEquals($value, '1');
+  }
+
+  /**
+   * @covers ::row
+   */
+  public function testRowBypassesOnMultipleNonValue() {
+    $configuration['method'] = 'row';
+    $configuration['value'] = [1, 1, 2, 3, 5, 8];
+    $configuration['not_equals'] = TRUE;
+    $value = (new SkipOnValue($configuration, 'skip_on_value', []))
+      ->transform(5, $this->migrateExecutable, $this->row, 'destinationproperty');
+    $this->assertEquals($value, '5');
+    $value = (new SkipOnValue($configuration, 'skip_on_value', []))
+      ->transform(1, $this->migrateExecutable, $this->row, 'destinationproperty');
+    $this->assertEquals($value, '1');
+  }
+
+  /**
+   * @covers ::row
+   */
+  public function testRowSkipsOnValue() {
+    $configuration['method'] = 'row';
+    $configuration['value'] = 86;
+    $this->setExpectedException(MigrateSkipRowException::class);
+    (new SkipOnValue($configuration, 'skip_on_value', []))
+      ->transform('86', $this->migrateExecutable, $this->row, 'destinationproperty');
+  }
+
+  /**
+   * @covers ::row
+   */
+  public function testRowBypassesOnNonValue() {
+    $configuration['method'] = 'row';
+    $configuration['value'] = 'sourcevalue';
+    $configuration['not_equals'] = TRUE;
+    $value = (new SkipOnValue($configuration, 'skip_on_value', []))
+      ->transform('sourcevalue', $this->migrateExecutable, $this->row, 'destinationproperty');
+    $this->assertEquals($value, 'sourcevalue');
+    $configuration['value'] = 86;
+    $value = (new SkipOnValue($configuration, 'skip_on_value', []))
+      ->transform('86', $this->migrateExecutable, $this->row, 'destinationproperty');
+    $this->assertEquals($value, 86);
+  }
+
+  /**
+   * @covers ::row
+   */
+  public function testRequiredRowConfiguration() {
+    $configuration['method'] = 'row';
+    $this->setExpectedException(MigrateException::class);
+    (new SkipOnValue($configuration, 'skip_on_value', []))
+      ->transform('sourcevalue', $this->migrateExecutable, $this->row, 'destinationproperty');
+  }
+
+  /**
+   * @covers ::process
+   */
+  public function testRequiredProcessConfiguration() {
+    $configuration['method'] = 'process';
+    $this->setExpectedException(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
new file mode 100644
index 0000000000..7194c711be
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Unit/process/StrReplaceTest.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Unit\process;
+
+use Drupal\migrate_plus\Plugin\migrate\process\StrReplace;
+use Drupal\Tests\migrate\Unit\process\MigrateProcessTestCase;
+
+/**
+ * Tests the str replace process plugin.
+ *
+ * @group migrate
+ * @coversDefaultClass \Drupal\migrate_plus\Plugin\migrate\process\StrReplace
+ */
+class StrReplaceTest extends MigrateProcessTestCase {
+
+  /**
+   * Test for a simple str_replace string.
+   */
+  public function testStrReplace() {
+    $value = 'vero eos et accusam et justo vero';
+    $configuration['search'] = 'et';
+    $configuration['replace'] = 'that';
+    $plugin = new StrReplace($configuration, 'str_replace', []);
+    $actual = $plugin->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
+    $this->assertSame('vero eos that accusam that justo vero', $actual);
+
+  }
+
+  /**
+   * Test for case insensitive searches.
+   */
+  public function testStrIreplace() {
+    $value = 'VERO eos et accusam et justo vero';
+    $configuration['search'] = 'vero';
+    $configuration['replace'] = 'that';
+    $configuration['case_insensitive'] = TRUE;
+    $plugin = new StrReplace($configuration, 'str_replace', []);
+    $actual = $plugin->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
+    $this->assertSame('that eos et accusam et justo that', $actual);
+
+  }
+
+  /**
+   * Test for regular expressions.
+   */
+  public function testPregReplace() {
+    $value = 'vero eos et 123 accusam et justo 123 duo';
+    $configuration['search'] = '/[0-9]{3}/';
+    $configuration['replace'] = 'the';
+    $configuration['regex'] = TRUE;
+    $plugin = new StrReplace($configuration, 'str_replace', []);
+    $actual = $plugin->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
+    $this->assertSame('vero eos et the accusam et justo the duo', $actual);
+  }
+
+  /**
+   * Test for MigrateException for "search" configuration.
+   */
+  public function testSearchMigrateException() {
+    $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.');
+    $plugin->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
+  }
+
+  /**
+   * Test for MigrateException for "replace" configuration.
+   */
+  public function testReplaceMigrateException() {
+    $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.');
+    $plugin->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
+  }
+
+  /**
+   * Test for multiple.
+   */
+  public function testIsMultiple() {
+    $value = [
+      'vero eos et accusam et justo vero',
+      'et eos vero accusam vero justo et',
+    ];
+
+    $expected = [
+      'vero eos that accusam that justo vero',
+      'that eos vero accusam vero justo that',
+    ];
+    $configuration['search'] = 'et';
+    $configuration['replace'] = 'that';
+    $plugin = new StrReplace($configuration, 'str_replace', []);
+    $actual = $plugin->transform($value, $this->migrateExecutable, $this->row, 'destinationproperty');
+    $this->assertArrayEquals($expected, $actual);
+
+    $this->assertTrue($plugin->multiple());
+  }
+
+}
diff --git a/web/modules/migrate_plus/tests/src/Unit/process/TransliterationTest.php b/web/modules/migrate_plus/tests/src/Unit/process/TransliterationTest.php
new file mode 100644
index 0000000000..dbfe288570
--- /dev/null
+++ b/web/modules/migrate_plus/tests/src/Unit/process/TransliterationTest.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Drupal\Tests\migrate_plus\Unit\process;
+
+use Drupal\Component\Transliteration\PhpTransliteration;
+use Drupal\migrate\MigrateExecutableInterface;
+use Drupal\migrate\Row;
+use Drupal\migrate_plus\Plugin\migrate\process\Transliteration;
+use Drupal\Tests\migrate\Unit\process\MigrateProcessTestCase;
+
+/**
+ * Tests the transliteration process plugin.
+ *
+ * @group migrate_plus
+ */
+class TransliterationTest extends MigrateProcessTestCase {
+
+  /**
+   * A transliteration instance.
+   *
+   * @var \Drupal\Component\Transliteration\TransliterationInterface
+   */
+  protected $transliteration;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    $this->transliteration = new PhpTransliteration();
+    $this->row = $this->getMockBuilder(Row::class)
+      ->disableOriginalConstructor()
+      ->getMock();
+    $this->migrateExecutable = $this->getMockBuilder(MigrateExecutableInterface::class)
+      ->disableOriginalConstructor()
+      ->getMock();
+    parent::setUp();
+  }
+
+  /**
+   * Tests transliteration transformation of non-alphanumeric characters.
+   */
+  public function testTransform() {
+    $actual = '9000004351_53494854_Spøgelsesjægerneáéö';
+    $expected_result = '9000004351_53494854_Spogelsesjaegerneaeo';
+
+    $plugin = new Transliteration([], 'transliteration', [], $this->transliteration);
+    $value = $plugin->transform($actual, $this->migrateExecutable, $this->row, 'destinationproperty');
+    $this->assertEquals($expected_result, $value);
+  }
+
+}
diff --git a/web/modules/migrate_tools/LICENSE.txt b/web/modules/migrate_tools/LICENSE.txt
new file mode 100644
index 0000000000..d159169d10
--- /dev/null
+++ b/web/modules/migrate_tools/LICENSE.txt
@@ -0,0 +1,339 @@
+                    GNU GENERAL PUBLIC LICENSE
+                       Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The licenses for most software are designed to take away your
+freedom to share and change it.  By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users.  This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it.  (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.)  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+  To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have.  You must make sure that they, too, receive or can get the
+source code.  And you must show them these terms so they know their
+rights.
+
+  We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+  Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software.  If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+  Finally, any free program is threatened constantly by software
+patents.  We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary.  To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                    GNU GENERAL PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License.  The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language.  (Hereinafter, translation is included without limitation in
+the term "modification".)  Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope.  The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+  1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+  2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+    a) You must cause the modified files to carry prominent notices
+    stating that you changed the files and the date of any change.
+
+    b) You must cause any work that you distribute or publish, that in
+    whole or in part contains or is derived from the Program or any
+    part thereof, to be licensed as a whole at no charge to all third
+    parties under the terms of this License.
+
+    c) If the modified program normally reads commands interactively
+    when run, you must cause it, when started running for such
+    interactive use in the most ordinary way, to print or display an
+    announcement including an appropriate copyright notice and a
+    notice that there is no warranty (or else, saying that you provide
+    a warranty) and that users may redistribute the program under
+    these conditions, and telling the user how to view a copy of this
+    License.  (Exception: if the Program itself is interactive but
+    does not normally print such an announcement, your work based on
+    the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole.  If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works.  But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+  3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+    a) Accompany it with the complete corresponding machine-readable
+    source code, which must be distributed under the terms of Sections
+    1 and 2 above on a medium customarily used for software interchange; or,
+
+    b) Accompany it with a written offer, valid for at least three
+    years, to give any third party, for a charge no more than your
+    cost of physically performing source distribution, a complete
+    machine-readable copy of the corresponding source code, to be
+    distributed under the terms of Sections 1 and 2 above on a medium
+    customarily used for software interchange; or,
+
+    c) Accompany it with the information you received as to the offer
+    to distribute corresponding source code.  (This alternative is
+    allowed only for noncommercial distribution and only if you
+    received the program in object code or executable form with such
+    an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it.  For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable.  However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+  4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License.  Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+  5. You are not required to accept this License, since you have not
+signed it.  However, nothing else grants you permission to modify or
+distribute the Program or its derivative works.  These actions are
+prohibited by law if you do not accept this License.  Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+  6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions.  You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+  7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all.  For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices.  Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+  8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded.  In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+  9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number.  If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation.  If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+  10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission.  For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this.  Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+                            NO WARRANTY
+
+  11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.  EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.  THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.  SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+  12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software; you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation; either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License along
+    with this program; if not, write to the Free Software Foundation, Inc.,
+    51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+    Gnomovision version 69, Copyright (C) year name of author
+    Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary.  Here is a sample; alter the names:
+
+  Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+  `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+  <signature of Ty Coon>, 1 April 1989
+  Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs.  If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
diff --git a/web/modules/migrate_tools/README.txt b/web/modules/migrate_tools/README.txt
new file mode 100644
index 0000000000..5f46ba08e4
--- /dev/null
+++ b/web/modules/migrate_tools/README.txt
@@ -0,0 +1,15 @@
+The Migrate Tools module provides tools for running and managing Drupal 8
+migrations.
+
+Drush commands supported include:
+
+* migrate-status - Lists migrations and their status.
+* migrate-import - Performs import operations.
+* migrate-rollback - Performs rollback operations.
+* migrate-stop - Cleanly stops a running operation.
+* migrate-reset-status - Sets a migration status to Idle if it's gotten stuck.
+* migrate-messages - Lists any messages associated with a migration import.
+
+The UI at this point provides a front-end equivalent to the migrate-status and
+migrate-messages commands. It will be enhanced to allow running the other
+operations, as well as provide the ability to create and alter migrations.
diff --git a/web/modules/migrate_tools/composer.json b/web/modules/migrate_tools/composer.json
new file mode 100644
index 0000000000..d5c8fca8fd
--- /dev/null
+++ b/web/modules/migrate_tools/composer.json
@@ -0,0 +1,27 @@
+{
+  "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"
+      }
+    }
+  }
+}
diff --git a/web/modules/migrate_tools/drush.services.yml b/web/modules/migrate_tools/drush.services.yml
new file mode 100644
index 0000000000..ed0a3917b1
--- /dev/null
+++ b/web/modules/migrate_tools/drush.services.yml
@@ -0,0 +1,6 @@
+services:
+  migrate_tools.commands:
+    class: \Drupal\migrate_tools\Commands\MigrateToolsCommands
+    arguments: ['@plugin.manager.migration', '@date.formatter', '@entity_type.manager', '@keyvalue']
+    tags:
+      - { name: drush.command }
diff --git a/web/modules/migrate_tools/migrate_tools.drush.inc b/web/modules/migrate_tools/migrate_tools.drush.inc
new file mode 100644
index 0000000000..73ddec5307
--- /dev/null
+++ b/web/modules/migrate_tools/migrate_tools.drush.inc
@@ -0,0 +1,561 @@
+<?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
new file mode 100644
index 0000000000..e3cb18da29
--- /dev/null
+++ b/web/modules/migrate_tools/migrate_tools.info.yml
@@ -0,0 +1,16 @@
+type: module
+name: Migrate Tools
+description: 'Tools to assist in developing and running migrations.'
+package: Migration
+# core: 8.x
+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'
+project: 'migrate_tools'
+datestamp: 1535380087
diff --git a/web/modules/migrate_tools/migrate_tools.links.action.yml b/web/modules/migrate_tools/migrate_tools.links.action.yml
new file mode 100644
index 0000000000..869297cf7c
--- /dev/null
+++ b/web/modules/migrate_tools/migrate_tools.links.action.yml
@@ -0,0 +1,11 @@
+migrate_tools.add_group_action:
+  route_name: entity.migration_group.add_form
+  title: 'Add migration group'
+  appears_on:
+    - entity.migration_group.list
+
+#migrate_tools.add_migration_action:
+#  route_name: entity.migration.add_form
+#  title: 'Add migration'
+#  appears_on:
+#    - entity.migration.list
diff --git a/web/modules/migrate_tools/migrate_tools.links.menu.yml b/web/modules/migrate_tools/migrate_tools.links.menu.yml
new file mode 100644
index 0000000000..d55ba56c13
--- /dev/null
+++ b/web/modules/migrate_tools/migrate_tools.links.menu.yml
@@ -0,0 +1,5 @@
+migrate_tools.menu:
+  title: Migrations
+  parent: system.admin_structure
+  description: Manage migration processes.
+  route_name: entity.migration_group.list
diff --git a/web/modules/migrate_tools/migrate_tools.links.task.yml b/web/modules/migrate_tools/migrate_tools.links.task.yml
new file mode 100644
index 0000000000..da508a0ff0
--- /dev/null
+++ b/web/modules/migrate_tools/migrate_tools.links.task.yml
@@ -0,0 +1,53 @@
+# Component routing definition
+entity.migration.overview:
+  route_name: entity.migration.overview
+  base_route: entity.migration.overview
+  title: View
+
+entity.migration.overview_general:
+  title: Overview
+  route_name: entity.migration.overview
+  parent_id: entity.migration.overview
+entity.migration.overview_source:
+  title: Source
+  route_name: entity.migration.source
+  parent_id: entity.migration.overview
+  weight: 1
+entity.migration.overview_process:
+  title: Process
+  route_name: entity.migration.process
+  parent_id: entity.migration.overview
+  weight: 2
+entity.migration.overview_destination:
+  title: Destination
+  route_name: entity.migration.destination
+  parent_id: entity.migration.overview
+  weight: 3
+
+entity.migration.edit_form:
+  route_name: entity.migration.edit_form
+  base_route: entity.migration.overview
+  title: Edit
+
+migrate_tools.messages:
+  route_name: migrate_tools.messages
+  base_route: entity.migration.overview
+  title: Messages
+
+entity.migration.delete_form:
+  route_name:  entity.migration.delete_form
+  base_route:  entity.migration.overview
+  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
new file mode 100644
index 0000000000..b8113fe3d8
--- /dev/null
+++ b/web/modules/migrate_tools/migrate_tools.module
@@ -0,0 +1,58 @@
+<?php
+
+/**
+ * @file
+ * Provides tools for implementing and managing migrations.
+ */
+
+/**
+ * Implements hook_entity_type_build().
+ */
+function migrate_tools_entity_type_build(array &$entity_types) {
+  // Inject our UI into the general migration and migration group config
+  // entities.
+  /** @var \Drupal\Core\Config\Entity\ConfigEntityType[] $entity_types */
+  $entity_types['migration']
+    ->set('admin_permission', 'administer migrations')
+    ->setHandlerClass('list_builder', 'Drupal\migrate_tools\Controller\MigrationListBuilder')
+    ->setFormClass('edit', 'Drupal\migrate_tools\Form\MigrationEditForm')
+    ->setFormClass('delete', 'Drupal\migrate_tools\Form\MigrationDeleteForm')
+    ->setLinkTemplate('list-form', '/admin/structure/migrate/manage/{migration_group}/migrations');
+
+  $entity_types['migration_group']
+    ->set('admin_permission', 'administer migrations')
+    ->setHandlerClass('list_builder', 'Drupal\migrate_tools\Controller\MigrationGroupListBuilder')
+    ->setFormClass('add', 'Drupal\migrate_tools\Form\MigrationGroupAddForm')
+    ->setFormClass('edit', 'Drupal\migrate_tools\Form\MigrationGroupEditForm')
+    ->setFormClass('delete', 'Drupal\migrate_tools\Form\MigrationGroupDeleteForm')
+    ->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.permissions.yml b/web/modules/migrate_tools/migrate_tools.permissions.yml
new file mode 100644
index 0000000000..b5301e5c1f
--- /dev/null
+++ b/web/modules/migrate_tools/migrate_tools.permissions.yml
@@ -0,0 +1,3 @@
+'administer migrations':
+  title: 'Administer migrations'
+  description: Create, edit, and manage migration processed.
diff --git a/web/modules/migrate_tools/migrate_tools.routing.yml b/web/modules/migrate_tools/migrate_tools.routing.yml
new file mode 100644
index 0000000000..08f43e5b58
--- /dev/null
+++ b/web/modules/migrate_tools/migrate_tools.routing.yml
@@ -0,0 +1,197 @@
+# This is the router item for listing all migration group entities.
+entity.migration_group.list:
+  path: '/admin/structure/migrate'
+  defaults:
+    _entity_list: 'migration_group'
+    _title: 'Migrations'
+  requirements:
+    _permission: 'administer migrations'
+
+# This is the router item for adding our migration group entity.
+entity.migration_group.add_form:
+  path: '/admin/structure/migrate/add'
+  defaults:
+    _title: 'Add migration group'
+    _entity_form: migration_group.add
+  requirements:
+    _entity_create_access: migration_group
+
+# This is the router item for editing our migration group entity.
+entity.migration_group.edit_form:
+  path: '/admin/structure/migrate/manage/{migration_group}'
+  defaults:
+    _title: 'Edit migration group'
+    _entity_form: migration_group.edit
+  requirements:
+    _entity_access: migration_group.update
+
+# This is the router item for deleting an instance of our migration group entity.
+entity.migration_group.delete_form:
+  path: '/admin/structure/migrate/manage/{migration_group}/delete'
+  defaults:
+    _title: 'Delete migration group'
+    _entity_form: migration_group.delete
+  requirements:
+    _entity_access: migration_group.delete
+
+# This is the router item for listing all migration entities.
+entity.migration.list:
+  path: '/admin/structure/migrate/manage/{migration_group}/migrations'
+  defaults:
+    _entity_list: 'migration'
+    _title: 'Migrations'
+  requirements:
+    _permission: 'administer migrations'
+
+# This is the router item for adding our migration entity.
+#entity.migration.add_form:
+#  path: '/admin/structure/migrate/manage/{migration_group}/migrations/add'
+#  defaults:
+#    _title: 'Add migration'
+#    _entity_form: migration.add
+#  requirements:
+#    _entity_create_access: migration
+
+# This is the router item for viewing our migration entity.
+entity.migration.overview:
+  path: '/admin/structure/migrate/manage/{migration_group}/migrations/{migration}'
+  defaults:
+    _controller: '\Drupal\migrate_tools\Controller\MigrationController::overview'
+    _title: 'Migration overview'
+    _migrate_group: true
+  requirements:
+    _permission: 'administer migrations'
+  options:
+    parameters:
+      migration:
+        type: entity:migration
+      migration_group:
+        type: entity:migration_group
+entity.migration.source:
+  path: '/admin/structure/migrate/manage/{migration_group}/migrations/{migration}/source'
+  defaults:
+    _controller: '\Drupal\migrate_tools\Controller\MigrationController::source'
+    _title: 'Source'
+    _migrate_group: true
+  requirements:
+    _permission: 'administer migrations'
+  options:
+    parameters:
+      migration:
+        type: entity:migration
+      migration_group:
+        type: entity:migration_group
+entity.migration.process:
+  path: '/admin/structure/migrate/manage/{migration_group}/migrations/{migration}/process'
+  defaults:
+    _controller: '\Drupal\migrate_tools\Controller\MigrationController::process'
+    _title: 'Process'
+    _migrate_group: true
+  requirements:
+    _permission: 'administer migrations'
+  options:
+    parameters:
+      migration:
+        type: entity:migration
+      migration_group:
+        type: entity:migration_group
+entity.migration.process.run:
+  path: '/admin/structure/migrate/manage/{migration_group}/migrations/{migration}/process/run'
+  defaults:
+    _controller: '\Drupal\migrate_tools\Controller\MigrationController::run'
+    _title: 'Run'
+    _migrate_group: true
+  requirements:
+    _permission: 'administer migrations'
+  options:
+    parameters:
+      migration:
+        type: entity:migration
+      migration_group:
+        type: entity:migration_group
+entity.migration.destination:
+  path: '/admin/structure/migrate/manage/{migration_group}/migrations/{migration}/destination'
+  defaults:
+    _controller: '\Drupal\migrate_tools\Controller\MigrationController::destination'
+    _title: 'Destination'
+    _migrate_group: true
+  requirements:
+    _permission: 'administer migrations'
+  options:
+    parameters:
+      migration:
+        type: entity:migration
+      migration_group:
+        type: entity:migration_group
+# This is the router item for editing our migration entity.
+entity.migration.edit_form:
+  path: '/admin/structure/migrate/manage/{migration_group}/migrations/{migration}/edit'
+  defaults:
+    _title: 'Edit migration'
+    _entity_form: migration.edit
+    _migrate_group: true
+  requirements:
+    _entity_access: migration.update
+  options:
+    parameters:
+      migration:
+        type: entity:migration
+      migration_group:
+        type: entity:migration_group
+# This is the router item for deleting an instance of our migration entity.
+entity.migration.delete_form:
+  path: '/admin/structure/migrate/manage/{migration_group}/migrations/{migration}/delete'
+  defaults:
+    _title: 'Delete migration'
+    _entity_form: migration.delete
+    _migrate_group: true
+  requirements:
+    _entity_access: migration.delete
+  options:
+    parameters:
+      migration:
+        type: entity:migration
+      migration_group:
+        type: entity:migration_group
+migrate_tools.messages:
+  path: '/admin/structure/migrate/manage/{migration_group}/migrations/{migration}/messages'
+  defaults:
+    _controller: '\Drupal\migrate_tools\Controller\MessageController::overview'
+    _title: 'Messages'
+    _migrate_group: true
+  requirements:
+    _permission: 'administer migrations'
+  options:
+    parameters:
+      migration:
+        type: entity:migration
+      migration_group:
+        type: entity:migration_group
+migrate_tools.execute:
+  path: '/admin/structure/migrate/manage/{migration_group}/migrations/{migration}/execute'
+  defaults:
+    _form: '\Drupal\migrate_tools\Form\MigrationExecuteForm'
+    _title: 'Execute migration'
+    _migrate_group: true
+  requirements:
+    _permission: 'administer migrations'
+  options:
+    parameters:
+      migration:
+        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
new file mode 100644
index 0000000000..fa61e007f5
--- /dev/null
+++ b/web/modules/migrate_tools/migrate_tools.services.yml
@@ -0,0 +1,9 @@
+services:
+  logger.channel.migrate_tools:
+    class: Drupal\Core\Logger\LoggerChannel
+    factory: logger.factory:get
+    arguments: ['migrate_tools']
+  route_processor.migrate_group:
+    class: Drupal\migrate_tools\Routing\RouteProcessor
+    tags:
+    - { name: route_processor_outbound }
diff --git a/web/modules/migrate_tools/phpcs.xml b/web/modules/migrate_tools/phpcs.xml
new file mode 100644
index 0000000000..a18054efbc
--- /dev/null
+++ b/web/modules/migrate_tools/phpcs.xml
@@ -0,0 +1,207 @@
+<?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
new file mode 100644
index 0000000000..976b3c684d
--- /dev/null
+++ b/web/modules/migrate_tools/src/Commands/MigrateToolsCommands.php
@@ -0,0 +1,719 @@
+<?php
+
+namespace Drupal\migrate_tools\Commands;
+
+use Consolidation\OutputFormatters\StructuredData\RowsOfFields;
+use Drupal\Component\Utility\Unicode;
+use Drupal\Core\Datetime\DateFormatter;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
+use Drupal\migrate\Exception\RequirementsException;
+use Drupal\migrate\Plugin\MigrationInterface;
+use Drupal\migrate\Plugin\MigrationPluginManager;
+use Drupal\migrate\Plugin\RequirementsInterface;
+use Drupal\migrate_tools\Drush9LogMigrateMessage;
+use Drupal\migrate_tools\MigrateExecutable;
+use Drush\Commands\DrushCommands;
+
+/**
+ * Migrate Tools drush commands.
+ */
+class MigrateToolsCommands extends DrushCommands {
+
+  /**
+   * Migration plugin manager service.
+   *
+   * @var \Drupal\migrate\Plugin\MigrationPluginManager
+   */
+  protected $migrationPluginManager;
+
+  /**
+   * Date formatter service.
+   *
+   * @var \Drupal\Core\Datetime\DateFormatter
+   */
+  protected $dateFormatter;
+
+  /**
+   * Entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * Key-value store service.
+   *
+   * @var \Drupal\Core\KeyValueStore\KeyValueFactoryInterface
+   */
+  protected $keyValue;
+
+  /**
+   * Migrate message logger.
+   *
+   * @var \Drupal\migrate_tools\Drush9LogMigrateMessage
+   */
+  protected $migrateMessage;
+
+  /**
+   * MigrateToolsCommands constructor.
+   *
+   * @param \Drupal\migrate\Plugin\MigrationPluginManager $migrationPluginManager
+   *   Migration Plugin Manager service.
+   * @param \Drupal\Core\Datetime\DateFormatter $dateFormatter
+   *   Date formatter service.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
+   *   Entity type manager service.
+   * @param \Drupal\Core\KeyValueStore\KeyValueFactoryInterface $keyValue
+   *   Key-value store service.
+   */
+  public function __construct(MigrationPluginManager $migrationPluginManager, DateFormatter $dateFormatter, EntityTypeManagerInterface $entityTypeManager, KeyValueFactoryInterface $keyValue) {
+    parent::__construct();
+    $this->migrationPluginManager = $migrationPluginManager;
+    $this->dateFormatter = $dateFormatter;
+    $this->entityTypeManager = $entityTypeManager;
+    $this->keyValue = $keyValue;
+  }
+
+  /**
+   * List all migrations with current status.
+   *
+   * @param string $migration_names
+   *   Restrict to a comma-separated list of migrations (Optional).
+   * @param array $options
+   *   Additional options for the command.
+   *
+   * @command migrate:status
+   *
+   * @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)
+   *
+   * @usage migrate:status
+   *   Retrieve status for all migrations
+   * @usage migrate:status --group=beer
+   *   Retrieve status for all migrations in a given group
+   * @usage migrate:status --tag=user
+   *   Retrieve status for all migrations with a given tag
+   * @usage migrate:status --group=beer --tag=user
+   *   Retrieve status for all migrations in the beer group
+   *   and with the user tag.
+   * @usage migrate:status beer_term,beer_node
+   *   Retrieve status for specific migrations
+   *
+   * @validate-module-enabled migrate_tools
+   *
+   * @aliases ms, migrate-status
+   *
+   * @field-labels
+   *   group: Group
+   *   id: Migration ID
+   *   status: Status
+   *   total: Total
+   *   imported: Imported
+   *   unprocessed: Unprocessed
+   *   last_imported: Last Imported
+   * @default-fields group,id,status,total,imported,unprocessed,last_imported
+   *
+   * @return \Consolidation\OutputFormatters\StructuredData\RowsOfFields
+   *   Migrations status formatted as table.
+   */
+  public function status($migration_names = '', array $options = ['group' => NULL, 'tag' => NULL, 'names-only' => NULL]) {
+    $names_only = $options['names-only'];
+
+    $migrations = $this->migrationsList($migration_names, $options);
+
+    $table = [];
+    // 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 */
+      $group = $this->entityTypeManager->getStorage('migration_group')->load($group_id);
+      $group_name = !empty($group) ? "{$group->label()} ({$group->id()})" : $group_id;
+
+      foreach ($migration_list as $migration_id => $migration) {
+        if ($names_only) {
+          $table[] = [
+            'group' => dt('Group: @name', ['@name' => $group_name]),
+            'id' => $migration_id,
+          ];
+        }
+        else {
+          try {
+            $map = $migration->getIdMap();
+            $imported = $map->importedCount();
+            $source_plugin = $migration->getSourcePlugin();
+          }
+          catch (\Exception $e) {
+            $this->logger()->error(
+              dt(
+                'Failure retrieving information on @migration: @message',
+                ['@migration' => $migration_id, '@message' => $e->getMessage()]
+              )
+            );
+            continue;
+          }
+
+          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) {
+            $this->logger()->error(
+              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 = $this->keyValue->get(
+            'migrate_last_imported'
+          );
+          $last_imported = $migrate_last_imported_store->get(
+            $migration->id(),
+            FALSE
+          );
+          if ($last_imported) {
+            $last_imported = $this->dateFormatter->format(
+              $last_imported / 1000,
+              'custom',
+              'Y-m-d H:i:s'
+            );
+          }
+          else {
+            $last_imported = '';
+          }
+          $table[] = [
+            'group' => $group_name,
+            'id' => $migration_id,
+            'status' => $status,
+            'total' => $source_rows,
+            'imported' => $imported,
+            'unprocessed' => $unprocessed,
+            'last_imported' => $last_imported,
+          ];
+        }
+      }
+
+      // Add empty row to separate groups, for readability.
+      end($migrations);
+      if ($group_id !== key($migrations)) {
+        $table[] = [];
+      }
+    }
+
+    return new RowsOfFields($table);
+  }
+
+  /**
+   * Perform one or more migration processes.
+   *
+   * @param string $migration_names
+   *   ID of migration(s) to import. Delimit multiple using commas.
+   * @param array $options
+   *   Additional options for the command.
+   *
+   * @command migrate:import
+   *
+   * @option all Process all migrations.
+   * @option group A comma-separated list of migration groups to import
+   * @option tag Name of the migration tag to import
+   * @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 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 execute-dependencies Execute all dependent migrations first.
+   *
+   * @usage migrate:import --all
+   *   Perform all migrations
+   * @usage migrate:import --group=beer
+   *   Import all migrations in the beer group
+   * @usage migrate:import --tag=user
+   *   Import all migrations with the user tag
+   * @usage migrate:import --group=beer --tag=user
+   *   Import all migrations in the beer group and with the user tag
+   * @usage migrate:import beer_term,beer_node
+   *   Import new terms and nodes
+   * @usage migrate:import beer_user --limit=2
+   *   Import no more than 2 users
+   * @usage migrate:import beer_user --idlist=5
+   *   Import the user record with source ID 5
+   *
+   * @validate-module-enabled migrate_tools
+   *
+   * @aliases mim, migrate-import
+   *
+   * @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]) {
+    $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.'));
+    }
+
+    // Take it one group at a time, importing the migrations within each group.
+    foreach ($migrations as $group_id => $migration_list) {
+      array_walk(
+        $migration_list,
+        [$this, 'executeMigration'],
+        $additional_options
+      );
+    }
+  }
+
+  /**
+   * Rollback one or more migrations.
+   *
+   * @param string $migration_names
+   *   Name of migration(s) to rollback. Delimit multiple using commas.
+   * @param array $options
+   *   Additional options for the command.
+   *
+   * @command migrate:rollback
+   *
+   * @option all Process all migrations.
+   * @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
+   *
+   * @usage migrate:rollback --all
+   *   Perform all migrations
+   * @usage migrate:rollback --group=beer
+   *   Rollback all migrations in the beer group
+   * @usage migrate:rollback --tag=user
+   *   Rollback all migrations with the user tag
+   * @usage migrate:rollback --group=beer --tag=user
+   *   Rollback all migrations in the beer group and with the user tag
+   * @usage migrate:rollback beer_term,beer_node
+   *   Rollback imported terms and nodes
+   * @validate-module-enabled migrate_tools
+   *
+   * @aliases mr, migrate-rollback
+   *
+   * @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]) {
+    $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.'));
+    }
+
+    // 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,
+          $this->getMigrateMessage(),
+          $additional_options
+        );
+        // drush_op() provides --simulate support.
+        drush_op([$executable, 'rollback']);
+      }
+    }
+  }
+
+  /**
+   * Stop an active migration operation.
+   *
+   * @param string $migration_id
+   *   ID of migration to stop.
+   *
+   * @command migrate:stop
+   *
+   * @validate-module-enabled migrate_tools
+   * @aliases mst, migrate-stop
+   */
+  public function stop($migration_id = '') {
+    /** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
+    $migration = $this->migrationPluginManager->createInstance(
+      $migration_id
+    );
+    if ($migration) {
+      $status = $migration->getStatus();
+      switch ($status) {
+        case MigrationInterface::STATUS_IDLE:
+          $this->logger()->warning(
+            dt('Migration @id is idle', ['@id' => $migration_id])
+          );
+          break;
+
+        case MigrationInterface::STATUS_DISABLED:
+          $this->logger()->warning(
+            dt('Migration @id is disabled', ['@id' => $migration_id])
+          );
+          break;
+
+        case MigrationInterface::STATUS_STOPPING:
+          $this->logger()->warning(
+            dt('Migration @id is already stopping', ['@id' => $migration_id])
+          );
+          break;
+
+        default:
+          $migration->interruptMigration(MigrationInterface::RESULT_STOPPED);
+          $this->logger()->notice(
+            dt('Migration @id requested to stop', ['@id' => $migration_id])
+          );
+          break;
+      }
+    }
+    else {
+      $this->logger()->error(
+        dt('Migration @id does not exist', ['@id' => $migration_id])
+      );
+    }
+  }
+
+  /**
+   * Reset a active migration's status to idle.
+   *
+   * @param string $migration_id
+   *   ID of migration to reset.
+   *
+   * @command migrate:reset-status
+   *
+   * @validate-module-enabled migrate_tools
+   * @aliases mrs, migrate-reset-status
+   */
+  public function resetStatus($migration_id = '') {
+    /** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
+    $migration = $this->migrationPluginManager->createInstance(
+      $migration_id
+    );
+    if ($migration) {
+      $status = $migration->getStatus();
+      if ($status == MigrationInterface::STATUS_IDLE) {
+        $this->logger()->warning(
+          dt('Migration @id is already Idle', ['@id' => $migration_id])
+        );
+      }
+      else {
+        $migration->setStatus(MigrationInterface::STATUS_IDLE);
+        $this->logger()->notice(
+          dt('Migration @id reset to Idle', ['@id' => $migration_id])
+        );
+      }
+    }
+    else {
+      $this->logger()->error(
+        dt('Migration @id does not exist', ['@id' => $migration_id])
+      );
+    }
+  }
+
+  /**
+   * View any messages associated with a migration.
+   *
+   * @param string $migration_id
+   *   ID of the migration.
+   * @param array $options
+   *   Additional options for the command.
+   *
+   * @command migrate:messages
+   *
+   * @option csv Export messages as a CSV
+   *
+   * @usage migrate:messages MyNode
+   *   Show all messages for the MyNode migration
+   *
+   * @validate-module-enabled migrate_tools
+   *
+   * @aliases mmsg,migrate-messages
+   *
+   * @field-labels
+   *   source_ids_hash: Source IDs Hash
+   *   level: Level
+   *   message: Message
+   * @default-fields source_ids_hash,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]) {
+    /** @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;
+    }
+
+    $map = $migration->getIdMap();
+    $table = [];
+    foreach ($map->getMessageIterator() as $row) {
+      unset($row->msgid);
+      $table[] = (array) $row;
+    }
+    if (empty($table)) {
+      $this->logger()->notice(dt('No messages for this migration'));
+      return NULL;
+    }
+
+    if ($options['csv']) {
+      fputcsv(STDOUT, array_keys($table[0]));
+      foreach ($table as $row) {
+        fputcsv(STDOUT, $row);
+      }
+      return NULL;
+    }
+    return new RowsOfFields($table);
+  }
+
+  /**
+   * List the fields available for mapping in a source.
+   *
+   * @param string $migration_id
+   *   ID of the migration.
+   *
+   * @command migrate:fields-source
+   *
+   * @usage migrate:fields-source my_node
+   *   List fields for the source in the my_node migration
+   *
+   * @validate-module-enabled migrate_tools
+   *
+   * @aliases mfs, migrate-fields-source
+   *
+   * @field-labels
+   *   machine_name: Machine Name
+   *   description: Description
+   * @default-fields machine_name,description
+   *
+   * @return \Consolidation\OutputFormatters\StructuredData\RowsOfFields
+   *   Source fields of the given migration formatted as a table.
+   */
+  public function fieldsSource($migration_id) {
+    /** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
+    $migration = $this->migrationPluginManager->createInstance(
+      $migration_id
+    );
+    if ($migration) {
+      $source = $migration->getSourcePlugin();
+      $table = [];
+      foreach ($source->fields() as $machine_name => $description) {
+        $table[] = [
+          'machine_name' => $machine_name,
+          'description' => strip_tags($description),
+        ];
+      }
+      return new RowsOfFields($table);
+    }
+    else {
+      $this->logger()->error(
+        dt('Migration @id does not exist', ['@id' => $migration_id])
+      );
+    }
+  }
+
+  /**
+   * Retrieve a list of active migrations.
+   *
+   * @param string $migration_ids
+   *   Comma-separated list of migrations -
+   *   if present, return only these migrations.
+   * @param array $options
+   *   Command 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']
+    ) : [];
+
+    $manager = $this->migrationPluginManager;
+    $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 : [];
+  }
+
+  /**
+   * 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 of the command.
+   *
+   * @throws \Exception
+   *   If some migrations failed during execution.
+   */
+  protected function executeMigration(MigrationInterface $migration, $migration_id, array $options = []) {
+    // Keep track of all migrations run during this command so the same
+    // migration is not run multiple times.
+    static $executed_migrations = [];
+
+    if (isset($options['execute-dependencies'])) {
+      $required_migrations = $migration->get('requirements');
+      $required_migrations = array_filter($required_migrations, function ($value) use ($executed_migrations) {
+        return !isset($executed_migrations[$value]);
+      });
+
+      if (!empty($required_migrations)) {
+        $manager = $this->migrationPluginManager;
+        $required_migrations = $manager->createInstances($required_migrations);
+        $dependency_options = array_merge($options, ['is_dependency' => TRUE]);
+        array_walk($required_migrations, [$this, __FUNCTION__], $dependency_options);
+        $executed_migrations += $required_migrations;
+      }
+    }
+    if (!empty($options['force'])) {
+      $migration->set('requirements', []);
+    }
+    if (!empty($options['update'])) {
+      $migration->getIdMap()->prepareUpdate();
+    }
+    $executable = new MigrateExecutable($migration, $this->getMigrateMessage(), $options);
+    // drush_op() provides --simulate support.
+    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]
+        )
+      );
+    }
+  }
+
+  /**
+   * Gets the migrate message logger.
+   *
+   * @return \Drupal\migrate\MigrateMessageInterface
+   *   The migrate message service.
+   */
+  protected function getMigrateMessage() {
+    if (!isset($this->migrateMessage)) {
+      $this->migrateMessage = new Drush9LogMigrateMessage($this->logger());
+    }
+    return $this->migrateMessage;
+  }
+
+}
diff --git a/web/modules/migrate_tools/src/Controller/MessageController.php b/web/modules/migrate_tools/src/Controller/MessageController.php
new file mode 100644
index 0000000000..fbd347c96d
--- /dev/null
+++ b/web/modules/migrate_tools/src/Controller/MessageController.php
@@ -0,0 +1,144 @@
+<?php
+
+namespace Drupal\migrate_tools\Controller;
+
+use Drupal\Component\Utility\Html;
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Database\Connection;
+use Drupal\migrate\Plugin\MigrationInterface;
+use Drupal\migrate\Plugin\MigrationPluginManagerInterface;
+use Drupal\migrate_plus\Entity\MigrationGroupInterface;
+use Drupal\migrate_plus\Entity\MigrationInterface as MigratePlusMigrationInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Returns responses for migrate_tools message routes.
+ */
+class MessageController extends ControllerBase {
+
+  /**
+   * The database service.
+   *
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $database;
+
+  /**
+   * Plugin manager for migration plugins.
+   *
+   * @var \Drupal\migrate\Plugin\MigrationPluginManagerInterface
+   */
+  protected $migrationPluginManager;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('database'),
+      $container->get('plugin.manager.migration')
+    );
+  }
+
+  /**
+   * Constructs a MessageController object.
+   *
+   * @param \Drupal\Core\Database\Connection $database
+   *   A database connection.
+   * @param \Drupal\migrate\Plugin\MigrationPluginManagerInterface $migration_plugin_manager
+   *   The migration plugin manager.
+   */
+  public function __construct(Connection $database, MigrationPluginManagerInterface $migration_plugin_manager) {
+    $this->database = $database;
+    $this->migrationPluginManager = $migration_plugin_manager;
+  }
+
+  /**
+   * Gets an array of log level classes.
+   *
+   * @return array
+   *   An array of log level classes.
+   */
+  public static function getLogLevelClassMap() {
+    return [
+      MigrationInterface::MESSAGE_INFORMATIONAL => 'migrate-message-4',
+      MigrationInterface::MESSAGE_NOTICE => 'migrate-message-3',
+      MigrationInterface::MESSAGE_WARNING => 'migrate-message-2',
+      MigrationInterface::MESSAGE_ERROR => 'migrate-message-1',
+    ];
+  }
+
+  /**
+   * Displays a listing of migration messages.
+   *
+   * Messages are truncated at 56 chars.
+   *
+   * @param \Drupal\migrate_plus\Entity\MigrationGroupInterface $migration_group
+   *   The migration group.
+   * @param \Drupal\migrate_plus\Entity\MigrationInterface $migration
+   *   The $migration.
+   *
+   * @return array
+   *   A render array as expected by drupal_render().
+   */
+  public function overview(MigrationGroupInterface $migration_group, MigratePlusMigrationInterface $migration) {
+    $rows = [];
+    $classes = static::getLogLevelClassMap();
+    $migration_plugin = $this->migrationPluginManager->createInstance($migration->id(), $migration->toArray());
+    $source_id_field_names = array_keys($migration_plugin->getSourcePlugin()->getIds());
+    $column_number = 1;
+    foreach ($source_id_field_names as $source_id_field_name) {
+      $header[] = [
+        'data' => $source_id_field_name,
+        'field' => 'sourceid' . $column_number++,
+        'class' => [RESPONSIVE_PRIORITY_MEDIUM],
+      ];
+    }
+    $header[] = [
+      'data' => $this->t('Severity level'),
+      'field' => 'level',
+      'class' => [RESPONSIVE_PRIORITY_LOW],
+    ];
+    $header[] = [
+      'data' => $this->t('Message'),
+      'field' => 'message',
+    ];
+
+    $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();
+
+    foreach ($result as $message_row) {
+      $column_number = 1;
+      foreach ($source_id_field_names as $source_id_field_name) {
+        $column_name = 'sourceid' . $column_number++;
+        $row[$column_name] = $message_row->$column_name;
+      }
+      $row['level'] = $message_row->level;
+      $row['message'] = $message_row->message;
+      $row['class'] = [Html::getClass('migrate-message-' . $message_row->level), $classes[$message_row->level]];
+      $rows[] = $row;
+    }
+
+    $build['message_table'] = [
+      '#type' => 'table',
+      '#header' => $header,
+      '#rows' => $rows,
+      '#attributes' => ['id' => $message_table, 'class' => [$message_table]],
+      '#empty' => $this->t('No messages for this migration.'),
+    ];
+    $build['message_pager'] = ['#type' => 'pager'];
+
+    return $build;
+  }
+
+}
diff --git a/web/modules/migrate_tools/src/Controller/MigrationController.php b/web/modules/migrate_tools/src/Controller/MigrationController.php
new file mode 100644
index 0000000000..15c90795ca
--- /dev/null
+++ b/web/modules/migrate_tools/src/Controller/MigrationController.php
@@ -0,0 +1,300 @@
+<?php
+
+namespace Drupal\migrate_tools\Controller;
+
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\Component\Utility\Xss;
+use Drupal\Component\Utility\Html;
+use Drupal\Core\Routing\CurrentRouteMatch;
+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.
+ */
+class MigrationController extends ControllerBase implements ContainerInjectionInterface {
+
+  /**
+   * Plugin manager for migration plugins.
+   *
+   * @var \Drupal\migrate\Plugin\MigrationPluginManagerInterface
+   */
+  protected $migrationPluginManager;
+
+  /**
+   * The current route match.
+   *
+   * @var \Drupal\Core\Routing\CurrentRouteMatch
+   */
+  protected $currentRouteMatch;
+
+  /**
+   * Constructs a new MigrationController object.
+   *
+   * @param \Drupal\migrate\Plugin\MigrationPluginManagerInterface $migration_plugin_manager
+   *   The plugin manager for config entity-based migrations.
+   * @param \Drupal\Core\Routing\CurrentRouteMatch $currentRouteMatch
+   *   The current route match.
+   */
+  public function __construct(MigrationPluginManagerInterface $migration_plugin_manager, CurrentRouteMatch $currentRouteMatch) {
+    $this->migrationPluginManager = $migration_plugin_manager;
+    $this->currentRouteMatch = $currentRouteMatch;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('plugin.manager.migration'),
+      $container->get('current_route_match')
+    );
+  }
+
+  /**
+   * Displays an overview of a migration entity.
+   *
+   * @param \Drupal\migrate_plus\Entity\MigrationGroupInterface $migration_group
+   *   The migration group.
+   * @param \Drupal\migrate_plus\Entity\MigrationInterface $migration
+   *   The $migration.
+   *
+   * @return array
+   *   A render array as expected by drupal_render().
+   */
+  public function overview(MigrationGroupInterface $migration_group, MigrationInterface $migration) {
+    $build['overview'] = [
+      '#type' => 'fieldset',
+      '#title' => $this->t('Overview'),
+    ];
+
+    $build['overview']['group'] = [
+      '#title' => $this->t('Group:'),
+      '#markup' => Xss::filterAdmin($migration_group->label()),
+      '#type' => 'item',
+    ];
+
+    $build['overview']['description'] = [
+      '#title' => $this->t('Description:'),
+      '#markup' => Xss::filterAdmin($migration->label()),
+      '#type' => 'item',
+    ];
+    $migration_plugin = $this->migrationPluginManager->createInstance($migration->id(), $migration->toArray());
+    $migration_dependencies = $migration_plugin->getMigrationDependencies();
+    if (!empty($migration_dependencies['required'])) {
+      $build['overview']['dependencies'] = [
+        '#title' => $this->t('Migration Dependencies') ,
+        '#markup' => Xss::filterAdmin(implode(', ', $migration_dependencies['required'])),
+        '#type' => 'item',
+      ];
+    }
+    if (!empty($migration_dependencies['optional'])) {
+      $build['overview']['soft_dependencies'] = [
+        '#title' => $this->t('Soft Migration Dependencies'),
+        '#markup' => Xss::filterAdmin(implode(', ', $migration_dependencies['optional'])),
+        '#type' => 'item',
+      ];
+    }
+
+    return $build;
+  }
+
+  /**
+   * Display source information of a migration entity.
+   *
+   * @param \Drupal\migrate_plus\Entity\MigrationGroupInterface $migration_group
+   *   The migration group.
+   * @param \Drupal\migrate_plus\Entity\MigrationInterface $migration
+   *   The $migration.
+   *
+   * @return array
+   *   A render array as expected by drupal_render().
+   */
+  public function source(MigrationGroupInterface $migration_group, MigrationInterface $migration) {
+    // Source field information.
+    $build['source'] = [
+      '#type' => 'fieldset',
+      '#title' => $this->t('Source'),
+      '#group' => 'detail',
+      '#description' => $this->t('<p>These are the fields available from the source of this migration task. The machine names listed here may be used as sources in the process pipeline.</p>'),
+      '#attributes' => [
+        'id' => 'migration-detail-source',
+      ],
+    ];
+    $migration_plugin = $this->migrationPluginManager->createInstance($migration->id(), $migration->toArray());
+    $source = $migration_plugin->getSourcePlugin();
+    $build['source']['query'] = [
+      '#type' => 'item',
+      '#title' => $this->t('Query'),
+      '#markup' => '<pre>' . Xss::filterAdmin($source) . '</pre>',
+    ];
+    $header = [$this->t('Machine name'), $this->t('Description')];
+    $rows = [];
+    foreach ($source->fields($migration_plugin) as $machine_name => $description) {
+      $rows[] = [
+        ['data' => Html::escape($machine_name)],
+        ['data' => Xss::filterAdmin($description)],
+      ];
+    }
+
+    $build['source']['fields'] = [
+      '#theme' => 'table',
+      '#header' => $header,
+      '#rows' => $rows,
+      '#empty' => $this->t('No fields'),
+    ];
+
+    return $build;
+  }
+
+  /**
+   * Run a migration.
+   *
+   * @param \Drupal\migrate_plus\Entity\MigrationGroupInterface $migration_group
+   *   The migration group.
+   * @param \Drupal\migrate_plus\Entity\MigrationInterface $migration
+   *   The $migration.
+   *
+   * @return array
+   *   A render array as expected by drupal_render().
+   */
+  public function run(MigrationGroupInterface $migration_group, MigrationInterface $migration) {
+    $migrateMessage = new MigrateMessage();
+    $options = [];
+
+    $migration_plugin = $this->migrationPluginManager->createInstance($migration->id(), $migration->toArray());
+    $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(),
+    ];
+    return batch_process(Url::fromRoute('entity.migration.process', $route_parameters));
+  }
+
+  /**
+   * Display process information of a migration entity.
+   *
+   * @param \Drupal\migrate_plus\Entity\MigrationGroupInterface $migration_group
+   *   The migration group.
+   * @param \Drupal\migrate_plus\Entity\MigrationInterface $migration
+   *   The $migration.
+   *
+   * @return array
+   *   A render array as expected by drupal_render().
+   */
+  public function process(MigrationGroupInterface $migration_group, MigrationInterface $migration) {
+    $migration_plugin = $this->migrationPluginManager->createInstance($migration->id(), $migration->toArray());
+
+    // Process information.
+    $build['process'] = [
+      '#type' => 'fieldset',
+      '#title' => $this->t('Process'),
+    ];
+
+    $header = [
+      $this->t('Destination'),
+      $this->t('Source'),
+      $this->t('Process plugin'),
+      $this->t('Default'),
+    ];
+    $rows = [];
+    foreach ($migration_plugin->getProcess() as $destination_id => $process_line) {
+      $row = [];
+      $row[] = ['data' => Html::escape($destination_id)];
+      if (isset($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'])];
+      }
+      else {
+        $row[] = '';
+      }
+      if (isset($process_line[0]['default_value'])) {
+        $row[] = ['data' => Xss::filterAdmin($process_line[0]['default_value'])];
+      }
+      else {
+        $row[] = '';
+      }
+      $rows[] = $row;
+    }
+
+    $build['process']['fields'] = [
+      '#theme' => 'table',
+      '#header' => $header,
+      '#rows' => $rows,
+      '#empty' => $this->t('No process defined.'),
+    ];
+
+    $build['process']['run'] = [
+      '#type' => 'link',
+      '#title' => $this->t('Run'),
+      '#url' => Url::fromRoute('entity.migration.process.run', ['migration_group' => $migration_group->id(), 'migration' => $migration->id()]),
+    ];
+
+    return $build;
+  }
+
+  /**
+   * Displays destination information of a migration entity.
+   *
+   * @param \Drupal\migrate_plus\Entity\MigrationGroupInterface $migration_group
+   *   The migration group.
+   * @param \Drupal\migrate_plus\Entity\MigrationInterface $migration
+   *   The $migration.
+   *
+   * @return array
+   *   A render array as expected by drupal_render().
+   */
+  public function destination(MigrationGroupInterface $migration_group, MigrationInterface $migration) {
+    $migration_plugin = $this->migrationPluginManager->createInstance($migration->id(), $migration->toArray());
+
+    // Destination field information.
+    $build['destination'] = [
+      '#type' => 'fieldset',
+      '#title' => $this->t('Destination'),
+      '#group' => 'detail',
+      '#description' => $this->t('<p>These are the fields available in the destination plugin of this migration task. The machine names are those available to be used as the keys in the process pipeline.</p>'),
+      '#attributes' => [
+        'id' => 'migration-detail-destination',
+      ],
+    ];
+    $destination = $migration_plugin->getDestinationPlugin();
+    $build['destination']['type'] = [
+      '#type' => 'item',
+      '#title' => $this->t('Type'),
+      '#markup' => Xss::filterAdmin($destination->getPluginId()),
+    ];
+    $header = [$this->t('Machine name'), $this->t('Description')];
+    $rows = [];
+    $destination_fields = $destination->fields() ?: [];
+    foreach ($destination_fields as $machine_name => $description) {
+      $rows[] = [
+        ['data' => Html::escape($machine_name)],
+        ['data' => Xss::filterAdmin($description)],
+      ];
+    }
+
+    $build['destination']['fields'] = [
+      '#theme' => 'table',
+      '#header' => $header,
+      '#rows' => $rows,
+      '#empty' => $this->t('No fields'),
+    ];
+
+    return $build;
+  }
+
+}
diff --git a/web/modules/migrate_tools/src/Controller/MigrationGroupListBuilder.php b/web/modules/migrate_tools/src/Controller/MigrationGroupListBuilder.php
new file mode 100644
index 0000000000..8ab2b87fb5
--- /dev/null
+++ b/web/modules/migrate_tools/src/Controller/MigrationGroupListBuilder.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Drupal\migrate_tools\Controller;
+
+use Drupal\Core\Config\Entity\ConfigEntityListBuilder;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Url;
+
+/**
+ * Provides a listing of migration group entities.
+ *
+ * @package Drupal\migrate_tools\Controller
+ *
+ * @ingroup migrate_tools
+ */
+class MigrationGroupListBuilder extends ConfigEntityListBuilder {
+
+  /**
+   * Builds the header row for the entity listing.
+   *
+   * @return array
+   *   A render array structure of header strings.
+   *
+   * @see Drupal\Core\Entity\EntityListController::render()
+   */
+  public function buildHeader() {
+    $header['label'] = $this->t('Migration Group');
+    $header['machine_name'] = $this->t('Machine Name');
+    $header['description'] = $this->t('Description');
+    $header['source_type'] = $this->t('Source Type');
+    return $header + parent::buildHeader();
+  }
+
+  /**
+   * Builds a row for an entity in the entity listing.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity for which to build the row.
+   *
+   * @return array
+   *   A render array of the table row for displaying the entity.
+   *
+   * @see \Drupal\Core\Entity\EntityListController::render()
+   */
+  public function buildRow(EntityInterface $entity) {
+    $row['label'] = $entity->label();
+    $row['machine_name'] = $entity->id();
+    $row['description'] = $entity->get('description');
+    $row['source_type'] = $entity->get('source_type');
+
+    return $row + parent::buildRow($entity);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDefaultOperations(EntityInterface $entity) {
+    $operations = parent::getDefaultOperations($entity);
+    $operations['list'] = [
+      'title' => $this->t('List migrations'),
+      'weight' => 0,
+      'url' => Url::fromRoute('entity.migration.list', ['migration_group' => $entity->id()]),
+    ];
+
+    return $operations;
+  }
+
+}
diff --git a/web/modules/migrate_tools/src/Controller/MigrationListBuilder.php b/web/modules/migrate_tools/src/Controller/MigrationListBuilder.php
new file mode 100644
index 0000000000..d078764cea
--- /dev/null
+++ b/web/modules/migrate_tools/src/Controller/MigrationListBuilder.php
@@ -0,0 +1,244 @@
+<?php
+
+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\migrate\Plugin\MigrationPluginManagerInterface;
+use Drupal\migrate_plus\Entity\MigrationGroup;
+use Drupal\Core\Url;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a listing of migration entities in a given group.
+ *
+ * @package Drupal\migrate_tools\Controller
+ *
+ * @ingroup migrate_tools
+ */
+class MigrationListBuilder extends ConfigEntityListBuilder implements EntityHandlerInterface {
+
+  /**
+   * Default object for current_route_match service.
+   *
+   * @var \Drupal\Core\Routing\CurrentRouteMatch
+   */
+  protected $currentRouteMatch;
+
+  /**
+   * Plugin manager for migration plugins.
+   *
+   * @var \Drupal\migrate\Plugin\MigrationPluginManagerInterface
+   */
+  protected $migrationPluginManager;
+
+  /**
+   * The logger service.
+   *
+   * @var \Drupal\Core\Logger\LoggerChannelInterface
+   */
+  protected $logger;
+
+  /**
+   * Constructs a new EntityListBuilder object.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type definition.
+   * @param \Drupal\Core\Entity\EntityStorageInterface $storage
+   *   The entity storage class.
+   * @param \Drupal\Core\Routing\CurrentRouteMatch $current_route_match
+   *   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
+   *   The logger service.
+   */
+  public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $storage, CurrentRouteMatch $current_route_match, MigrationPluginManagerInterface $migration_plugin_manager, LoggerChannelInterface $logger) {
+    parent::__construct($entity_type, $storage);
+    $this->currentRouteMatch = $current_route_match;
+    $this->migrationPluginManager = $migration_plugin_manager;
+    $this->logger = $logger;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
+    return new static(
+      $entity_type,
+      $container->get('entity.manager')->getStorage($entity_type->id()),
+      $container->get('current_route_match'),
+      $container->get('plugin.manager.migration'),
+      $container->get('logger.channel.migrate_tools')
+    );
+  }
+
+  /**
+   * Retrieve the migrations belonging to the appropriate group.
+   *
+   * @return array
+   *   An array of entity IDs.
+   */
+  protected function getEntityIds() {
+    $migration_group = $this->currentRouteMatch->getParameter('migration_group');
+
+    $query = $this->getStorage()->getQuery()
+      ->sort($this->entityType->getKey('id'));
+
+    $migration_groups = MigrationGroup::loadMultiple();
+
+    if (array_key_exists($migration_group, $migration_groups)) {
+      $query->condition('migration_group', $migration_group);
+    }
+    else {
+      $query->notExists('migration_group');
+    }
+    // Only add the pager if a limit is specified.
+    if ($this->limit) {
+      $query->pager($this->limit);
+    }
+    return $query->execute();
+  }
+
+  /**
+   * Builds the header row for the entity listing.
+   *
+   * @return array
+   *   A render array structure of header strings.
+   *
+   * @see \Drupal\Core\Entity\EntityListController::render()
+   */
+  public function buildHeader() {
+    $header['label'] = $this->t('Migration');
+    $header['machine_name'] = $this->t('Machine Name');
+    $header['status'] = $this->t('Status');
+    $header['total'] = $this->t('Total');
+    $header['imported'] = $this->t('Imported');
+    $header['unprocessed'] = $this->t('Unprocessed');
+    $header['messages'] = $this->t('Messages');
+    $header['last_imported'] = $this->t('Last Imported');
+    $header['operations'] = $this->t('Operations');
+    return $header;
+  }
+
+  /**
+   * Builds a row for a migration plugin.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $migration_entity
+   *   The migration plugin for which to build the row.
+   *
+   * @return array|null
+   *   A render array of the table row for displaying the plugin information.
+   *
+   * @see \Drupal\Core\Entity\EntityListController::render()
+   */
+  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');
+      if (!$migration_group) {
+        $migration_group = 'default';
+      }
+      $route_parameters = [
+        'migration_group' => $migration_group,
+        'migration' => $migration->id(),
+      ];
+      $row['label'] = [
+        'data' => [
+          '#type' => 'link',
+          '#title' => $migration->label(),
+          '#url' => Url::fromRoute("entity.migration.overview", $route_parameters),
+        ],
+      ];
+      $row['machine_name'] = $migration->id();
+      $row['status'] = $migration->getStatusLabel();
+    }
+    catch (PluginException $e) {
+      $this->logger->warning('Migration entity id %id is malformed: %orig', ['%id' => $migration_entity->id(), '%orig' => $e->getMessage()]);
+      return NULL;
+    }
+
+    try {
+      // Derive the stats.
+      $source_plugin = $migration->getSourcePlugin();
+      $row['total'] = $source_plugin->count();
+      $map = $migration->getIdMap();
+      $row['imported'] = $map->importedCount();
+      // -1 indicates uncountable sources.
+      if ($row['total'] == -1) {
+        $row['total'] = $this->t('N/A');
+        $row['unprocessed'] = $this->t('N/A');
+      }
+      else {
+        $row['unprocessed'] = $row['total'] - $map->processedCount();
+      }
+      $row['messages'] = [
+        'data' => [
+          '#type' => 'link',
+          '#title' => $map->messageCount(),
+          '#url' => Url::fromRoute("migrate_tools.messages", $route_parameters),
+        ],
+      ];
+      $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');
+        $row['last_imported'] = $date_formatter->format($last_imported / 1000,
+          'custom', 'Y-m-d H:i:s');
+      }
+      else {
+        $row['last_imported'] = '';
+      }
+
+      $row['operations']['data'] = [
+        '#type' => 'dropbutton',
+        '#links' => [
+          'simple_form' => [
+            'title' => $this->t('Execute'),
+            'url' => Url::fromRoute('migrate_tools.execute', [
+              'migration_group' => $migration_group,
+              'migration' => $migration->id(),
+            ]),
+          ],
+        ],
+      ];
+    }
+    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');
+    }
+
+    return $row;
+  }
+
+  /**
+   * Add group route parameter.
+   *
+   * @param \Drupal\Core\Url $url
+   *   The URL associated with an operation.
+   * @param string $migration_group
+   *   The migration's parent group.
+   */
+  protected function addGroupParameter(Url $url, $migration_group) {
+    if (!$migration_group) {
+      $migration_group = 'default';
+    }
+    $route_parameters = $url->getRouteParameters() + ['migration_group' => $migration_group];
+    $url->setRouteParameters($route_parameters);
+  }
+
+}
diff --git a/web/modules/migrate_tools/src/Drush9LogMigrateMessage.php b/web/modules/migrate_tools/src/Drush9LogMigrateMessage.php
new file mode 100644
index 0000000000..0217a58e18
--- /dev/null
+++ b/web/modules/migrate_tools/src/Drush9LogMigrateMessage.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Drupal\migrate_tools;
+
+use Drupal\migrate\MigrateMessageInterface;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerAwareTrait;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Print message in drush from migrate message. Drush 9 version.
+ *
+ * @package Drupal\migrate_tools
+ */
+class Drush9LogMigrateMessage implements MigrateMessageInterface, LoggerAwareInterface {
+
+  use LoggerAwareTrait;
+
+  /**
+   * The map between migrate status and drush log levels.
+   *
+   * @var array
+   */
+  protected $map = [
+    'status' => 'notice',
+  ];
+
+  /**
+   * DrushLogMigrateMessage constructor.
+   */
+  public function __construct(LoggerInterface $logger) {
+    $this->setLogger($logger);
+  }
+
+  /**
+   * Output a message from the migration.
+   *
+   * @param string $message
+   *   The message to display.
+   * @param string $type
+   *   The type of message to display.
+   */
+  public function display($message, $type = 'status') {
+    $type = isset($this->map[$type]) ? $this->map[$type] : $type;
+    $this->logger->log($type, $message);
+  }
+
+}
diff --git a/web/modules/migrate_tools/src/DrushLogMigrateMessage.php b/web/modules/migrate_tools/src/DrushLogMigrateMessage.php
new file mode 100644
index 0000000000..4f7e92494c
--- /dev/null
+++ b/web/modules/migrate_tools/src/DrushLogMigrateMessage.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Drupal\migrate_tools;
+
+use Drupal\migrate\MigrateMessageInterface;
+
+/**
+ * Class DrushLogMigrateMessage.
+ *
+ * @package Drupal\migrate_tools
+ */
+class DrushLogMigrateMessage implements MigrateMessageInterface {
+
+  /**
+   * Output a message from the migration.
+   *
+   * @param string $message
+   *   The message to display.
+   * @param string $type
+   *   The type of message to display.
+   *
+   * @see drush_log()
+   */
+  public function display($message, $type = 'status') {
+    drush_log($message, $type);
+  }
+
+}
diff --git a/web/modules/migrate_tools/src/Form/MigrationAddForm.php b/web/modules/migrate_tools/src/Form/MigrationAddForm.php
new file mode 100644
index 0000000000..63c1fc5c31
--- /dev/null
+++ b/web/modules/migrate_tools/src/Form/MigrationAddForm.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Drupal\migrate_tools\Form;
+
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Class MigrationAddForm.
+ *
+ * Provides the add form for our migration entity.
+ *
+ * @package Drupal\migrate_tools\Form
+ *
+ * @ingroup migrate_tools
+ */
+class MigrationAddForm extends MigrationFormBase {
+
+  /**
+   * Returns the actions provided by this form.
+   *
+   * For our add form, we only need to change the text of the submit button.
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   An associative array containing the current state of the form.
+   *
+   * @return array
+   *   An array of supported actions for the current entity form.
+   */
+  protected function actions(array $form, FormStateInterface $form_state) {
+    $actions = parent::actions($form, $form_state);
+    unset($actions['submit']);
+    return $actions;
+  }
+
+}
diff --git a/web/modules/migrate_tools/src/Form/MigrationDeleteForm.php b/web/modules/migrate_tools/src/Form/MigrationDeleteForm.php
new file mode 100644
index 0000000000..593046dbae
--- /dev/null
+++ b/web/modules/migrate_tools/src/Form/MigrationDeleteForm.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Drupal\migrate_tools\Form;
+
+use Drupal\Core\Entity\EntityConfirmFormBase;
+use Drupal\Core\Url;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Provides the delete form for our Migration entity.
+ *
+ * @package Drupal\migrate_tools\Form
+ *
+ * @ingroup migrate_tools
+ */
+class MigrationDeleteForm extends EntityConfirmFormBase {
+
+  /**
+   * Gathers a confirmation question.
+   *
+   * @return string
+   *   Translated string.
+   */
+  public function getQuestion() {
+    return $this->t('Are you sure you want to delete migration %label?', [
+      '%label' => $this->entity->label(),
+    ]);
+  }
+
+  /**
+   * Gather the confirmation text.
+   *
+   * @return string
+   *   Translated string.
+   */
+  public function getConfirmText() {
+    return $this->t('Delete Migration');
+  }
+
+  /**
+   * Gets the cancel URL.
+   *
+   * @return \Drupal\Core\Url
+   *   The URL to go to if the user cancels the deletion.
+   */
+  public function getCancelUrl() {
+    return new Url('entity.migration_group.list');
+  }
+
+  /**
+   * The submit handler for the confirm form.
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   An associative array containing the current state of the form.
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    // Delete the entity.
+    $this->entity->delete();
+
+    // Set a message that the entity was deleted.
+    drupal_set_message(t('Migration %label was deleted.', [
+      '%label' => $this->entity->label(),
+    ]));
+
+    // Redirect the user to the list controller when complete.
+    $form_state->setRedirectUrl($this->getCancelUrl());
+  }
+
+}
diff --git a/web/modules/migrate_tools/src/Form/MigrationEditForm.php b/web/modules/migrate_tools/src/Form/MigrationEditForm.php
new file mode 100644
index 0000000000..214d0ae35d
--- /dev/null
+++ b/web/modules/migrate_tools/src/Form/MigrationEditForm.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Drupal\migrate_tools\Form;
+
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Url;
+
+/**
+ * Provides the edit form for our Migration entity.
+ *
+ * @package Drupal\migrate_tools\Form
+ *
+ * @ingroup migrate_tools
+ */
+class MigrationEditForm extends MigrationFormBase {
+
+  /**
+   * Returns the actions provided by this form.
+   *
+   * For the edit form, we only need to change the text of the submit button.
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   An associative array containing the current state of the form.
+   *
+   * @return array
+   *   An array of supported actions for the current entity form.
+   */
+  public function actions(array $form, FormStateInterface $form_state) {
+    $actions = parent::actions($form, $form_state);
+    $actions['submit']['#value'] = t('Update Migration');
+
+    return $actions;
+  }
+
+  /**
+   * Add group route parameter.
+   *
+   * @param \Drupal\Core\Url $url
+   *   The URL associated with an operation.
+   * @param string $migration_group
+   *   The migration's parent group.
+   */
+  protected function addGroupParameter(Url $url, $migration_group) {
+    $route_parameters = $url->getRouteParameters() + ['migration_group' => $migration_group];
+    $url->setRouteParameters($route_parameters);
+  }
+
+}
diff --git a/web/modules/migrate_tools/src/Form/MigrationExecuteForm.php b/web/modules/migrate_tools/src/Form/MigrationExecuteForm.php
new file mode 100644
index 0000000000..d05b736ebb
--- /dev/null
+++ b/web/modules/migrate_tools/src/Form/MigrationExecuteForm.php
@@ -0,0 +1,212 @@
+<?php
+
+namespace Drupal\migrate_tools\Form;
+
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Form\FormBase;
+use Drupal\migrate\MigrateMessage;
+use Drupal\migrate\Plugin\MigrationInterface;
+use Drupal\migrate\Plugin\MigrationPluginManagerInterface;
+use Drupal\migrate_tools\MigrateBatchExecutable;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * This form is specifically for configuring process pipelines.
+ */
+class MigrationExecuteForm extends FormBase {
+
+  /**
+   * Plugin manager for migration plugins.
+   *
+   * @var \Drupal\migrate\Plugin\MigrationPluginManagerInterface
+   */
+  protected $migrationPluginManager;
+
+  /**
+   * Constructs a new MigrationExecuteForm object.
+   *
+   * @param \Drupal\migrate\Plugin\MigrationPluginManagerInterface $migration_plugin_manager
+   *   The plugin manager for config entity-based migrations.
+   */
+  public function __construct(MigrationPluginManagerInterface $migration_plugin_manager) {
+    $this->migrationPluginManager = $migration_plugin_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('plugin.manager.migration')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'migration_execute_form';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+
+    $form = [];
+
+    $form['operations'] = $this->migrateMigrateOperations();
+
+    return $form;
+  }
+
+  /**
+   * Get Operations.
+   */
+  private function migrateMigrateOperations() {
+    // Build the 'Update options' form.
+    $form = [
+      '#type' => 'fieldset',
+      '#title' => t('Operations'),
+    ];
+    $options = [
+      'import' => t('Import'),
+      'rollback' => t('Rollback'),
+      'stop' => t('Stop'),
+      'reset' => t('Reset'),
+    ];
+    $form['operation'] = [
+      '#type' => 'select',
+      '#title' => t('Choose an operation to run'),
+      '#options' => $options,
+      '#default_value' => 'import',
+      '#required' => TRUE,
+    ];
+    $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,
+    ];
+
+    $form['options'] = [
+      '#type' => 'fieldset',
+      '#title' => t('Options'),
+      '#collapsible' => TRUE,
+      '#collapsed' => TRUE,
+    ];
+    $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'),
+    ];
+    $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.'),
+    ];
+    // @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;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+
+    $operation = $form_state->getValue('operation');
+
+    if ($form_state->getValue('limit')) {
+      $limit = $form_state->getValue('limit');
+    }
+    else {
+      $limit = 0;
+    }
+
+    if ($form_state->getValue('update')) {
+      $update = $form_state->getValue('update');
+    }
+    else {
+      $update = 0;
+    }
+    if ($form_state->getValue('force')) {
+      $force = $form_state->getValue('force');
+    }
+    else {
+      $force = 0;
+    }
+
+    $migration = \Drupal::routeMatch()->getParameter('migration');
+    if ($migration) {
+      /** @var \Drupal\migrate\Plugin\MigrationInterface $migration_plugin */
+      $migration_plugin = $this->migrationPluginManager->createInstance($migration->id(), $migration->toArray());
+      $migrateMessage = new MigrateMessage();
+
+      switch ($operation) {
+        case 'import':
+
+          $options = [
+            'limit' => $limit,
+            'update' => $update,
+            'force' => $force,
+          ];
+
+          $executable = new MigrateBatchExecutable($migration_plugin, $migrateMessage, $options);
+          $executable->batchImport();
+
+          break;
+
+        case 'rollback':
+
+          $options = [
+            'limit' => $limit,
+            'update' => $update,
+            'force' => $force,
+          ];
+
+          $executable = new MigrateBatchExecutable($migration_plugin, $migrateMessage, $options);
+          $executable->rollback();
+
+          break;
+
+        case 'stop':
+
+          $migration_plugin->interruptMigration(MigrationInterface::RESULT_STOPPED);
+
+          break;
+
+        case 'reset':
+
+          $migration_plugin->setStatus(MigrationInterface::STATUS_IDLE);
+
+          break;
+
+      }
+    }
+  }
+
+}
diff --git a/web/modules/migrate_tools/src/Form/MigrationFormBase.php b/web/modules/migrate_tools/src/Form/MigrationFormBase.php
new file mode 100644
index 0000000000..3ba6a0f55f
--- /dev/null
+++ b/web/modules/migrate_tools/src/Form/MigrationFormBase.php
@@ -0,0 +1,184 @@
+<?php
+
+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.
+ *
+ * @package Drupal\migrate_tools\Form
+ *
+ * @ingroup migrate_tools
+ */
+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().
+   *
+   * Builds the entity add/edit form.
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   An associative array containing the current state of the form.
+   *
+   * @return array
+   *   An associative array containing the migration add/edit form.
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    // Get anything we need from the base class.
+    $form = parent::buildForm($form, $form_state);
+
+    /** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
+    $migration = $this->entity;
+
+    $form['warning'] = [
+      '#markup' => $this->t('Creating migrations is not yet supported. See <a href=":url">:url</a>', [
+        ':url' => 'https://www.drupal.org/node/2573241',
+      ]),
+    ];
+
+    // Build the form.
+    $form['label'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Label'),
+      '#maxlength' => 255,
+      '#default_value' => $migration->label(),
+      '#required' => TRUE,
+    ];
+    $form['id'] = [
+      '#type' => 'machine_name',
+      '#title' => $this->t('Machine name'),
+      '#default_value' => $migration->id(),
+      '#machine_name' => [
+        'exists' => [$this, 'exists'],
+        'replace_pattern' => '([^a-z0-9_]+)|(^custom$)',
+        'error' => 'The machine-readable name must be unique, and can only contain lowercase letters, numbers, and underscores. Additionally, it can not be the reserved word "custom".',
+      ],
+      '#disabled' => !$migration->isNew(),
+    ];
+
+    $groups = MigrationGroup::loadMultiple();
+    $group_options = [];
+    foreach ($groups as $group) {
+      $group_options[$group->id()] = $group->label();
+    }
+    if (!$migration->get('migration_group') && isset($group_options['default'])) {
+      $migration->set('migration_group', 'default');
+    }
+
+    $form['migration_group'] = [
+      '#type' => 'select',
+      '#title' => $this->t('Migration Group'),
+      '#empty_value' => '',
+      '#default_value' => $migration->get('migration_group'),
+      '#options' => $group_options,
+      '#description' => $this->t('Assign this migration to an existing group.'),
+    ];
+
+    return $form;
+  }
+
+  /**
+   * Checks for an existing migration group.
+   *
+   * @param string|int $entity_id
+   *   The entity ID.
+   * @param array $element
+   *   The form element.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   *
+   * @return bool
+   *   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 the entity ID to see if its in use.
+    $result = $query->condition('id', $element['#field_prefix'] . $entity_id)
+      ->execute();
+
+    // We don't need to return the ID, only if it exists or not.
+    return (bool) $result;
+  }
+
+  /**
+   * Overrides Drupal\Core\Entity\EntityFormController::actions().
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   An associative array containing the current state of the form.
+   *
+   * @return array
+   *   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.
+    $actions = parent::actions($form, $form_state);
+
+    // Change the submit button text.
+    $actions['submit']['#value'] = $this->t('Save');
+
+    // Return the result.
+    return $actions;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function save(array $form, FormStateInterface $form_state) {
+    $migration = $this->getEntity();
+    $status = $migration->save();
+
+    if ($status == SAVED_UPDATED) {
+      // If we edited an existing entity...
+      drupal_set_message($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()]));
+    }
+
+    // Redirect the user back to the listing route after the save operation.
+    $form_state->setRedirect('entity.migration.list',
+      ['migration_group' => $migration->get('migration_group')]);
+  }
+
+}
diff --git a/web/modules/migrate_tools/src/Form/MigrationGroupAddForm.php b/web/modules/migrate_tools/src/Form/MigrationGroupAddForm.php
new file mode 100644
index 0000000000..3efbf8c884
--- /dev/null
+++ b/web/modules/migrate_tools/src/Form/MigrationGroupAddForm.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Drupal\migrate_tools\Form;
+
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Class MigrationGroupAddForm.
+ *
+ * Provides the add form for our migration_group entity.
+ *
+ * @package Drupal\migrate_tools\Form
+ *
+ * @ingroup migrate_tools
+ */
+class MigrationGroupAddForm extends MigrationGroupFormBase {
+
+  /**
+   * Returns the actions provided by this form.
+   *
+   * For our add form, we only need to change the text of the submit button.
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   An associative array containing the current state of the form.
+   *
+   * @return array
+   *   An array of supported actions for the current entity form.
+   */
+  protected function actions(array $form, FormStateInterface $form_state) {
+    $actions = parent::actions($form, $form_state);
+    $actions['submit']['#value'] = $this->t('Create Migration Group');
+    return $actions;
+  }
+
+}
diff --git a/web/modules/migrate_tools/src/Form/MigrationGroupDeleteForm.php b/web/modules/migrate_tools/src/Form/MigrationGroupDeleteForm.php
new file mode 100644
index 0000000000..98d1baf700
--- /dev/null
+++ b/web/modules/migrate_tools/src/Form/MigrationGroupDeleteForm.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Drupal\migrate_tools\Form;
+
+use Drupal\Core\Entity\EntityConfirmFormBase;
+use Drupal\Core\Url;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Provides the delete form for our Migration Group entity.
+ *
+ * @package Drupal\migrate_tools\Form
+ *
+ * @ingroup migrate_tools
+ */
+class MigrationGroupDeleteForm extends EntityConfirmFormBase {
+
+  /**
+   * Gathers a confirmation question.
+   *
+   * @return string
+   *   Translated string.
+   */
+  public function getQuestion() {
+    return $this->t('Are you sure you want to delete migration group %label?', [
+      '%label' => $this->entity->label(),
+    ]);
+  }
+
+  /**
+   * Gather the confirmation text.
+   *
+   * @return string
+   *   Translated string.
+   */
+  public function getConfirmText() {
+    return $this->t('Delete Migration Group');
+  }
+
+  /**
+   * Gets the cancel URL.
+   *
+   * @return \Drupal\Core\Url
+   *   The URL to go to if the user cancels the deletion.
+   */
+  public function getCancelUrl() {
+    return new Url('entity.migration_group.list');
+  }
+
+  /**
+   * The submit handler for the confirm form.
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   An associative array containing the current state of the form.
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    // Delete the entity.
+    $this->entity->delete();
+
+    // Set a message that the entity was deleted.
+    drupal_set_message(t('Migration group %label was deleted.', [
+      '%label' => $this->entity->label(),
+    ]));
+
+    // Redirect the user to the list controller when complete.
+    $form_state->setRedirectUrl($this->getCancelUrl());
+  }
+
+}
diff --git a/web/modules/migrate_tools/src/Form/MigrationGroupEditForm.php b/web/modules/migrate_tools/src/Form/MigrationGroupEditForm.php
new file mode 100644
index 0000000000..1eedc2d261
--- /dev/null
+++ b/web/modules/migrate_tools/src/Form/MigrationGroupEditForm.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Drupal\migrate_tools\Form;
+
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Provides the edit form for our Migration Group entity.
+ *
+ * @package Drupal\migrate_tools\Form
+ *
+ * @ingroup migrate_tools
+ */
+class MigrationGroupEditForm extends MigrationGroupFormBase {
+
+  /**
+   * Returns the actions provided by this form.
+   *
+   * For the edit form, we only need to change the text of the submit button.
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   An associative array containing the current state of the form.
+   *
+   * @return array
+   *   An array of supported actions for the current entity form.
+   */
+  public function actions(array $form, FormStateInterface $form_state) {
+    $actions = parent::actions($form, $form_state);
+    $actions['submit']['#value'] = 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
new file mode 100644
index 0000000000..d60944a759
--- /dev/null
+++ b/web/modules/migrate_tools/src/Form/MigrationGroupFormBase.php
@@ -0,0 +1,172 @@
+<?php
+
+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.
+ *
+ * @package Drupal\migrate_tools\Form
+ *
+ * @ingroup migrate_tools
+ */
+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().
+   *
+   * Builds the entity add/edit form.
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   An associative array containing the current state of the form.
+   *
+   * @return array
+   *   An associative array containing the migration group add/edit form.
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    // Get anything we need from the base class.
+    $form = parent::buildForm($form, $form_state);
+
+    /** @var \Drupal\migrate_plus\Entity\MigrationGroupInterface $migration_group */
+    $migration_group = $this->entity;
+
+    // Build the form.
+    $form['label'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Label'),
+      '#maxlength' => 255,
+      '#default_value' => $migration_group->label(),
+      '#required' => TRUE,
+    ];
+    $form['id'] = [
+      '#type' => 'machine_name',
+      '#title' => $this->t('Machine name'),
+      '#default_value' => $migration_group->id(),
+      '#machine_name' => [
+        'exists' => [$this, 'exists'],
+        'replace_pattern' => '([^a-z0-9_]+)|(^custom$)',
+        'error' => 'The machine-readable name must be unique, and can only contain lowercase letters, numbers, and underscores. Additionally, it can not be the reserved word "custom".',
+      ],
+      '#disabled' => !$migration_group->isNew(),
+    ];
+    $form['description'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Description'),
+      '#maxlength' => 255,
+      '#default_value' => $migration_group->get('description'),
+    ];
+    $form['source_type'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Source type'),
+      '#description' => $this->t('Type of source system the group is migrating from, for example "Drupal 6" or "WordPress 4".'),
+      '#maxlength' => 255,
+      '#default_value' => $migration_group->get('source_type'),
+    ];
+
+    // Return the form.
+    return $form;
+  }
+
+  /**
+   * Checks for an existing migration group.
+   *
+   * @param string|int $entity_id
+   *   The entity ID.
+   * @param array $element
+   *   The form element.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   *
+   * @return bool
+   *   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 the entity ID to see if its in use.
+    $result = $query->condition('id', $element['#field_prefix'] . $entity_id)
+      ->execute();
+
+    // We don't need to return the ID, only if it exists or not.
+    return (bool) $result;
+  }
+
+  /**
+   * Overrides Drupal\Core\Entity\EntityFormController::actions().
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   An associative array containing the current state of the form.
+   *
+   * @return array
+   *   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.
+    $actions = parent::actions($form, $form_state);
+
+    // Change the submit button text.
+    $actions['submit']['#value'] = $this->t('Save');
+
+    // Return the result.
+    return $actions;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function save(array $form, FormStateInterface $form_state) {
+    $migration_group = $this->getEntity();
+    $status = $migration_group->save();
+
+    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()]));
+    }
+    else {
+      // If we created a new entity...
+      drupal_set_message($this->t('Migration group %label has been added.', ['%label' => $migration_group->label()]));
+    }
+
+    // Redirect the user back to the listing route after the save operation.
+    $form_state->setRedirect('entity.migration_group.list');
+  }
+
+}
diff --git a/web/modules/migrate_tools/src/Form/SourceCsvForm.php b/web/modules/migrate_tools/src/Form/SourceCsvForm.php
new file mode 100644
index 0000000000..40b5dc3bac
--- /dev/null
+++ b/web/modules/migrate_tools/src/Form/SourceCsvForm.php
@@ -0,0 +1,453 @@
+<?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/MigrateBatchExecutable.php b/web/modules/migrate_tools/src/MigrateBatchExecutable.php
new file mode 100644
index 0000000000..ee24f64d77
--- /dev/null
+++ b/web/modules/migrate_tools/src/MigrateBatchExecutable.php
@@ -0,0 +1,295 @@
+<?php
+
+namespace Drupal\migrate_tools;
+
+use Drupal\migrate\MigrateMessage;
+use Drupal\migrate\MigrateMessageInterface;
+use Drupal\migrate\Plugin\Migration;
+use Drupal\migrate\Plugin\MigrationInterface;
+
+/**
+ * Defines a migrate executable class for batch migrations through UI.
+ */
+class MigrateBatchExecutable extends MigrateExecutable {
+
+  /**
+   * Representing a batch import operation.
+   */
+  const BATCH_IMPORT = 1;
+
+  /**
+   * Indicates if we need to update existing rows or skip them.
+   *
+   * @var int
+   */
+  protected $updateExistingRows = 0;
+
+  /**
+   * Indicates if we need import dependent migrations also.
+   *
+   * @var int
+   */
+  protected $checkDependencies = 0;
+
+  /**
+   * The current batch context.
+   *
+   * @var array
+   */
+  protected $batchContext = [];
+
+  /**
+   * Plugin manager for migration plugins.
+   *
+   * @var \Drupal\migrate\Plugin\MigrationPluginManagerInterface
+   */
+  protected $migrationPluginManager;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(MigrationInterface $migration, MigrateMessageInterface $message, array $options = []) {
+
+    if (isset($options['update'])) {
+      $this->updateExistingRows = $options['update'];
+    }
+
+    if (isset($options['force'])) {
+      $this->checkDependencies = $options['force'];
+    }
+
+    parent::__construct($migration, $message, $options);
+    $this->migrationPluginManager = \Drupal::getContainer()->get('plugin.manager.migration');
+  }
+
+  /**
+   * Sets the current batch content so listeners can update the messages.
+   *
+   * @param array $context
+   *   The batch context.
+   */
+  public function setBatchContext(array &$context) {
+    $this->batchContext = &$context;
+  }
+
+  /**
+   * Gets a reference to the current batch context.
+   *
+   * @return array
+   *   The batch context.
+   */
+  public function &getBatchContext() {
+    return $this->batchContext;
+  }
+
+  /**
+   * Setup batch operations for running the migration.
+   */
+  public function batchImport() {
+    // Create the batch operations for each migration that needs to be executed.
+    // This includes the migration for this executable, but also the dependent
+    // migrations.
+    $operations = $this->batchOperations([$this->migration], 'import', [
+      'limit' => $this->itemLimit,
+      'update' => $this->updateExistingRows,
+      'force' => $this->checkDependencies,
+    ]);
+
+    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()]),
+        'finished' => '\Drupal\migrate_tools\MigrateBatchExecutable::batchFinishedImport',
+      ];
+
+      batch_set($batch);
+    }
+  }
+
+  /**
+   * Helper to generate the batch operations for importing migrations.
+   *
+   * @param \Drupal\migrate\Plugin\MigrationInterface[] $migrations
+   *   The migrations.
+   * @param string $operation
+   *   The batch operation to perform.
+   * @param array $options
+   *   The migration options.
+   *
+   * @return array
+   *   The batch operations to perform.
+   */
+  protected function batchOperations(array $migrations, $operation, array $options = []) {
+    $operations = [];
+    foreach ($migrations as $id => $migration) {
+
+      if (!empty($options['update'])) {
+        $migration->getIdMap()->prepareUpdate();
+      }
+
+      if (!empty($options['force'])) {
+        $migration->set('requirements', []);
+      }
+      else {
+        $dependencies = $migration->getMigrationDependencies();
+        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, [
+            'limit' => 0,
+            'update' => $options['update'],
+            'force' => $options['force'],
+          ]);
+        }
+      }
+
+      $operations[] = [
+        '\Drupal\migrate_tools\MigrateBatchExecutable::batchProcessImport',
+        [$migration->id(), $options],
+      ];
+    }
+
+    return $operations;
+  }
+
+  /**
+   * Batch 'operation' callback.
+   *
+   * @param string $migration_id
+   *   The migration id.
+   * @param array $options
+   *   The batch executable options.
+   * @param array $context
+   *   The sandbox context.
+   */
+  public static function batchProcessImport($migration_id, array $options, array &$context) {
+    if (empty($context['sandbox'])) {
+      $context['finished'] = 0;
+      $context['sandbox'] = [];
+      $context['sandbox']['total'] = 0;
+      $context['sandbox']['counter'] = 0;
+      $context['sandbox']['batch_limit'] = 0;
+      $context['sandbox']['operation'] = MigrateBatchExecutable::BATCH_IMPORT;
+    }
+
+    // Prepare the migration executable.
+    $message = new MigrateMessage();
+    /** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
+    $migration = \Drupal::getContainer()->get('plugin.manager.migration')->createInstance($migration_id);
+    $executable = new MigrateBatchExecutable($migration, $message, $options);
+
+    if (empty($context['sandbox']['total'])) {
+      $context['sandbox']['total'] = $executable->getSource()->count();
+      $context['sandbox']['batch_limit'] = $executable->calculateBatchLimit($context);
+      $context['results'][$migration->id()] = [
+        '@numitems' => 0,
+        '@created' => 0,
+        '@updated' => 0,
+        '@failures' => 0,
+        '@ignored' => 0,
+        '@name' => $migration->id(),
+      ];
+    }
+
+    // Every iteration, we reset out batch counter.
+    $context['sandbox']['batch_counter'] = 0;
+
+    // Make sure we know our batch context.
+    $executable->setBatchContext($context);
+
+    // Do the import.
+    $result = $executable->import();
+
+    // Store the result; will need to combine the results of all our iterations.
+    $context['results'][$migration->id()] = [
+      '@numitems' => $context['results'][$migration->id()]['@numitems'] + $executable->getProcessedCount(),
+      '@created' => $context['results'][$migration->id()]['@created'] + $executable->getCreatedCount(),
+      '@updated' => $context['results'][$migration->id()]['@updated'] + $executable->getUpdatedCount(),
+      '@failures' => $context['results'][$migration->id()]['@failures'] + $executable->getFailedCount(),
+      '@ignored' => $context['results'][$migration->id()]['@ignored'] + $executable->getIgnoredCount(),
+      '@name' => $migration->id(),
+    ];
+
+    // Do some housekeeping.
+    if (
+      $result != MigrationInterface::RESULT_INCOMPLETE
+    ) {
+      $context['finished'] = 1;
+    }
+    else {
+      $context['sandbox']['counter'] = $context['results'][$migration->id()]['@numitems'];
+      if ($context['sandbox']['counter'] <= $context['sandbox']['total']) {
+        $context['finished'] = ((float) $context['sandbox']['counter'] / (float) $context['sandbox']['total']);
+        $context['message'] = t('Importing %migration (@percent%).', [
+          '%migration' => $migration->label(),
+          '@percent' => (int) ($context['finished'] * 100),
+        ]);
+      }
+    }
+
+  }
+
+  /**
+   * Finished callback for import batches.
+   *
+   * @param bool $success
+   *   A boolean indicating whether the batch has completed successfully.
+   * @param array $results
+   *   The value set in $context['results'] by callback_batch_operation().
+   * @param array $operations
+   *   If $success is FALSE, contains the operations that remained unprocessed.
+   */
+  public static function batchFinishedImport($success, array $results, array $operations) {
+    if ($success) {
+      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'],
+          $singular_message,
+          $plural_message,
+          $result));
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function checkStatus() {
+    $status = parent::checkStatus();
+
+    if ($status == MigrationInterface::RESULT_COMPLETED) {
+      // Do some batch housekeeping.
+      $context = $this->getBatchContext();
+
+      if (!empty($context['sandbox']) && $context['sandbox']['operation'] == MigrateBatchExecutable::BATCH_IMPORT) {
+        $context['sandbox']['batch_counter']++;
+        if ($context['sandbox']['batch_counter'] >= $context['sandbox']['batch_limit']) {
+          $status = MigrationInterface::RESULT_INCOMPLETE;
+        }
+      }
+    }
+
+    return $status;
+  }
+
+  /**
+   * Calculates how much a single batch iteration will handle.
+   *
+   * @param array $context
+   *   The sandbox context.
+   *
+   * @return float
+   *   The batch limit.
+   */
+  public function calculateBatchLimit(array $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
new file mode 100644
index 0000000000..2282ab8cbc
--- /dev/null
+++ b/web/modules/migrate_tools/src/MigrateExecutable.php
@@ -0,0 +1,395 @@
+<?php
+
+namespace Drupal\migrate_tools;
+
+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_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;
+
+/**
+ * Defines a migrate executable class for drush.
+ */
+class MigrateExecutable extends MigrateExecutableBase {
+
+  /**
+   * Counters of map statuses.
+   *
+   * @var array
+   *   Set of counters, keyed by MigrateIdMapInterface::STATUS_* constant.
+   */
+  protected $saveCounters = [
+    MigrateIdMapInterface::STATUS_FAILED => 0,
+    MigrateIdMapInterface::STATUS_IGNORED => 0,
+    MigrateIdMapInterface::STATUS_IMPORTED => 0,
+    MigrateIdMapInterface::STATUS_NEEDS_UPDATE => 0,
+  ];
+
+  /**
+   * Counter of map saves, used to detect the item limit threshold.
+   *
+   * @var int
+   */
+  protected $itemLimitCounter = 0;
+
+  /**
+   * Counter of map deletions.
+   *
+   * @var int
+   */
+  protected $deleteCounter = 0;
+
+  /**
+   * Maximum number of items to process in this migration.
+   *
+   * 0 indicates no limit is to be applied.
+   *
+   * @var int
+   */
+  protected $itemLimit = 0;
+
+  /**
+   * Frequency (in items) at which progress messages should be emitted.
+   *
+   * @var int
+   */
+  protected $feedback = 0;
+
+  /**
+   * List of specific source IDs to import.
+   *
+   * @var array
+   */
+  protected $idlist = [];
+
+  /**
+   * Count of number of items processed so far in this migration.
+   *
+   * @var int
+   */
+  protected $counter = 0;
+
+  /**
+   * Whether the destination item exists before saving.
+   *
+   * @var bool
+   */
+  protected $preExistingItem = FALSE;
+
+  /**
+   * List of event listeners we have registered.
+   *
+   * @var array
+   */
+  protected $listeners = [];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(MigrationInterface $migration, MigrateMessageInterface $message, array $options = []) {
+    parent::__construct($migration, $message);
+    if (isset($options['limit'])) {
+      $this->itemLimit = $options['limit'];
+    }
+    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->listeners[MigrateEvents::MAP_SAVE] = [$this, 'onMapSave'];
+    $this->listeners[MigrateEvents::MAP_DELETE] = [$this, 'onMapDelete'];
+    $this->listeners[MigrateEvents::POST_IMPORT] = [$this, 'onPostImport'];
+    $this->listeners[MigrateEvents::POST_ROLLBACK] = [$this, 'onPostRollback'];
+    $this->listeners[MigrateEvents::PRE_ROW_SAVE] = [$this, 'onPreRowSave'];
+    $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);
+    }
+  }
+
+  /**
+   * Count up any map save events.
+   *
+   * @param \Drupal\migrate\Event\MigrateMapSaveEvent $event
+   *   The map event.
+   */
+  public function onMapSave(MigrateMapSaveEvent $event) {
+    // Only count saves for this migration.
+    if ($event->getMap()->getQualifiedMapTableName() == $this->migration->getIdMap()->getQualifiedMapTableName()) {
+      $fields = $event->getFields();
+      $this->itemLimitCounter++;
+      // Distinguish between creation and update.
+      if ($fields['source_row_status'] == MigrateIdMapInterface::STATUS_IMPORTED &&
+        $this->preExistingItem
+      ) {
+        $this->saveCounters[MigrateIdMapInterface::STATUS_NEEDS_UPDATE]++;
+      }
+      else {
+        $this->saveCounters[$fields['source_row_status']]++;
+      }
+    }
+  }
+
+  /**
+   * Count up any rollback events.
+   *
+   * @param \Drupal\migrate\Event\MigrateMapDeleteEvent $event
+   *   The map event.
+   */
+  public function onMapDelete(MigrateMapDeleteEvent $event) {
+    $this->deleteCounter++;
+  }
+
+  /**
+   * Return the number of items created.
+   *
+   * @return int
+   *   The number of items created.
+   */
+  public function getCreatedCount() {
+    return $this->saveCounters[MigrateIdMapInterface::STATUS_IMPORTED];
+  }
+
+  /**
+   * Return the number of items updated.
+   *
+   * @return int
+   *   The updated count.
+   */
+  public function getUpdatedCount() {
+    return $this->saveCounters[MigrateIdMapInterface::STATUS_NEEDS_UPDATE];
+  }
+
+  /**
+   * Return the number of items ignored.
+   *
+   * @return int
+   *   The ignored count.
+   */
+  public function getIgnoredCount() {
+    return $this->saveCounters[MigrateIdMapInterface::STATUS_IGNORED];
+  }
+
+  /**
+   * Return the number of items that failed.
+   *
+   * @return int
+   *   The failed count.
+   */
+  public function getFailedCount() {
+    return $this->saveCounters[MigrateIdMapInterface::STATUS_FAILED];
+  }
+
+  /**
+   * Return the total number of items processed.
+   *
+   * Note that STATUS_NEEDS_UPDATE is not counted, since this is typically set
+   * on stubs created as side effects, not on the primary item being imported.
+   *
+   * @return int
+   *   The processed count.
+   */
+  public function getProcessedCount() {
+    return $this->saveCounters[MigrateIdMapInterface::STATUS_IMPORTED] +
+      $this->saveCounters[MigrateIdMapInterface::STATUS_NEEDS_UPDATE] +
+      $this->saveCounters[MigrateIdMapInterface::STATUS_IGNORED] +
+      $this->saveCounters[MigrateIdMapInterface::STATUS_FAILED];
+  }
+
+  /**
+   * Return the number of items rolled back.
+   *
+   * @return int
+   *   The rollback count.
+   */
+  public function getRollbackCount() {
+    return $this->deleteCounter;
+  }
+
+  /**
+   * Reset all the per-status counters to 0.
+   */
+  protected function resetCounters() {
+    foreach ($this->saveCounters as $status => $count) {
+      $this->saveCounters[$status] = 0;
+    }
+    $this->deleteCounter = 0;
+  }
+
+  /**
+   * React to migration completion.
+   *
+   * @param \Drupal\migrate\Event\MigrateImportEvent $event
+   *   The map event.
+   */
+  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));
+    $this->progressMessage();
+    $this->removeListeners();
+  }
+
+  /**
+   * Clean up all our event listeners.
+   */
+  protected function removeListeners() {
+    foreach ($this->listeners as $event => $listener) {
+      \Drupal::service('event_dispatcher')->removeListener($event, $listener);
+    }
+  }
+
+  /**
+   * Emit information on what we've done.
+   *
+   * Either since the last feedback or the beginning of this migration.
+   *
+   * @param bool $done
+   *   TRUE if this is the last items to process. Otherwise FALSE.
+   */
+  protected function progressMessage($done = TRUE) {
+    $processed = $this->getProcessedCount();
+    if ($done) {
+      $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'";
+    }
+    else {
+      $singular_message = "Processed 1 item (@created created, @updated updated, @failures failed, @ignored ignored) - continuing with '@name'";
+      $plural_message = "Processed @numitems items (@created created, @updated updated, @failures failed, @ignored ignored) - continuing with '@name'";
+    }
+    $this->message->display(\Drupal::translation()->formatPlural($processed,
+      $singular_message, $plural_message,
+        [
+          '@numitems' => $processed,
+          '@created' => $this->getCreatedCount(),
+          '@updated' => $this->getUpdatedCount(),
+          '@failures' => $this->getFailedCount(),
+          '@ignored' => $this->getIgnoredCount(),
+          '@name' => $this->migration->id(),
+        ]
+    ));
+  }
+
+  /**
+   * React to rollback completion.
+   *
+   * @param \Drupal\migrate\Event\MigrateRollbackEvent $event
+   *   The map event.
+   */
+  public function onPostRollback(MigrateRollbackEvent $event) {
+    $this->rollbackMessage();
+    $this->removeListeners();
+  }
+
+  /**
+   * Emit information on what we've done.
+   *
+   * Either since the last feedback or the beginning of this migration.
+   *
+   * @param bool $done
+   *   TRUE if this is the last items to rollback. Otherwise FALSE.
+   */
+  protected function rollbackMessage($done = TRUE) {
+    $rolled_back = $this->getRollbackCount();
+    if ($done) {
+      $singular_message = "Rolled back 1 item - done with '@name'";
+      $plural_message = "Rolled back @numitems items - done with '@name'";
+    }
+    else {
+      $singular_message = "Rolled back 1 item - continuing with '@name'";
+      $plural_message = "Rolled back @numitems items - continuing with '@name'";
+    }
+    $this->message->display(\Drupal::translation()->formatPlural($rolled_back,
+      $singular_message, $plural_message,
+      [
+        '@numitems' => $rolled_back,
+        '@name' => $this->migration->id(),
+      ]
+    ));
+  }
+
+  /**
+   * React to an item about to be imported.
+   *
+   * @param \Drupal\migrate\Event\MigratePreRowSaveEvent $event
+   *   The pre-save event.
+   */
+  public function onPreRowSave(MigratePreRowSaveEvent $event) {
+    $id_map = $event->getRow()->getIdMap();
+    if (!empty($id_map['destid1'])) {
+      $this->preExistingItem = TRUE;
+    }
+    else {
+      $this->preExistingItem = FALSE;
+    }
+  }
+
+  /**
+   * React to item rollback.
+   *
+   * @param \Drupal\migrate\Event\MigrateRowDeleteEvent $event
+   *   The post-save event.
+   */
+  public function onPostRowDelete(MigrateRowDeleteEvent $event) {
+    if ($this->feedback && ($this->deleteCounter) && $this->deleteCounter % $this->feedback == 0) {
+      $this->rollbackMessage(FALSE);
+      $this->resetCounters();
+    }
+  }
+
+  /**
+   * React to a new row.
+   *
+   * @param \Drupal\migrate_plus\Event\MigratePrepareRowEvent $event
+   *   The prepare-row 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) {
+      $this->progressMessage(FALSE);
+      $this->resetCounters();
+    }
+    $this->counter++;
+    if ($this->itemLimit && ($this->itemLimitCounter + 1) >= $this->itemLimit) {
+      $event->getMigration()->interruptMigration(MigrationInterface::RESULT_COMPLETED);
+    }
+
+  }
+
+}
diff --git a/web/modules/migrate_tools/src/Routing/RouteProcessor.php b/web/modules/migrate_tools/src/Routing/RouteProcessor.php
new file mode 100644
index 0000000000..fe0c5e623f
--- /dev/null
+++ b/web/modules/migrate_tools/src/Routing/RouteProcessor.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Drupal\migrate_tools\Routing;
+
+use Drupal\Core\Render\BubbleableMetadata;
+use Drupal\Core\RouteProcessor\OutboundRouteProcessorInterface;
+use Symfony\Component\Routing\Route;
+
+/**
+ * Route processor to expand migrate_group.
+ */
+class RouteProcessor implements OutboundRouteProcessorInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function processOutbound($route_name, Route $route, array &$parameters, BubbleableMetadata $bubbleable_metadata = NULL) {
+    if ($route->hasDefault('_migrate_group')) {
+      if ($migration = \Drupal::entityTypeManager()->getStorage('migration')->load($parameters['migration'])) {
+        if ($group = $migration->get('migration_group')) {
+          $parameters['migration_group'] = $group;
+        }
+      }
+    }
+  }
+
+}
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
new file mode 100644
index 0000000000..0d600a9ffc
--- /dev/null
+++ b/web/modules/migrate_tools/tests/modules/csv_source_test/config/install/migrate_plus.migration.csv_source_test.yml
@@ -0,0 +1,33 @@
+langcode: en
+status: true
+dependencies: {  }
+id: csv_source_test
+label: Test edit of column aliases for CSV source plugin
+class: null
+field_plugin_method: null
+cck_plugin_method: null
+migration_tags: {  }
+migration_group: csv_test
+source:
+  plugin: csv
+  path: 'public://test.csv'
+  header_row_count: 1
+  enclosure: '"'
+  keys:
+    - vid
+  column_names:
+    0:
+      vid: 'Vocabulary Id'
+    1:
+      name: 'Name'
+    2:
+      description: 'Description'
+process:
+  vid: vid
+  name: name
+  description: description
+destination:
+  plugin: entity:taxonomy_vocabulary
+migration_dependencies:
+  required: {  }
+  optional: {  }
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
new file mode 100644
index 0000000000..1c2b05931e
--- /dev/null
+++ b/web/modules/migrate_tools/tests/modules/csv_source_test/config/install/migrate_plus.migration_group.csv_test.yml
@@ -0,0 +1,4 @@
+id: csv_test
+label: CSV source plugin edit test
+description: Test editting of source plugin via the UI
+source_type: CSV
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
new file mode 100644
index 0000000000..f16e6bbd79
--- /dev/null
+++ b/web/modules/migrate_tools/tests/modules/csv_source_test/csv_source_test.info.yml
@@ -0,0 +1,15 @@
+type: module
+name: CSV Source edit test
+description: 'Test editing of source plugin via the UI.'
+package: Testing
+# core: 8.x
+dependencies:
+  - drupal:migrate (>=8.3)
+  - 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'
+project: 'migrate_tools'
+datestamp: 1535380087
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
new file mode 100644
index 0000000000..7f606d127f
--- /dev/null
+++ b/web/modules/migrate_tools/tests/modules/migrate_tools_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_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
new file mode 100644
index 0000000000..0f35a21895
--- /dev/null
+++ b/web/modules/migrate_tools/tests/modules/migrate_tools_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_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
new file mode 100644
index 0000000000..bbeb698678
--- /dev/null
+++ b/web/modules/migrate_tools/tests/modules/migrate_tools_test/migrate_tools_test.info.yml
@@ -0,0 +1,14 @@
+type: module
+name: Migrate Tools Test
+description: 'Test module to test Migrate Tools.'
+package: Testing
+# core: 8.x
+dependencies:
+  - drupal:migrate (>=8.3)
+  - migrate_plus:migrate_plus
+
+# Information added by Drupal.org packaging script on 2018-08-27
+version: '8.x-4.0'
+core: '8.x'
+project: 'migrate_tools'
+datestamp: 1535380087
diff --git a/web/modules/migrate_tools/tests/src/Functional/MigrateExecutionFormTest.php b/web/modules/migrate_tools/tests/src/Functional/MigrateExecutionFormTest.php
new file mode 100644
index 0000000000..2f29bf5ba2
--- /dev/null
+++ b/web/modules/migrate_tools/tests/src/Functional/MigrateExecutionFormTest.php
@@ -0,0 +1,132 @@
+<?php
+
+namespace Drupal\Tests\migrate_tools\Functional;
+
+use Drupal\taxonomy\Entity\Vocabulary;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Execution form test.
+ *
+ * @group migrate_tools
+ */
+class MigrateExecutionFormTest extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'user',
+    'filter',
+    'field',
+    'node',
+    'text',
+    'taxonomy',
+    'migrate',
+    'migrate_plus',
+    'migrate_tools',
+    'migrate_tools_test',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $profile = 'testing';
+
+  /**
+   * The vocabulary.
+   *
+   * @var \Drupal\taxonomy\VocabularyInterface
+   */
+  protected $vocabulary;
+
+  /**
+   * The vocabulary query.
+   *
+   * @var \Drupal\Core\Entity\Query\QueryInterface
+   */
+  protected $vocabularyQuery;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->vocabulary = $this->createVocabulary(['vid' => 'fruit', 'name' => 'Fruit']);
+    $this->vocabularyQuery = $this->container->get('entity_type.manager')
+      ->getStorage('taxonomy_term')
+      ->getQuery();
+    // Log in as user 1. Migrations in the UI can only be performed as user 1.
+    $this->drupalLogin($this->rootUser);
+  }
+
+  /**
+   * Tests execution of import and rollback of a migration.
+   *
+   * @throws \Behat\Mink\Exception\ExpectationException
+   */
+  public function testExecution() {
+    $group = 'default';
+    $migration = 'fruit_terms';
+    $urlPath = "/admin/structure/migrate/manage/{$group}/migrations/{$migration}/execute";
+    $real_count = $this->vocabularyQuery->count()->execute();
+    $expected_count = 0;
+    $this->assertEquals($expected_count, $real_count);
+    $this->drupalGet($urlPath);
+    $this->assertSession()->responseContains('Choose an operation to run');
+    $edit = [
+      'operation' => 'import',
+    ];
+    $this->drupalPostForm($urlPath, $edit, 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'));
+    $real_count = $this->vocabularyQuery->count()->execute();
+    $expected_count = 0;
+    $this->assertEquals($expected_count, $real_count);
+    $edit = [
+      'operation' => 'import',
+    ];
+    $this->drupalPostForm($urlPath, $edit, t('Execute'));
+    $real_count = $this->vocabularyQuery->count()->execute();
+    $expected_count = 3;
+    $this->assertEquals($expected_count, $real_count);
+  }
+
+  /**
+   * Creates a custom vocabulary based on default settings.
+   *
+   * @param array $values
+   *   An array of settings to change from the defaults.
+   *   Example: 'vid' => 'foo'.
+   *
+   * @return \Drupal\taxonomy\VocabularyInterface
+   *   Created vocabulary.
+   */
+  protected function createVocabulary(array $values = []) {
+    // Find a non-existent random vocabulary name.
+    if (!isset($values['vid'])) {
+      do {
+        $id = strtolower($this->randomMachineName(8));
+      } while (Vocabulary::load($id));
+    }
+    else {
+      $id = $values['vid'];
+    }
+    $values += [
+      'id' => $id,
+      'name' => $id,
+    ];
+    $vocabulary = Vocabulary::create($values);
+    $status = $vocabulary->save();
+
+    $this->assertSame($status, SAVED_NEW);
+
+    return $vocabulary;
+  }
+
+}
diff --git a/web/modules/migrate_tools/tests/src/Functional/SourceCsvFormTest.php b/web/modules/migrate_tools/tests/src/Functional/SourceCsvFormTest.php
new file mode 100644
index 0000000000..767a9bfe08
--- /dev/null
+++ b/web/modules/migrate_tools/tests/src/Functional/SourceCsvFormTest.php
@@ -0,0 +1,241 @@
+<?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());
+  }
+
+}
-- 
GitLab