diff --git a/core/core.services.yml b/core/core.services.yml
index 98ddc9e..57fdb52 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -297,6 +297,11 @@ services:
     tags:
       - { name: module_install.uninstall_validator }
     arguments: ['@entity.manager', '@string_translation']
+  required_module_uninstall_validator:
+    class: Drupal\Core\Extension\RequiredModuleUninstallValidator
+    tags:
+      - { name: module_install.uninstall_validator }
+    arguments: ['@string_translation']
   theme_handler:
     class: Drupal\Core\Extension\ThemeHandler
     arguments: ['@app.root', '@config.factory', '@module_handler', '@state', '@info_parser', '@logger.channel.default', '@asset.css.collection_optimizer', '@config.installer', '@config.manager', '@router.builder_indicator']
diff --git a/core/lib/Drupal/Core/Entity/ContentUninstallValidator.php b/core/lib/Drupal/Core/Entity/ContentUninstallValidator.php
index cc139e0..e791d8a 100644
--- a/core/lib/Drupal/Core/Entity/ContentUninstallValidator.php
+++ b/core/lib/Drupal/Core/Entity/ContentUninstallValidator.php
@@ -18,6 +18,13 @@ class ContentUninstallValidator implements ModuleUninstallValidatorInterface {
   use StringTranslationTrait;
 
   /**
+   * The entity manager.
+   *
+   * @var \Drupal\Core\Entity\EntityManagerInterface
+   */
+  protected $entityManager;
+
+  /**
    * Constructs a new ContentUninstallValidator.
    *
    * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
diff --git a/core/lib/Drupal/Core/Extension/RequiredModuleUninstallValidator.php b/core/lib/Drupal/Core/Extension/RequiredModuleUninstallValidator.php
new file mode 100644
index 0000000..5b51777
--- /dev/null
+++ b/core/lib/Drupal/Core/Extension/RequiredModuleUninstallValidator.php
@@ -0,0 +1,54 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Core\Extension\RequiredModuleUninstallValidator.
+ */
+
+namespace Drupal\Core\Extension;
+
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+
+/**
+ * Ensures that required modules cannot be uninstalled.
+ */
+class RequiredModuleUninstallValidator implements ModuleUninstallValidatorInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
+   *   The string translation service.
+   */
+  public function __construct(TranslationInterface $string_translation) {
+    $this->stringTranslation = $string_translation;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validate($module) {
+    $reasons = [];
+    $module_info = $this->getModuleInfoByModule($module);
+    if (!empty($module_info['required'])) {
+      $reasons[] = $this->t('This module is required.');
+    }
+    return $reasons;
+  }
+
+  /**
+   * Returns the module info for a specific module.
+   *
+   * @param string $module
+   *   The name of the module.
+   *
+   * @return array
+   *   The module info, or NULL if that module does not exist.
+   */
+  protected function getModuleInfoByModule($module) {
+    $modules = system_rebuild_module_data();
+    return isset($modules[$module]->info) ? $modules[$module]->info : [];
+  }
+
+}
diff --git a/core/modules/book/book.module b/core/modules/book/book.module
index fe6e06d..e1e2b50 100644
--- a/core/modules/book/book.module
+++ b/core/modules/book/book.module
@@ -575,33 +575,3 @@ function book_node_type_update(NodeTypeInterface $type) {
     $config->save();
   }
 }
-
-/**
- * Implements hook_system_info_alter().
- *
- * Prevents book module from being uninstalled whilst any book nodes exist or
- * there are any book outline stored.
- */
-function book_system_info_alter(&$info, Extension $file, $type) {
-  // It is not safe use the entity query service during maintenance mode.
-  if ($type == 'module' && !defined('MAINTENANCE_MODE') && $file->getName() == 'book') {
-    if (\Drupal::service('book.outline_storage')->hasBooks()) {
-      $info['required'] = TRUE;
-      $info['explanation'] = t('To uninstall Book, delete all content that is part of a book.');
-    }
-    else {
-      // The book node type is provided by the Book module. Prevent uninstall if
-      // there are any nodes of that type.
-      $factory = \Drupal::service('entity.query');
-      $nodes = $factory->get('node')
-        ->condition('type', 'book')
-        ->accessCheck(FALSE)
-        ->range(0, 1)
-        ->execute();
-      if (!empty($nodes)) {
-        $info['required'] = TRUE;
-        $info['explanation'] = t('To uninstall Book, delete all content that has the Book content type.');
-      }
-    }
-  }
-}
diff --git a/core/modules/book/book.services.yml b/core/modules/book/book.services.yml
index 9aeda19..9eab43b 100644
--- a/core/modules/book/book.services.yml
+++ b/core/modules/book/book.services.yml
@@ -23,3 +23,9 @@ services:
     arguments: ['@book.manager']
     tags:
       - { name: access_check, applies_to: _access_book_removable }
+
+  book.uninstall_validator:
+    class: Drupal\book\BookUninstallValidator
+    tags:
+      - { name: module_install.uninstall_validator }
+    arguments: ['@book.outline_storage', '@entity.query', '@string_translation']
diff --git a/core/modules/book/src/BookUninstallValidator.php b/core/modules/book/src/BookUninstallValidator.php
new file mode 100644
index 0000000..5a7a1cd
--- /dev/null
+++ b/core/modules/book/src/BookUninstallValidator.php
@@ -0,0 +1,96 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\book\BookUninstallValidator.
+ */
+
+namespace Drupal\book;
+
+use Drupal\Core\Entity\Query\QueryFactory;
+use Drupal\Core\Extension\ModuleUninstallValidatorInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+
+/**
+ * Prevents book module from being uninstalled whilst any book nodes exist or
+ * there are any book outline stored.
+ */
+class BookUninstallValidator implements ModuleUninstallValidatorInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The book outline storage.
+   *
+   * @var \Drupal\book\BookOutlineStorageInterface
+   */
+  protected $bookOutlineStorage;
+
+  /**
+   * The entity query for node.
+   *
+   * @var \Drupal\Core\Entity\Query\QueryInterface
+   */
+  protected $entityQuery;
+
+  /**
+   * @param \Drupal\book\BookOutlineStorageInterface $book_outline_storage
+   *   The book outline storage.
+   * @param \Drupal\Core\Entity\Query\QueryFactory $query_factory
+   *   The entity query factory.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
+   *   The string translation service.
+   */
+  public function __construct(BookOutlineStorageInterface $book_outline_storage, QueryFactory $query_factory, TranslationInterface $string_translation) {
+    $this->bookOutlineStorage = $book_outline_storage;
+    $this->entityQuery = $query_factory->get('node');
+    $this->stringTranslation = $string_translation;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validate($module) {
+    $reasons = [];
+    if ($module == 'book') {
+      if ($this->hasOutlineBooks()) {
+        $reasons[] = $this->t('To uninstall Book, delete all content that is part of a book.');
+      }
+      else {
+        // The book node type is provided by the Book module. Prevent uninstall if
+        // there are any nodes of that type.
+        if ($this->hasBookNodes()) {
+          $reasons[] = $this->t('To uninstall Book, delete all content that has the Book content type.');
+        }
+      }
+    }
+    return $reasons;
+  }
+
+  /**
+   * Checks if there are any books in an outline.
+   *
+   * @return bool
+   *   TRUE if there are books, FALSE if not.
+   */
+  protected function hasOutlineBooks() {
+    return $this->bookOutlineStorage->hasBooks();
+  }
+
+  /**
+   * Determines if there are any book nodes or not.
+   *
+   * @return bool
+   *   TRUE if there are book nodes, FALSE otherwise.
+   */
+  protected function hasBookNodes() {
+    $nodes = $this->entityQuery
+      ->condition('type', 'book')
+      ->accessCheck(FALSE)
+      ->range(0, 1)
+      ->execute();
+    return !empty($nodes);
+  }
+
+}
diff --git a/core/modules/book/src/Tests/BookUninstallTest.php b/core/modules/book/src/Tests/BookUninstallTest.php
deleted file mode 100644
index a1d55f7..0000000
--- a/core/modules/book/src/Tests/BookUninstallTest.php
+++ /dev/null
@@ -1,102 +0,0 @@
-<?php
-
-/**
- * @file
- * Contains Drupal\book\Tests\BookUninstallTest.
- */
-
-namespace Drupal\book\Tests;
-
-use Drupal\node\Entity\Node;
-use Drupal\node\Entity\NodeType;
-use Drupal\simpletest\KernelTestBase;
-
-/**
- * Tests that the Book module cannot be uninstalled if books exist.
- *
- * @group book
- */
-class BookUninstallTest extends KernelTestBase {
-
-  /**
-   * Modules to enable.
-   *
-   * @var array
-   */
-  public static $modules = array('system', 'user', 'field', 'filter', 'text', 'entity_reference', 'node', 'book');
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function setUp() {
-    parent::setUp();
-    $this->installEntitySchema('user');
-    $this->installEntitySchema('node');
-    $this->installSchema('book', array('book'));
-    $this->installSchema('node', array('node_access'));
-    $this->installConfig(array('node', 'book', 'field'));
-    // For uninstall to work.
-    $this->installSchema('user', array('users_data'));
-  }
-
-  /**
-   * Tests the book_system_info_alter() method.
-   */
-  public function testBookUninstall() {
-    // No nodes exist.
-    $module_data = _system_rebuild_module_data();
-    $this->assertFalse(isset($module_data['book']->info['required']), 'The book module is not required.');
-
-    $content_type = NodeType::create(array(
-      'type' => $this->randomMachineName(),
-      'name' => $this->randomString(),
-    ));
-    $content_type->save();
-    $book_config = \Drupal::config('book.settings');
-    $allowed_types = $book_config->get('allowed_types');
-    $allowed_types[] = $content_type->id();
-    $book_config->set('allowed_types', $allowed_types)->save();
-
-    $node = Node::create(array('type' => $content_type->id()));
-    $node->book['bid'] = 'new';
-    $node->save();
-
-    // One node in a book but not of type book.
-    $module_data = _system_rebuild_module_data();
-    $this->assertTrue($module_data['book']->info['required'], 'The book module is required.');
-    $this->assertEqual($module_data['book']->info['explanation'], t('To uninstall Book, delete all content that is part of a book.'));
-
-    $book_node = Node::create(array('type' => 'book'));
-    $book_node->book['bid'] = FALSE;
-    $book_node->save();
-
-    // Two nodes, one in a book but not of type book and one book node (which is
-    // not in a book).
-    $module_data = _system_rebuild_module_data();
-    $this->assertTrue($module_data['book']->info['required'], 'The book module is required.');
-    $this->assertEqual($module_data['book']->info['explanation'], t('To uninstall Book, delete all content that is part of a book.'));
-
-    $node->delete();
-    // One node of type book but not actually part of a book.
-    $module_data = _system_rebuild_module_data();
-    $this->assertTrue($module_data['book']->info['required'], 'The book module is required.');
-    $this->assertEqual($module_data['book']->info['explanation'], t('To uninstall Book, delete all content that has the Book content type.'));
-
-    $book_node->delete();
-    // No nodes exist therefore the book module is not required.
-    $module_data = _system_rebuild_module_data();
-    $this->assertFalse(isset($module_data['book']->info['required']), 'The book module is not required.');
-
-    $node = Node::create(array('type' => $content_type->id()));
-    $node->save();
-    // One node exists but is not part of a book therefore the book module is
-    // not required.
-    $module_data = _system_rebuild_module_data();
-    $this->assertFalse(isset($module_data['book']->info['required']), 'The book module is not required.');
-
-    // Uninstall the Book module and check the node type is deleted.
-    \Drupal::service('module_installer')->uninstall(array('book'));
-    $this->assertNull(NodeType::load('book'), "The book node type does not exist.");
-  }
-
-}
diff --git a/core/modules/book/tests/src/Unit/BookUninstallValidatorTest.php b/core/modules/book/tests/src/Unit/BookUninstallValidatorTest.php
new file mode 100644
index 0000000..70f9503
--- /dev/null
+++ b/core/modules/book/tests/src/Unit/BookUninstallValidatorTest.php
@@ -0,0 +1,100 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Tests\book\Unit\BookUninstallValidatorTest.
+ */
+
+namespace Drupal\Tests\book\Unit;
+
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\book\BookUninstallValidator
+ * @group book
+ */
+class BookUninstallValidatorTest extends UnitTestCase {
+
+  /**
+   * @var \Drupal\book\BookUninstallValidator|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $bookUninstallValidator;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->bookUninstallValidator = $this->getMockBuilder('Drupal\book\BookUninstallValidator')
+      ->disableOriginalConstructor()
+      ->setMethods(['hasOutlineBooks', 'hasBookNodes'])
+      ->getMock();
+    $this->bookUninstallValidator->setStringTranslation($this->getStringTranslationStub());
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testValidateNotBook() {
+    $this->bookUninstallValidator->expects($this->never())
+      ->method('hasOutlineBooks');
+    $this->bookUninstallValidator->expects($this->never())
+      ->method('hasBookNodes');
+
+    $module = 'not_book';
+    $expected = [];
+    $reasons = $this->bookUninstallValidator->validate($module);
+    $this->assertSame($expected, $reasons);
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testValidateEntityQueryWithoutResults() {
+    $this->bookUninstallValidator->expects($this->once())
+      ->method('hasOutlineBooks')
+      ->willReturn(FALSE);
+    $this->bookUninstallValidator->expects($this->once())
+      ->method('hasBookNodes')
+      ->willReturn(FALSE);
+
+    $module = 'book';
+    $expected = [];
+    $reasons = $this->bookUninstallValidator->validate($module);
+    $this->assertSame($expected, $reasons);
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testValidateEntityQueryWithResults() {
+    $this->bookUninstallValidator->expects($this->once())
+      ->method('hasOutlineBooks')
+      ->willReturn(FALSE);
+    $this->bookUninstallValidator->expects($this->once())
+      ->method('hasBookNodes')
+      ->willReturn(TRUE);
+
+    $module = 'book';
+    $expected = ['To uninstall Book, delete all content that has the Book content type.'];
+    $reasons = $this->bookUninstallValidator->validate($module);
+    $this->assertSame($expected, $reasons);
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testValidateOutlineStorage() {
+    $this->bookUninstallValidator->expects($this->once())
+      ->method('hasOutlineBooks')
+      ->willReturn(TRUE);
+    $this->bookUninstallValidator->expects($this->never())
+      ->method('hasBookNodes');
+
+    $module = 'book';
+    $expected = ['To uninstall Book, delete all content that is part of a book.'];
+    $reasons = $this->bookUninstallValidator->validate($module);
+    $this->assertSame($expected, $reasons);
+  }
+
+}
diff --git a/core/modules/field/field.module b/core/modules/field/field.module
index 143e8c5..9264d31 100644
--- a/core/modules/field/field.module
+++ b/core/modules/field/field.module
@@ -118,40 +118,6 @@ function field_cron() {
 }
 
 /**
- * Implements hook_system_info_alter().
- *
- * Goes through a list of all modules that provide a field type and makes them
- * required if there are any active fields of that type.
- */
-function field_system_info_alter(&$info, Extension $file, $type) {
-  // It is not safe to call entity_load_multiple_by_properties() during
-  // maintenance mode.
-  if ($type == 'module' && !defined('MAINTENANCE_MODE')) {
-    $field_storages = entity_load_multiple_by_properties('field_storage_config', array('module' => $file->getName(), 'include_deleted' => TRUE));
-    if ($field_storages) {
-      $info['required'] = TRUE;
-
-      // Provide an explanation message (only mention pending deletions if there
-      // remains no actual, non-deleted fields)
-      $non_deleted = FALSE;
-      foreach ($field_storages as $field_storage) {
-        if (empty($field_storage->deleted)) {
-          $non_deleted = TRUE;
-          break;
-        }
-      }
-      if ($non_deleted) {
-        $explanation = t('Fields type(s) in use');
-      }
-      else {
-        $explanation = t('Fields pending deletion');
-      }
-      $info['explanation'] = $explanation;
-    }
-  }
-}
-
-/**
  * Implements hook_entity_field_storage_info().
  */
 function field_entity_field_storage_info(\Drupal\Core\Entity\EntityTypeInterface $entity_type) {
diff --git a/core/modules/field/field.services.yml b/core/modules/field/field.services.yml
new file mode 100644
index 0000000..a263c70
--- /dev/null
+++ b/core/modules/field/field.services.yml
@@ -0,0 +1,6 @@
+services:
+  field.uninstall_validator:
+    class: Drupal\field\FieldUninstallValidator
+    tags:
+      - { name: module_install.uninstall_validator }
+    arguments: ['@entity.manager', '@string_translation']
diff --git a/core/modules/field/src/FieldUninstallValidator.php b/core/modules/field/src/FieldUninstallValidator.php
new file mode 100644
index 0000000..8b27ae3
--- /dev/null
+++ b/core/modules/field/src/FieldUninstallValidator.php
@@ -0,0 +1,78 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\field\FieldUninstallValidator.
+ */
+
+namespace Drupal\field;
+
+use Drupal\Core\Entity\EntityManagerInterface;
+use Drupal\Core\Extension\ModuleUninstallValidatorInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+
+/**
+ * Prevents uninstallation of modules providing active field storage.
+ */
+class FieldUninstallValidator implements ModuleUninstallValidatorInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The field storage config storage.
+   *
+   * @var \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected $fieldStorageConfigStorage;
+
+  /**
+   * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
+   *   The entity manager.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
+   *   The string translation service.
+   */
+  public function __construct(EntityManagerInterface $entity_manager, TranslationInterface $string_translation) {
+    $this->fieldStorageConfigStorage = $entity_manager->getStorage('field_storage_config');
+    $this->stringTranslation = $string_translation;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validate($module) {
+    $reasons = [];
+    if ($field_storages = $this->getFieldStoragesByModule($module)) {
+      // Provide an explanation message (only mention pending deletions if there
+      // remains no actual, non-deleted fields)
+      $non_deleted = FALSE;
+      foreach ($field_storages as $field_storage) {
+        if (empty($field_storage->deleted)) {
+          $non_deleted = TRUE;
+          break;
+        }
+      }
+      if ($non_deleted) {
+        $reasons[] = $this->t('Fields type(s) in use');
+      }
+      else {
+        $reasons[] = $this->t('Fields pending deletion');
+      }
+    }
+    return $reasons;
+  }
+
+  /**
+   * Returns all field storages for a specified module.
+   *
+   * @param string $module
+   *   The module to filter field storages by.
+   *
+   * @return \Drupal\field\FieldStorageConfigInterface[]
+   *   An array of field storages for a specified module.
+   */
+  protected function getFieldStoragesByModule($module) {
+    return $this->fieldStorageConfigStorage->loadByProperties(['module' => $module, 'include_deleted' => TRUE]);
+  }
+
+}
diff --git a/core/modules/field/src/Tests/reEnableModuleFieldTest.php b/core/modules/field/src/Tests/reEnableModuleFieldTest.php
index 2b8f4c1..947ba83 100644
--- a/core/modules/field/src/Tests/reEnableModuleFieldTest.php
+++ b/core/modules/field/src/Tests/reEnableModuleFieldTest.php
@@ -88,10 +88,10 @@ function testReEnabledField() {
     // for it's fields.
     $admin_user = $this->drupalCreateUser(array('access administration pages', 'administer modules'));
     $this->drupalLogin($admin_user);
-    $this->drupalGet('admin/modules');
+    $this->drupalGet('admin/modules/uninstall');
     $this->assertText('Fields type(s) in use');
     $field_storage->delete();
-    $this->drupalGet('admin/modules');
+    $this->drupalGet('admin/modules/uninstall');
     $this->assertText('Fields pending deletion');
     $this->cronRun();
     $this->assertNoText('Fields type(s) in use');
diff --git a/core/modules/field/tests/src/Unit/FieldUninstallValidatorTest.php b/core/modules/field/tests/src/Unit/FieldUninstallValidatorTest.php
new file mode 100644
index 0000000..8d50c22
--- /dev/null
+++ b/core/modules/field/tests/src/Unit/FieldUninstallValidatorTest.php
@@ -0,0 +1,85 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Tests\field\Unit\FieldUninstallValidatorTest.
+ */
+
+namespace Drupal\Tests\field\Unit;
+
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\field\FieldUninstallValidator
+ * @group field
+ */
+class FieldUninstallValidatorTest extends UnitTestCase {
+
+  /**
+   * @var \Drupal\field\FieldUninstallValidator|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $fieldUninstallValidator;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->fieldUninstallValidator = $this->getMockBuilder('Drupal\field\FieldUninstallValidator')
+      ->disableOriginalConstructor()
+      ->setMethods(['getFieldStoragesByModule'])
+      ->getMock();
+    $this->fieldUninstallValidator->setStringTranslation($this->getStringTranslationStub());
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testValidateNoStorages() {
+    $this->fieldUninstallValidator->expects($this->once())
+      ->method('getFieldStoragesByModule')
+      ->willReturn([]);
+
+    $module = $this->randomMachineName();
+    $expected = [];
+    $reasons = $this->fieldUninstallValidator->validate($module);
+    $this->assertSame($expected, $reasons);
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testValidateDeleted() {
+    $field_storage = $this->getMockBuilder('Drupal\field\Entity\FieldStorageConfig')
+      ->disableOriginalConstructor()
+      ->getMock();
+    $field_storage->deleted = TRUE;
+    $this->fieldUninstallValidator->expects($this->once())
+      ->method('getFieldStoragesByModule')
+      ->willReturn([$field_storage]);
+
+    $module = $this->randomMachineName();
+    $expected = ['Fields pending deletion'];
+    $reasons = $this->fieldUninstallValidator->validate($module);
+    $this->assertSame($expected, $reasons);
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testValidateNoDeleted() {
+    $field_storage = $this->getMockBuilder('Drupal\field\Entity\FieldStorageConfig')
+      ->disableOriginalConstructor()
+      ->getMock();
+    $field_storage->deleted = FALSE;
+    $this->fieldUninstallValidator->expects($this->once())
+      ->method('getFieldStoragesByModule')
+      ->willReturn([$field_storage]);
+
+    $module = $this->randomMachineName();
+    $expected = ['Fields type(s) in use'];
+    $reasons = $this->fieldUninstallValidator->validate($module);
+    $this->assertSame($expected, $reasons);
+  }
+
+}
diff --git a/core/modules/filter/filter.module b/core/modules/filter/filter.module
index bd3d072..60a0056 100644
--- a/core/modules/filter/filter.module
+++ b/core/modules/filter/filter.module
@@ -84,38 +84,6 @@ function filter_theme() {
 }
 
 /**
- * Implements hook_system_info_alter().
- *
- * Prevents uninstallation of modules that provide filter plugins that are being
- * used in a filter format.
- */
-function filter_system_info_alter(&$info, Extension $file, $type) {
-  // It is not safe to call filter_formats() during maintenance mode.
-  if ($type == 'module' && !defined('MAINTENANCE_MODE')) {
-    // Get filter plugins supplied by this module.
-    $filter_plugins = array_filter(\Drupal::service('plugin.manager.filter')->getDefinitions(), function ($definition) use ($file) {
-      return $definition['provider'] == $file->getName();
-    });
-    if (!empty($filter_plugins)) {
-      $used_in = [];
-      // Find out if any filter formats have the plugin enabled.
-      foreach (filter_formats() as $filter_format) {
-        foreach ($filter_plugins as $filter_plugin) {
-          if ($filter_format->filters($filter_plugin['id'])->status) {
-            $used_in[] = $filter_format->label();
-            $info['required'] = TRUE;
-            break;
-          }
-        }
-      }
-      if (!empty($used_in)) {
-        $info['explanation'] = t('Provides a filter plugin that is in use in the following filter formats: %formats', array('%formats' => implode(', ', $used_in)));
-      }
-    }
-  }
-}
-
-/**
  * Retrieves a list of enabled text formats, ordered by weight.
  *
  * @param \Drupal\Core\Session\AccountInterface|null $account
diff --git a/core/modules/filter/filter.services.yml b/core/modules/filter/filter.services.yml
index 406161a..0ecee66 100644
--- a/core/modules/filter/filter.services.yml
+++ b/core/modules/filter/filter.services.yml
@@ -2,3 +2,9 @@ services:
   plugin.manager.filter:
     class: Drupal\filter\FilterPluginManager
     parent: default_plugin_manager
+
+  filter.uninstall_validator:
+    class: Drupal\filter\FilterUninstallValidator
+    tags:
+      - { name: module_install.uninstall_validator }
+    arguments: ['@plugin.manager.filter', '@entity.manager', '@string_translation']
diff --git a/core/modules/filter/src/FilterUninstallValidator.php b/core/modules/filter/src/FilterUninstallValidator.php
new file mode 100644
index 0000000..3c0ecf6
--- /dev/null
+++ b/core/modules/filter/src/FilterUninstallValidator.php
@@ -0,0 +1,101 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\filter\FilterUninstallValidator.
+ */
+
+namespace Drupal\filter;
+
+use Drupal\Component\Plugin\PluginManagerInterface;
+use Drupal\Core\Entity\EntityManagerInterface;
+use Drupal\Core\Extension\ModuleUninstallValidatorInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+
+/**
+ * Prevents uninstallation of modules that provide filter plugins that are being
+ * used in a filter format.
+ */
+class FilterUninstallValidator implements ModuleUninstallValidatorInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The filter plugin manager.
+   *
+   * @var \Drupal\Component\Plugin\PluginManagerInterface
+   */
+  protected $filterManager;
+
+  /**
+   * The filter entity storage.
+   *
+   * @var \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected $filterStorage;
+
+  /**
+   * @param \Drupal\Component\Plugin\PluginManagerInterface $filter_manager
+   *   The filter plugin manager.
+   * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
+   *   The entity manager.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
+   *   The string translation service.
+   */
+  public function __construct(PluginManagerInterface $filter_manager, EntityManagerInterface $entity_manager, TranslationInterface $string_translation) {
+    $this->filterManager = $filter_manager;
+    $this->filterStorage = $entity_manager->getStorage('filter_format');
+    $this->stringTranslation = $string_translation;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validate($module) {
+    $reasons = [];
+    // Get filter plugins supplied by this module.
+    if ($filter_plugins = $this->getFilterDefinitionsByProvider($module)) {
+      $used_in = [];
+      // Find out if any filter formats have the plugin enabled.
+      foreach ($this->getEnabledFilterFormats() as $filter_format) {
+        $filters = $filter_format->filters();
+        foreach ($filter_plugins as $filter_plugin) {
+          if ($filters->has($filter_plugin['id']) && $filters->get($filter_plugin['id'])->status) {
+            $used_in[] = $filter_format->label();
+            break;
+          }
+        }
+      }
+      if (!empty($used_in)) {
+        $reasons[] = $this->t('Provides a filter plugin that is in use in the following filter formats: %formats', ['%formats' => implode(', ', $used_in)]);
+      }
+    }
+    return $reasons;
+  }
+
+  /**
+   * Returns all filter definitions that are provided by the specified provider.
+   *
+   * @param string $provider
+   *   The provider of the filters.
+   *
+   * @return array
+   *   The filter definitions for the specified provider.
+   */
+  protected function getFilterDefinitionsByProvider($provider) {
+    return array_filter($this->filterManager->getDefinitions(), function ($definition) use ($provider) {
+      return $definition['provider'] == $provider;
+    });
+  }
+
+  /**
+   * Returns all enabled filter formats.
+   *
+   * @return \Drupal\filter\FilterFormatInterface[]
+   */
+  protected function getEnabledFilterFormats() {
+    return $this->filterStorage->loadByProperties(['status' => TRUE]);
+  }
+
+}
diff --git a/core/modules/filter/src/Tests/FilterAPITest.php b/core/modules/filter/src/Tests/FilterAPITest.php
index 2808fc1..eac6179 100644
--- a/core/modules/filter/src/Tests/FilterAPITest.php
+++ b/core/modules/filter/src/Tests/FilterAPITest.php
@@ -403,18 +403,6 @@ public function testDependencyRemoval() {
     $this->installSchema('user', array('users_data'));
     $filter_format = \Drupal\filter\Entity\FilterFormat::load('filtered_html');
 
-    // Enable the filter_test_restrict_tags_and_attributes filter plugin on the
-    // filtered_html filter format.
-    $filter_config = [
-      'weight' => 10,
-      'status' => 1,
-    ];
-    $filter_format->setFilterConfig('filter_test_restrict_tags_and_attributes', $filter_config)->save();
-
-    $module_data = _system_rebuild_module_data();
-    $this->assertTrue($module_data['filter_test']->info['required'], 'The filter_test module is required.');
-    $this->assertEqual($module_data['filter_test']->info['explanation'], String::format('Provides a filter plugin that is in use in the following filter formats: %formats', array('%formats' => $filter_format->label())));
-
     // Disable the filter_test_restrict_tags_and_attributes filter plugin but
     // have custom configuration so that the filter plugin is still configured
     // in filtered_html the filter format.
diff --git a/core/modules/filter/tests/src/Unit/FilterUninstallValidatorTest.php b/core/modules/filter/tests/src/Unit/FilterUninstallValidatorTest.php
new file mode 100644
index 0000000..bc516d2
--- /dev/null
+++ b/core/modules/filter/tests/src/Unit/FilterUninstallValidatorTest.php
@@ -0,0 +1,172 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Tests\filter\Unit\FilterUninstallValidatorTest.
+ */
+
+namespace Drupal\Tests\filter\Unit;
+
+use Drupal\Component\Utility\String;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\filter\FilterUninstallValidator
+ * @group filter
+ */
+class FilterUninstallValidatorTest extends UnitTestCase {
+
+  /**
+   * @var \Drupal\filter\FilterUninstallValidator|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $filterUninstallValidator;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->filterUninstallValidator = $this->getMockBuilder('Drupal\filter\FilterUninstallValidator')
+      ->disableOriginalConstructor()
+      ->setMethods(['getFilterDefinitionsByProvider', 'getEnabledFilterFormats'])
+      ->getMock();
+    $this->filterUninstallValidator->setStringTranslation($this->getStringTranslationStub());
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testValidateNoPlugins() {
+    $this->filterUninstallValidator->expects($this->once())
+      ->method('getFilterDefinitionsByProvider')
+      ->willReturn([]);
+    $this->filterUninstallValidator->expects($this->never())
+      ->method('getEnabledFilterFormats');
+
+    $module = $this->randomMachineName();
+    $expected = [];
+    $reasons = $this->filterUninstallValidator->validate($module);
+    $this->assertSame($expected, $reasons);
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testValidateNoFormats() {
+    $this->filterUninstallValidator->expects($this->once())
+      ->method('getFilterDefinitionsByProvider')
+      ->willReturn([
+        'test_filter_plugin' => [
+          'id' => 'test_filter_plugin',
+          'provider' => 'filter_test',
+        ],
+      ]);
+    $this->filterUninstallValidator->expects($this->once())
+      ->method('getEnabledFilterFormats')
+      ->willReturn([]);
+
+    $module = $this->randomMachineName();
+    $expected = [];
+    $reasons = $this->filterUninstallValidator->validate($module);
+    $this->assertSame($expected, $reasons);
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testValidateNoMatchingFormats() {
+    $this->filterUninstallValidator->expects($this->once())
+      ->method('getFilterDefinitionsByProvider')
+      ->willReturn([
+        'test_filter_plugin1' => [
+          'id' => 'test_filter_plugin1',
+          'provider' => 'filter_test',
+        ],
+        'test_filter_plugin2' => [
+          'id' => 'test_filter_plugin2',
+          'provider' => 'filter_test',
+        ],
+        'test_filter_plugin3' => [
+          'id' => 'test_filter_plugin3',
+          'provider' => 'filter_test',
+        ],
+        'test_filter_plugin4' => [
+          'id' => 'test_filter_plugin4',
+          'provider' => 'filter_test',
+        ],
+      ]);
+
+    $filter_plugin_enabled = $this->getMockForAbstractClass('Drupal\filter\Plugin\FilterBase', [['status' => TRUE], '', ['provider' => 'filter_test']]);
+    $filter_plugin_disabled = $this->getMockForAbstractClass('Drupal\filter\Plugin\FilterBase', [['status' => FALSE], '', ['provider' => 'filter_test']]);
+
+    // The first format has 2 matching and enabled filters, but the loop breaks
+    // after finding the first one.
+    $filter_plugin_collection1 = $this->getMockBuilder('Drupal\filter\FilterPluginCollection')
+      ->disableOriginalConstructor()
+      ->getMock();
+    $filter_plugin_collection1->expects($this->exactly(3))
+      ->method('has')
+      ->willReturnMap([
+        ['test_filter_plugin1', FALSE],
+        ['test_filter_plugin2', TRUE],
+        ['test_filter_plugin3', TRUE],
+        ['test_filter_plugin4', TRUE],
+      ]);
+    $filter_plugin_collection1->expects($this->exactly(2))
+      ->method('get')
+      ->willReturnMap([
+        ['test_filter_plugin2', $filter_plugin_disabled],
+        ['test_filter_plugin3', $filter_plugin_enabled],
+        ['test_filter_plugin4', $filter_plugin_enabled],
+      ]);
+
+    $filter_format1 = $this->getMock('Drupal\filter\FilterFormatInterface');
+    $filter_format1->expects($this->once())
+      ->method('filters')
+      ->willReturn($filter_plugin_collection1);
+    $filter_format1->expects($this->once())
+      ->method('label')
+      ->willReturn('Filter Format 1 Label');
+
+    // The second filter format only has one matching and enabled filter.
+    $filter_plugin_collection2 = $this->getMockBuilder('Drupal\filter\FilterPluginCollection')
+      ->disableOriginalConstructor()
+      ->getMock();
+    $filter_plugin_collection2->expects($this->exactly(4))
+      ->method('has')
+      ->willReturnMap([
+        ['test_filter_plugin1', FALSE],
+        ['test_filter_plugin2', FALSE],
+        ['test_filter_plugin3', FALSE],
+        ['test_filter_plugin4', TRUE],
+      ]);
+    $filter_plugin_collection2->expects($this->exactly(1))
+      ->method('get')
+      ->with('test_filter_plugin4')
+      ->willReturn($filter_plugin_enabled);
+
+    $filter_format2 = $this->getMock('Drupal\filter\FilterFormatInterface');
+    $filter_format2->expects($this->once())
+      ->method('filters')
+      ->willReturn($filter_plugin_collection2);
+    $filter_format2->expects($this->once())
+      ->method('label')
+      ->willReturn('Filter Format 2 Label');
+    $this->filterUninstallValidator->expects($this->once())
+      ->method('getEnabledFilterFormats')
+      ->willReturn([
+        'test_filter_format1' => $filter_format1,
+        'test_filter_format2' => $filter_format2,
+      ]);
+
+    $expected = [
+      String::format('Provides a filter plugin that is in use in the following filter formats: %formats', ['%formats' => implode(', ', [
+        'Filter Format 1 Label',
+        'Filter Format 2 Label',
+      ])]),
+    ];
+    $reasons = $this->filterUninstallValidator->validate($this->randomMachineName());
+    $this->assertSame($expected, $reasons);
+  }
+
+}
diff --git a/core/modules/forum/forum.module b/core/modules/forum/forum.module
index d4be4f5..3b21ac7 100644
--- a/core/modules/forum/forum.module
+++ b/core/modules/forum/forum.module
@@ -662,63 +662,3 @@ function template_preprocess_forum_submitted(&$variables) {
   }
   $variables['time'] = isset($variables['topic']->created) ? \Drupal::service('date.formatter')->formatInterval(REQUEST_TIME - $variables['topic']->created) : '';
 }
-
-/**
- * Implements hook_system_info_alter().
- *
- * Prevents forum module from being uninstalled whilst any forum nodes exist
- * or there are any terms in the forum vocabulary.
- */
-function forum_system_info_alter(&$info, Extension $file, $type) {
-  // It is not safe use the entity query service during maintenance mode.
-  if ($type == 'module' && !defined('MAINTENANCE_MODE') && $file->getName() == 'forum') {
-    $factory = \Drupal::service('entity.query');
-    $nodes = $factory->get('node')
-      ->condition('type', 'forum')
-      ->accessCheck(FALSE)
-      ->range(0, 1)
-      ->execute();
-    if (!empty($nodes)) {
-      $info['required'] = TRUE;
-      $info['explanation'] = t('To uninstall Forum first delete all <em>Forum</em> content.');
-    }
-    $vid = \Drupal::config('forum.settings')->get('vocabulary');
-    $terms = $factory->get('taxonomy_term')
-      ->condition('vid', $vid)
-      ->accessCheck(FALSE)
-      ->range(0, 1)
-      ->execute();
-    if (!empty($terms)) {
-      $vocabulary = Vocabulary::load($vid);
-      $info['required'] = TRUE;
-      try {
-        if (!empty($info['explanation'])) {
-          if ($vocabulary->access('view')) {
-            $info['explanation'] = t('To uninstall Forum first delete all <em>Forum</em> content and <a href="!url">%vocabulary</a> terms.', [
-              '%vocabulary' => $vocabulary->label(),
-              '!url' => $vocabulary->url('overview-form'),
-            ]);
-          }
-          else {
-            $info['explanation'] = t('To uninstall Forum first delete all <em>Forum</em> content and %vocabulary terms.', [
-              '%vocabulary' => $vocabulary->label()
-            ]);
-          }
-        }
-        else {
-          $info['explanation'] = t('To uninstall Forum first delete all <a href="!url">%vocabulary</a> terms.', [
-            '%vocabulary' => $vocabulary->label(),
-            '!url' => $vocabulary->url('overview-form'),
-          ]);
-        }
-      }
-      catch (RouteNotFoundException $e) {
-        // Route rebuilding might not have occurred before this hook is fired.
-        // Just use an explanation without a link for the time being.
-        $info['explanation'] = t('To uninstall Forum first delete all <em>Forum</em> content and %vocabulary terms.', [
-          '%vocabulary' => $vocabulary->label()
-        ]);
-      }
-    }
-  }
-}
diff --git a/core/modules/forum/forum.services.yml b/core/modules/forum/forum.services.yml
index d5c0fe3..6fdfcc8 100644
--- a/core/modules/forum/forum.services.yml
+++ b/core/modules/forum/forum.services.yml
@@ -19,3 +19,9 @@ services:
     arguments: ['@database', '@forum_manager']
     tags:
       - { name: backend_overridable }
+
+  forum.uninstall_validator:
+    class: Drupal\forum\ForumUninstallValidator
+    tags:
+      - { name: module_install.uninstall_validator }
+    arguments: ['@entity.manager', '@entity.query', '@config.factory', '@string_translation']
diff --git a/core/modules/forum/src/ForumUninstallValidator.php b/core/modules/forum/src/ForumUninstallValidator.php
new file mode 100644
index 0000000..d1aedec
--- /dev/null
+++ b/core/modules/forum/src/ForumUninstallValidator.php
@@ -0,0 +1,137 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\forum\ForumUninstallValidator.
+ */
+
+namespace Drupal\forum;
+
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Entity\EntityManagerInterface;
+use Drupal\Core\Entity\Query\QueryFactory;
+use Drupal\Core\Extension\ModuleUninstallValidatorInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\taxonomy\VocabularyInterface;
+
+/**
+ * Prevents forum module from being uninstalled whilst any forum nodes exist
+ * or there are any terms in the forum vocabulary.
+ */
+class ForumUninstallValidator implements ModuleUninstallValidatorInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The field storage config storage.
+   *
+   * @var \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected $vocabularyStorage;
+
+  /**
+   * The entity query factory.
+   *
+   * @var \Drupal\Core\Entity\Query\QueryFactory
+   */
+  protected $queryFactory;
+
+  /**
+   * The config factory.
+   *
+   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   */
+  protected $configFactory;
+
+  /**
+   * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
+   *   The entity manager.
+   * @param \Drupal\Core\Entity\Query\QueryFactory $query_factory
+   *   The entity query factory.
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *  The config factory.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
+   *   The string translation service.
+   */
+  public function __construct(EntityManagerInterface $entity_manager, QueryFactory $query_factory, ConfigFactoryInterface $config_factory, TranslationInterface $string_translation) {
+    $this->vocabularyStorage = $entity_manager->getStorage('taxonomy_vocabulary');
+    $this->queryFactory = $query_factory;
+    $this->configFactory = $config_factory;
+    $this->stringTranslation = $string_translation;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validate($module) {
+    $reasons = [];
+    if ($module == 'forum') {
+      if ($this->hasForumNodes()) {
+        $reasons[] = $this->t('To uninstall Forum first delete all <em>Forum</em> content.');
+      }
+
+      $vocabulary = $this->getForumVocabulary();
+      if ($this->hasTermsForVocabulary($vocabulary)) {
+        if ($vocabulary->access('view')) {
+          $reasons[] = $this->t('To uninstall Forum first delete all <a href="!url">%vocabulary</a> terms.', [
+            '%vocabulary' => $vocabulary->label(),
+            '!url' => $vocabulary->url('overview-form'),
+          ]);
+        }
+        else {
+          $reasons[] = $this->t('To uninstall Forum first delete all %vocabulary terms.', [
+            '%vocabulary' => $vocabulary->label()
+          ]);
+        }
+      }
+    }
+
+    return $reasons;
+  }
+
+  /**
+   * Determines if there are any forum nodes or not.
+   *
+   * @return bool
+   *   TRUE if there are forum nodes, FALSE otherwise.
+   */
+  protected function hasForumNodes() {
+    $nodes = $this->queryFactory->get('node')
+      ->condition('type', 'forum')
+      ->accessCheck(FALSE)
+      ->range(0, 1)
+      ->execute();
+    return !empty($nodes);
+  }
+
+  /**
+   * Determines if there are any taxonomy terms for a specified vocabulary.
+   *
+   * @param \Drupal\taxonomy\VocabularyInterface $vocabulary
+   *   The vocabulary to check for terms.
+   *
+   * @return bool
+   *   TRUE if there are terms for this vocabulary, FALSE otherwise.
+   */
+  protected function hasTermsForVocabulary(VocabularyInterface $vocabulary) {
+    $terms = $this->queryFactory->get('taxonomy_term')
+      ->condition('vid', $vocabulary->id())
+      ->accessCheck(FALSE)
+      ->range(0, 1)
+      ->execute();
+    return !empty($terms);
+  }
+
+  /**
+   * Returns the vocabulary configured for forums.
+   *
+   * @return \Drupal\taxonomy\VocabularyInterface
+   *   The vocabulary entity for forums.
+   */
+  protected function getForumVocabulary() {
+    $vid = $this->configFactory->get('forum.settings')->get('vocabulary');
+    return $this->vocabularyStorage->load($vid);
+  }
+
+}
diff --git a/core/modules/forum/src/Tests/ForumUninstallTest.php b/core/modules/forum/src/Tests/ForumUninstallTest.php
index 904ac9d..8f2b79b 100644
--- a/core/modules/forum/src/Tests/ForumUninstallTest.php
+++ b/core/modules/forum/src/Tests/ForumUninstallTest.php
@@ -75,7 +75,6 @@ public function testForumUninstallWithField() {
     $this->drupalGet('admin/modules/uninstall');
     // Assert forum is required.
     $this->assertNoFieldByName('uninstall[forum]');
-    $this->drupalGet('admin/modules');
     $this->assertText('To uninstall Forum first delete all Forum content');
 
     // Delete the node.
@@ -85,7 +84,6 @@ public function testForumUninstallWithField() {
     $this->drupalGet('admin/modules/uninstall');
     // Assert forum is still required.
     $this->assertNoFieldByName('uninstall[forum]');
-    $this->drupalGet('admin/modules');
     $this->assertText('To uninstall Forum first delete all Forums terms');
 
     // Delete any forum terms.
@@ -103,7 +101,6 @@ public function testForumUninstallWithField() {
     $this->drupalGet('admin/modules/uninstall');
     // Assert forum is no longer required.
     $this->assertFieldByName('uninstall[forum]');
-    $this->drupalGet('admin/modules');
     $this->assertNoText('To uninstall Forum first delete all Forum content');
     $this->drupalPostForm('admin/modules/uninstall', array(
       'uninstall[forum]' => 1,
diff --git a/core/modules/forum/tests/src/Unit/ForumUninstallValidatorTest.php b/core/modules/forum/tests/src/Unit/ForumUninstallValidatorTest.php
new file mode 100644
index 0000000..3ac6196
--- /dev/null
+++ b/core/modules/forum/tests/src/Unit/ForumUninstallValidatorTest.php
@@ -0,0 +1,247 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Tests\forum\Unit\ForumUninstallValidatorTest.
+ */
+
+namespace Drupal\Tests\forum\Unit;
+
+use Drupal\Component\Utility\String;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\forum\ForumUninstallValidator
+ * @group forum
+ */
+class ForumUninstallValidatorTest extends UnitTestCase {
+
+  /**
+   * @var \Drupal\forum\ForumUninstallValidator|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $forumUninstallValidator;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->forumUninstallValidator = $this->getMockBuilder('Drupal\forum\ForumUninstallValidator')
+      ->disableOriginalConstructor()
+      ->setMethods(['hasForumNodes', 'hasTermsForVocabulary', 'getForumVocabulary'])
+      ->getMock();
+    $this->forumUninstallValidator->setStringTranslation($this->getStringTranslationStub());
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testValidateNotForum() {
+    $this->forumUninstallValidator->expects($this->never())
+      ->method('hasForumNodes');
+    $this->forumUninstallValidator->expects($this->never())
+      ->method('hasTermsForVocabulary');
+    $this->forumUninstallValidator->expects($this->never())
+      ->method('getForumVocabulary');
+
+    $module = 'not_forum';
+    $expected = [];
+    $reasons = $this->forumUninstallValidator->validate($module);
+    $this->assertSame($expected, $reasons);
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testValidate() {
+    $this->forumUninstallValidator->expects($this->once())
+      ->method('hasForumNodes')
+      ->willReturn(FALSE);
+
+    $vocabulary = $this->getMock('Drupal\taxonomy\VocabularyInterface');
+    $this->forumUninstallValidator->expects($this->once())
+      ->method('getForumVocabulary')
+      ->willReturn($vocabulary);
+
+    $this->forumUninstallValidator->expects($this->once())
+      ->method('hasTermsForVocabulary')
+      ->willReturn(FALSE);
+
+    $module = 'forum';
+    $expected = [];
+    $reasons = $this->forumUninstallValidator->validate($module);
+    $this->assertSame($expected, $reasons);
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testValidateHasForumNodes() {
+    $this->forumUninstallValidator->expects($this->once())
+      ->method('hasForumNodes')
+      ->willReturn(TRUE);
+
+    $vocabulary = $this->getMock('Drupal\taxonomy\VocabularyInterface');
+    $this->forumUninstallValidator->expects($this->once())
+      ->method('getForumVocabulary')
+      ->willReturn($vocabulary);
+
+    $this->forumUninstallValidator->expects($this->once())
+      ->method('hasTermsForVocabulary')
+      ->willReturn(FALSE);
+
+    $module = 'forum';
+    $expected = [
+      'To uninstall Forum first delete all <em>Forum</em> content.',
+    ];
+    $reasons = $this->forumUninstallValidator->validate($module);
+    $this->assertSame($expected, $reasons);
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testValidateHasTermsForVocabularyWithNodesAccess() {
+    $this->forumUninstallValidator->expects($this->once())
+      ->method('hasForumNodes')
+      ->willReturn(TRUE);
+
+    $vocabulary = $this->getMock('Drupal\taxonomy\VocabularyInterface');
+    $vocabulary->expects($this->once())
+      ->method('label')
+      ->willReturn('Vocabulary label');
+    $vocabulary->expects($this->once())
+      ->method('url')
+      ->willReturn('/path/to/vocabulary/overview');
+    $vocabulary->expects($this->once())
+      ->method('access')
+      ->willReturn(TRUE);
+    $this->forumUninstallValidator->expects($this->once())
+      ->method('getForumVocabulary')
+      ->willReturn($vocabulary);
+
+    $this->forumUninstallValidator->expects($this->once())
+      ->method('hasTermsForVocabulary')
+      ->willReturn(TRUE);
+
+    $module = 'forum';
+    $expected = [
+      'To uninstall Forum first delete all <em>Forum</em> content.',
+      String::format('To uninstall Forum first delete all <a href="!url">%vocabulary</a> terms.', [
+        '!url' => '/path/to/vocabulary/overview',
+        '%vocabulary' => 'Vocabulary label',
+      ]),
+    ];
+    $reasons = $this->forumUninstallValidator->validate($module);
+    $this->assertSame($expected, $reasons);
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testValidateHasTermsForVocabularyWithNodesNoAccess() {
+    $this->forumUninstallValidator->expects($this->once())
+      ->method('hasForumNodes')
+      ->willReturn(TRUE);
+
+    $vocabulary = $this->getMock('Drupal\taxonomy\VocabularyInterface');
+    $vocabulary->expects($this->once())
+      ->method('label')
+      ->willReturn('Vocabulary label');
+    $vocabulary->expects($this->never())
+      ->method('url');
+    $vocabulary->expects($this->once())
+      ->method('access')
+      ->willReturn(FALSE);
+    $this->forumUninstallValidator->expects($this->once())
+      ->method('getForumVocabulary')
+      ->willReturn($vocabulary);
+
+    $this->forumUninstallValidator->expects($this->once())
+      ->method('hasTermsForVocabulary')
+      ->willReturn(TRUE);
+
+    $module = 'forum';
+    $expected = [
+      'To uninstall Forum first delete all <em>Forum</em> content.',
+      String::format('To uninstall Forum first delete all %vocabulary terms.', [
+        '%vocabulary' => 'Vocabulary label',
+      ]),
+    ];
+    $reasons = $this->forumUninstallValidator->validate($module);
+    $this->assertSame($expected, $reasons);
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testValidateHasTermsForVocabularyAccess() {
+    $this->forumUninstallValidator->expects($this->once())
+      ->method('hasForumNodes')
+      ->willReturn(FALSE);
+
+    $vocabulary = $this->getMock('Drupal\taxonomy\VocabularyInterface');
+    $vocabulary->expects($this->once())
+      ->method('url')
+      ->willReturn('/path/to/vocabulary/overview');
+    $vocabulary->expects($this->once())
+      ->method('label')
+      ->willReturn('Vocabulary label');
+    $vocabulary->expects($this->once())
+      ->method('access')
+      ->willReturn(TRUE);
+    $this->forumUninstallValidator->expects($this->once())
+      ->method('getForumVocabulary')
+      ->willReturn($vocabulary);
+
+    $this->forumUninstallValidator->expects($this->once())
+      ->method('hasTermsForVocabulary')
+      ->willReturn(TRUE);
+
+    $module = 'forum';
+    $expected = [
+      String::format('To uninstall Forum first delete all <a href="!url">%vocabulary</a> terms.', [
+        '!url' => '/path/to/vocabulary/overview',
+        '%vocabulary' => 'Vocabulary label',
+      ]),
+    ];
+    $reasons = $this->forumUninstallValidator->validate($module);
+    $this->assertSame($expected, $reasons);
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testValidateHasTermsForVocabularyNoAccess() {
+    $this->forumUninstallValidator->expects($this->once())
+      ->method('hasForumNodes')
+      ->willReturn(FALSE);
+
+    $vocabulary = $this->getMock('Drupal\taxonomy\VocabularyInterface');
+    $vocabulary->expects($this->once())
+      ->method('label')
+      ->willReturn('Vocabulary label');
+    $vocabulary->expects($this->never())
+      ->method('url');
+    $vocabulary->expects($this->once())
+      ->method('access')
+      ->willReturn(FALSE);
+    $this->forumUninstallValidator->expects($this->once())
+      ->method('getForumVocabulary')
+      ->willReturn($vocabulary);
+
+    $this->forumUninstallValidator->expects($this->once())
+      ->method('hasTermsForVocabulary')
+      ->willReturn(TRUE);
+
+    $module = 'forum';
+    $expected = [
+      String::format('To uninstall Forum first delete all %vocabulary terms.', [
+        '%vocabulary' => 'Vocabulary label',
+      ]),
+    ];
+    $reasons = $this->forumUninstallValidator->validate($module);
+    $this->assertSame($expected, $reasons);
+  }
+
+}
diff --git a/core/modules/system/src/Tests/Module/DependencyTest.php b/core/modules/system/src/Tests/Module/DependencyTest.php
index 6454baf..6291f32 100644
--- a/core/modules/system/src/Tests/Module/DependencyTest.php
+++ b/core/modules/system/src/Tests/Module/DependencyTest.php
@@ -152,8 +152,7 @@ function testUninstallDependents() {
 
     // Check that the comment module cannot be uninstalled.
     $this->drupalGet('admin/modules/uninstall');
-    $checkbox = $this->xpath('//input[@type="checkbox" and @name="uninstall[comment]"]');
-    $this->assert(count($checkbox) == 0, 'Checkbox for uninstalling the comment module not found.');
+    $this->assertNoFieldByName('uninstall[checkbox]');
 
     // Delete any forum terms.
     $vid = \Drupal::config('forum.settings')->get('vocabulary');
diff --git a/core/modules/system/system.admin.inc b/core/modules/system/system.admin.inc
index 197767f..04e7e4a 100644
--- a/core/modules/system/system.admin.inc
+++ b/core/modules/system/system.admin.inc
@@ -310,9 +310,9 @@ function theme_system_modules_uninstall($variables) {
     }
     if (!empty($form['modules'][$module]['#validation_reasons'])) {
       $disabled_message = \Drupal::translation()->formatPlural(count($form['modules'][$module]['#validation_reasons']),
-        'The following reason prevents @module from being uninstalled: @reasons',
-        'The following reasons prevents @module from being uninstalled: @reasons',
-        array('@module' => $form['modules'][$module]['#module_name'], '@reasons' => implode('; ', $form['modules'][$module]['#validation_reasons'])));
+        'The following reason prevents @module from being uninstalled: !reasons',
+        'The following reasons prevents @module from being uninstalled: !reasons',
+        array('@module' => $form['modules'][$module]['#module_name'], '!reasons' => implode('; ', $form['modules'][$module]['#validation_reasons'])));
     }
     $rows[] = array(
       array('data' => drupal_render($form['uninstall'][$module]), 'align' => 'center'),
diff --git a/core/tests/Drupal/Tests/Core/Extension/RequiredModuleUninstallValidatorTest.php b/core/tests/Drupal/Tests/Core/Extension/RequiredModuleUninstallValidatorTest.php
new file mode 100644
index 0000000..6bc292f
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Extension/RequiredModuleUninstallValidatorTest.php
@@ -0,0 +1,78 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Tests\Core\Extension\RequiredModuleUninstallValidatorTest.
+ */
+
+namespace Drupal\Tests\Core\Extension;
+
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Extension\RequiredModuleUninstallValidator
+ * @group Extension
+ */
+class RequiredModuleUninstallValidatorTest extends UnitTestCase {
+
+  /**
+   * @var \Drupal\Core\Extension\RequiredModuleUninstallValidator|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $uninstallValidator;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->uninstallValidator = $this->getMockBuilder('Drupal\Core\Extension\RequiredModuleUninstallValidator')
+      ->disableOriginalConstructor()
+      ->setMethods(['getModuleInfoByModule'])
+      ->getMock();
+    $this->uninstallValidator->setStringTranslation($this->getStringTranslationStub());
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testValidateNoModule() {
+    $this->uninstallValidator->expects($this->once())
+      ->method('getModuleInfoByModule')
+      ->willReturn([]);
+
+    $module = $this->randomMachineName();
+    $expected = [];
+    $reasons = $this->uninstallValidator->validate($module);
+    $this->assertSame($expected, $reasons);
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testValidateNotRequired() {
+    $this->uninstallValidator->expects($this->once())
+      ->method('getModuleInfoByModule')
+      ->willReturn(['required' => FALSE]);
+
+    $module = $this->randomMachineName();
+
+    $expected = [];
+    $reasons = $this->uninstallValidator->validate($module);
+    $this->assertSame($expected, $reasons);
+  }
+
+  /**
+   * @covers ::validate
+   */
+  public function testValidateRequired() {
+    $this->uninstallValidator->expects($this->once())
+      ->method('getModuleInfoByModule')
+      ->willReturn(['required' => TRUE]);
+
+    $module = $this->randomMachineName();
+    $expected = ['This module is required.'];
+    $reasons = $this->uninstallValidator->validate($module);
+    $this->assertSame($expected, $reasons);
+  }
+
+}
