diff --git a/src/Hook/StatisticsHooks.php b/src/Hook/StatisticsHooks.php
new file mode 100644
index 0000000..9b5a2b0
--- /dev/null
+++ b/src/Hook/StatisticsHooks.php
@@ -0,0 +1,178 @@
+<?php
+
+namespace Drupal\statistics\Hook;
+
+use Drupal\node\NodeInterface;
+use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Url;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * Hook implementations for statistics.
+ */
+class StatisticsHooks {
+  use StringTranslationTrait;
+
+  /**
+   * Implements hook_help().
+   */
+  #[Hook('help')]
+  public function help($route_name, RouteMatchInterface $route_match) {
+    switch ($route_name) {
+      case 'help.page.statistics':
+        $output = '';
+        $output .= '<h2>' . $this->t('About') . '</h2>';
+        $output .= '<p>' . $this->t('The Statistics module shows you how often content is viewed. This is useful in determining which pages of your site are most popular. For more information, see the <a href=":statistics_do">online documentation for the Statistics module</a>.', [
+          ':statistics_do' => 'https://www.drupal.org/documentation/modules/statistics/',
+        ]) . '</p>';
+        $output .= '<h2>' . $this->t('Uses') . '</h2>';
+        $output .= '<dl>';
+        $output .= '<dt>' . $this->t('Displaying popular content') . '</dt>';
+        $settingsUrl = Url::fromRoute('statistics.settings')->toString();
+        $blockAdminUrl = \Drupal::moduleHandler()->moduleExists('block') ? Url::fromRoute('block.admin_display')->toString() : '#';
+        $permissionsUrl = Url::fromRoute('user.admin_permissions.module', [
+          'modules' => 'statistics',
+        ])->toString();
+        $output .= '<dd>' . $this->t('The module includes a <em>Popular content</em> block that displays the most viewed pages today and for all time, and the last content viewed. To use the block, enable <em>Count content views</em> on the <a href=":statistics-settings">Statistics page</a>, and then you can enable and configure the block on the <a href=":blocks">Block layout page</a>.', [
+          ':statistics-settings' => $settingsUrl,
+          ':blocks' => $blockAdminUrl,
+        ]) . '</dd>';
+        $output .= '<dt>' . $this->t('Page view counter') . '</dt>';
+        $output .= '<dd>' . $this->t('The Statistics module includes a counter for each page that increases whenever the page is viewed. To use the counter, enable <em>Count content views</em> on the <a href=":statistics-settings">Statistics page</a>, and set the necessary <a href=":permissions">permissions</a> (<em>View content hits</em>) so that the counter is visible to the users.', [
+          ':statistics-settings' => $settingsUrl,
+          ':permissions' => $permissionsUrl,
+        ]) . '</dd>';
+        $output .= '</dl>';
+        return $output;
+
+      case 'statistics.settings':
+        return '<p>' . $this->t('Settings for the statistical information that Drupal will keep about the site.') . '</p>';
+    }
+  }
+
+  /**
+   * Implements hook_ENTITY_TYPE_view() for node entities.
+   */
+  #[Hook('node_view')]
+  public static function nodeView(array &$build, EntityInterface $node, EntityViewDisplayInterface $display, $view_mode) {
+    if ($node->isNew() || $view_mode != 'full' || !node_is_page($node) || !empty($node->in_preview)) {
+      return;
+    }
+    $build['#attached']['library'][] = 'statistics/drupal.statistics';
+    $basePath = \Drupal::request()->getBasePath();
+    $statisticsPath = \Drupal::service('extension.list.module')->getPath('statistics');
+    $settings = [
+      'data' => [
+        'nid' => $node->id(),
+      ],
+      'url' => "{$basePath}/{$statisticsPath}/statistics.php",
+    ];
+    $build['#attached']['drupalSettings']['statistics'] = $settings;
+  }
+
+  /**
+   * Implements hook_node_links_alter().
+   */
+  #[Hook('node_links_alter')]
+  public static function nodeLinksAlter(array &$links, NodeInterface $entity, array &$context) {
+    if ($context['view_mode'] != 'rss') {
+      $links['#cache']['contexts'][] = 'user.permissions';
+      if (\Drupal::currentUser()->hasPermission('view post access counter')) {
+        $statistics = \Drupal::service('statistics.storage.node')->fetchView($entity->id());
+        if ($statistics) {
+          $statistics_links['statistics_counter']['title'] = \Drupal::translation()->formatPlural($statistics->getTotalCount(), '1 view', '@count views');
+          $links['statistics'] = [
+            '#theme' => 'links__node__statistics',
+            '#links' => $statistics_links,
+            '#attributes' => [
+              'class' => [
+                'links',
+                'inline',
+              ],
+            ],
+          ];
+        }
+        $links['#cache']['max-age'] = \Drupal::config('statistics.settings')->get('display_max_age');
+      }
+    }
+  }
+
+  /**
+   * Implements hook_cron().
+   */
+  #[Hook('cron')]
+  public static function cron() {
+    $storage = \Drupal::service('statistics.storage.node');
+    $storage->resetDayCount();
+    $max_total_count = $storage->maxTotalCount();
+    \Drupal::state()->set('statistics.node_counter_scale', 1.0 / max(1.0, $max_total_count));
+  }
+
+  /**
+   * Implements hook_ENTITY_TYPE_predelete() for node entities.
+   */
+  #[Hook('node_predelete')]
+  public static function nodePredelete(EntityInterface $node) {
+    // Clean up statistics table when node is deleted.
+    $id = $node->id();
+    return \Drupal::service('statistics.storage.node')->deleteViews($id);
+  }
+
+  /**
+   * Implements hook_ranking().
+   */
+  #[Hook('ranking')]
+  public function ranking() {
+    if (\Drupal::config('statistics.settings')->get('count_content_views')) {
+      return [
+        'views' => [
+          'title' => $this->t('Number of views'),
+          'join' => [
+            'type' => 'LEFT',
+            'table' => 'node_counter',
+            'alias' => 'node_counter',
+            'on' => 'node_counter.nid = i.sid',
+          ],
+                // Inverse law that maps the highest view count on the site to 1 and 0
+                // to 0. Note that the ROUND here is necessary for PostgreSQL and SQLite
+                // in order to ensure that the :statistics_scale argument is treated as
+                // a numeric type, because the PostgreSQL PDO driver sometimes puts
+                // values in as strings instead of numbers in complex expressions like
+                // this.
+          'score' => '2.0 - 2.0 / (1.0 + node_counter.totalcount * (ROUND(:statistics_scale, 4)))',
+          'arguments' => [
+            ':statistics_scale' => \Drupal::state()->get('statistics.node_counter_scale', 0),
+          ],
+        ],
+      ];
+    }
+  }
+
+  /**
+   * Implements hook_preprocess_HOOK() for block templates.
+   */
+  #[Hook('preprocess_block')]
+  public static function preprocessBlock(&$variables) {
+    if ($variables['configuration']['provider'] == 'statistics') {
+      $variables['attributes']['role'] = 'navigation';
+    }
+  }
+
+  /**
+   * Implements hook_block_alter().
+   *
+   * Removes the "popular" block from display if the module is not configured
+   * to count content views.
+   */
+  #[Hook('block_alter')]
+  public static function blockAlter(&$definitions) {
+    $statistics_count_content_views = \Drupal::config('statistics.settings')->get('count_content_views');
+    if (empty($statistics_count_content_views)) {
+      unset($definitions['statistics_popular_block']);
+    }
+  }
+
+}
diff --git a/src/Hook/StatisticsTokensHooks.php b/src/Hook/StatisticsTokensHooks.php
new file mode 100644
index 0000000..6dff9b0
--- /dev/null
+++ b/src/Hook/StatisticsTokensHooks.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Drupal\statistics\Hook;
+
+use Drupal\Core\Render\BubbleableMetadata;
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * Hook implementations for statistics.
+ */
+class StatisticsTokensHooks {
+  use StringTranslationTrait;
+
+  /**
+   * Implements hook_token_info().
+   */
+  #[Hook('token_info')]
+  public function tokenInfo() {
+    $node['total-count'] = [
+      'name' => $this->t("Number of views"),
+      'description' => $this->t("The number of visitors who have read the node."),
+    ];
+    $node['day-count'] = [
+      'name' => $this->t("Views today"),
+      'description' => $this->t("The number of visitors who have read the node today."),
+    ];
+    $node['last-view'] = [
+      'name' => $this->t("Last view"),
+      'description' => $this->t("The date on which a visitor last read the node."),
+      'type' => 'date',
+    ];
+    return [
+      'tokens' => [
+        'node' => $node,
+      ],
+    ];
+  }
+
+  /**
+   * Implements hook_tokens().
+   */
+  #[Hook('tokens')]
+  public function tokens($type, $tokens, array $data, array $options, BubbleableMetadata $bubbleable_metadata) {
+    $token_service = \Drupal::token();
+    $replacements = [];
+    if ($type == 'node' & !empty($data['node'])) {
+      $node = $data['node'];
+      /** @var \Drupal\statistics\StatisticsStorageInterface $stats_storage */
+      $stats_storage = \Drupal::service('statistics.storage.node');
+      $node_view = NULL;
+      foreach ($tokens as $name => $original) {
+        if ($name == 'total-count') {
+          $node_view = $node_view ?? $stats_storage->fetchView($node->id());
+          $replacements[$original] = $node_view ? $node_view->getTotalCount() : 0;
+        }
+        elseif ($name == 'day-count') {
+          $node_view = $node_view ?? $stats_storage->fetchView($node->id());
+          $replacements[$original] = $node_view ? $node_view->getDayCount() : 0;
+        }
+        elseif ($name == 'last-view') {
+          $node_view = $node_view ?? $stats_storage->fetchView($node->id());
+          $replacements[$original] = $node_view ? \Drupal::service('date.formatter')->format($node_view->getTimestamp()) : $this->t('never');
+        }
+      }
+      if ($created_tokens = $token_service->findWithPrefix($tokens, 'last-view')) {
+        $node_view = $node_view ?? $stats_storage->fetchView($node->id());
+        $replacements += $token_service->generate('date', $created_tokens, [
+          'date' => $node_view ? $node_view->getTimestamp() : 0,
+        ], $options, $bubbleable_metadata);
+      }
+    }
+    return $replacements;
+  }
+
+}
diff --git a/src/Hook/StatisticsViewsHooks.php b/src/Hook/StatisticsViewsHooks.php
new file mode 100644
index 0000000..d9ea5c8
--- /dev/null
+++ b/src/Hook/StatisticsViewsHooks.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace Drupal\statistics\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * Hook implementations for statistics.
+ */
+class StatisticsViewsHooks {
+  use StringTranslationTrait;
+  /**
+   * @file
+   * Provide views data for statistics.module.
+   */
+
+  /**
+   * Implements hook_views_data().
+   */
+  #[Hook('views_data')]
+  public function viewsData() {
+    $data['node_counter']['table']['group'] = $this->t('Content statistics');
+    $data['node_counter']['table']['join'] = [
+      'node_field_data' => [
+        'left_field' => 'nid',
+        'field' => 'nid',
+      ],
+    ];
+    $data['node_counter']['totalcount'] = [
+      'title' => $this->t('Total views'),
+      'help' => $this->t('The total number of times the node has been viewed.'),
+      'field' => [
+        'id' => 'statistics_numeric',
+        'click sortable' => TRUE,
+      ],
+      'filter' => [
+        'id' => 'numeric',
+      ],
+      'argument' => [
+        'id' => 'numeric',
+      ],
+      'sort' => [
+        'id' => 'standard',
+      ],
+    ];
+    $data['node_counter']['daycount'] = [
+      'title' => $this->t('Views today'),
+      'help' => $this->t('The total number of times the node has been viewed today.'),
+      'field' => [
+        'id' => 'statistics_numeric',
+        'click sortable' => TRUE,
+      ],
+      'filter' => [
+        'id' => 'numeric',
+      ],
+      'argument' => [
+        'id' => 'numeric',
+      ],
+      'sort' => [
+        'id' => 'standard',
+      ],
+    ];
+    $data['node_counter']['timestamp'] = [
+      'title' => $this->t('Most recent view'),
+      'help' => $this->t('The most recent time the node has been viewed.'),
+      'field' => [
+        'id' => 'node_counter_timestamp',
+        'click sortable' => TRUE,
+      ],
+      'filter' => [
+        'id' => 'date',
+      ],
+      'argument' => [
+        'id' => 'date',
+      ],
+      'sort' => [
+        'id' => 'standard',
+      ],
+    ];
+    return $data;
+  }
+
+}
diff --git a/statistics.module b/statistics.module
index bfa5b34..167aabf 100644
--- a/statistics.module
+++ b/statistics.module
@@ -5,152 +5,67 @@
  * Logs and displays content statistics for a site.
  */
 
+use Drupal\Core\Hook\Attribute\LegacyHook;
+use Drupal\statistics\Hook\StatisticsHooks;
 use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Routing\RouteMatchInterface;
-use Drupal\Core\Url;
 use Drupal\node\NodeInterface;
 
 /**
  * Implements hook_help().
  */
+#[LegacyHook]
 function statistics_help($route_name, RouteMatchInterface $route_match) {
-  switch ($route_name) {
-    case 'help.page.statistics':
-      $output = '';
-      $output .= '<h2>' . t('About') . '</h2>';
-      $output .= '<p>' .
-        t('The Statistics module shows you how often content is viewed. This is useful in determining which pages of your site are most popular. For more information, see the <a href=":statistics_do">online documentation for the Statistics module</a>.', [
-          ':statistics_do' => 'https://www.drupal.org/documentation/modules/statistics/',
-        ]) . '</p>';
-      $output .= '<h2>' . t('Uses') . '</h2>';
-      $output .= '<dl>';
-      $output .= '<dt>' . t('Displaying popular content') . '</dt>';
-      $settingsUrl = Url::fromRoute('statistics.settings')->toString();
-      $blockAdminUrl = (\Drupal::moduleHandler()
-        ->moduleExists('block')) ? Url::fromRoute('block.admin_display')
-        ->toString() : '#';
-      $permissionsUrl = Url::fromRoute('user.admin_permissions.module', [
-        'modules' => 'statistics',
-      ])->toString();
-      $output .= '<dd>' .
-        t('The module includes a <em>Popular content</em> block that displays the most viewed pages today and for all time, and the last content viewed. To use the block, enable <em>Count content views</em> on the <a href=":statistics-settings">Statistics page</a>, and then you can enable and configure the block on the <a href=":blocks">Block layout page</a>.', [
-          ':statistics-settings' => $settingsUrl,
-          ':blocks' => $blockAdminUrl,
-        ]) . '</dd>';
-      $output .= '<dt>' . t('Page view counter') . '</dt>';
-      $output .= '<dd>' .
-        t('The Statistics module includes a counter for each page that increases whenever the page is viewed. To use the counter, enable <em>Count content views</em> on the <a href=":statistics-settings">Statistics page</a>, and set the necessary <a href=":permissions">permissions</a> (<em>View content hits</em>) so that the counter is visible to the users.', [
-          ':statistics-settings' => $settingsUrl,
-          ':permissions' => $permissionsUrl,
-        ]) . '</dd>';
-      $output .= '</dl>';
-      return $output;
-
-    case 'statistics.settings':
-      return '<p>' . t('Settings for the statistical information that Drupal will keep about the site.') . '</p>';
-  }
+  return \Drupal::service(StatisticsHooks::class)->help($route_name, $route_match);
 }
 
 /**
  * Implements hook_ENTITY_TYPE_view() for node entities.
  */
+#[LegacyHook]
 function statistics_node_view(array &$build, EntityInterface $node, EntityViewDisplayInterface $display, $view_mode) {
-  if ($node->isNew() || $view_mode != 'full' || !node_is_page($node) || !empty($node->in_preview)) {
-    return;
-  }
-  $build['#attached']['library'][] = 'statistics/drupal.statistics';
-  $basePath = \Drupal::request()->getBasePath();
-  $statisticsPath = \Drupal::service('extension.list.module')
-    ->getPath('statistics');
-  $settings = [
-    'data' => ['nid' => $node->id()],
-    'url' => "{$basePath}/{$statisticsPath}/statistics.php",
-  ];
-  $build['#attached']['drupalSettings']['statistics'] = $settings;
+  \Drupal::service(StatisticsHooks::class)->nodeView($build, $node, $display, $view_mode);
 }
 
 /**
  * Implements hook_node_links_alter().
  */
+#[LegacyHook]
 function statistics_node_links_alter(array &$links, NodeInterface $entity, array &$context) {
-  if ($context['view_mode'] != 'rss') {
-    $links['#cache']['contexts'][] = 'user.permissions';
-    if (\Drupal::currentUser()->hasPermission('view post access counter')) {
-      $statistics = \Drupal::service('statistics.storage.node')
-        ->fetchView($entity->id());
-      if ($statistics) {
-        $statistics_links['statistics_counter']['title'] = \Drupal::translation()
-          ->formatPlural($statistics->getTotalCount(), '1 view', '@count views');
-        $links['statistics'] = [
-          '#theme' => 'links__node__statistics',
-          '#links' => $statistics_links,
-          '#attributes' => ['class' => ['links', 'inline']],
-        ];
-      }
-      $links['#cache']['max-age'] = \Drupal::config('statistics.settings')
-        ->get('display_max_age');
-    }
-  }
+  \Drupal::service(StatisticsHooks::class)->nodeLinksAlter($links, $entity, $context);
 }
 
 /**
  * Implements hook_cron().
  */
+#[LegacyHook]
 function statistics_cron() {
-  $storage = \Drupal::service('statistics.storage.node');
-  $storage->resetDayCount();
-  $max_total_count = $storage->maxTotalCount();
-  \Drupal::state()
-    ->set('statistics.node_counter_scale', 1.0 / max(1.0, $max_total_count));
+  \Drupal::service(StatisticsHooks::class)->cron();
 }
 
 /**
  * Implements hook_ENTITY_TYPE_predelete() for node entities.
  */
+#[LegacyHook]
 function statistics_node_predelete(EntityInterface $node) {
-  // Clean up statistics table when node is deleted.
-  $id = $node->id();
-  return \Drupal::service('statistics.storage.node')->deleteViews($id);
+  return \Drupal::service(StatisticsHooks::class)->nodePredelete($node);
 }
 
 /**
  * Implements hook_ranking().
  */
+#[LegacyHook]
 function statistics_ranking() {
-  if (\Drupal::config('statistics.settings')->get('count_content_views')) {
-    return [
-      'views' => [
-        'title' => t('Number of views'),
-        'join' => [
-          'type' => 'LEFT',
-          'table' => 'node_counter',
-          'alias' => 'node_counter',
-          'on' => 'node_counter.nid = i.sid',
-        ],
-        // Inverse law that maps the highest view count on the site to 1 and 0
-        // to 0. Note that the ROUND here is necessary for PostgreSQL and SQLite
-        // in order to ensure that the :statistics_scale argument is treated as
-        // a numeric type, because the PostgreSQL PDO driver sometimes puts
-        // values in as strings instead of numbers in complex expressions like
-        // this.
-        'score' => '2.0 - 2.0 / (1.0 + node_counter.totalcount * (ROUND(:statistics_scale, 4)))',
-        'arguments' => [
-          ':statistics_scale' => \Drupal::state()
-            ->get('statistics.node_counter_scale', 0),
-        ],
-      ],
-    ];
-  }
+  return \Drupal::service(StatisticsHooks::class)->ranking();
 }
 
 /**
  * Implements hook_preprocess_HOOK() for block templates.
  */
+#[LegacyHook]
 function statistics_preprocess_block(&$variables) {
-  if ($variables['configuration']['provider'] == 'statistics') {
-    $variables['attributes']['role'] = 'navigation';
-  }
+  \Drupal::service(StatisticsHooks::class)->preprocessBlock($variables);
 }
 
 /**
@@ -159,10 +74,7 @@ function statistics_preprocess_block(&$variables) {
  * Removes the "popular" block from display if the module is not configured
  * to count content views.
  */
+#[LegacyHook]
 function statistics_block_alter(&$definitions) {
-  $statistics_count_content_views = \Drupal::config('statistics.settings')
-    ->get('count_content_views');
-  if (empty($statistics_count_content_views)) {
-    unset($definitions['statistics_popular_block']);
-  }
+  \Drupal::service(StatisticsHooks::class)->blockAlter($definitions);
 }
diff --git a/statistics.services.yml b/statistics.services.yml
index beea6a6..55109a8 100644
--- a/statistics.services.yml
+++ b/statistics.services.yml
@@ -5,3 +5,15 @@ services:
     tags:
       - { name: backend_overridable }
   Drupal\statistics\StatisticsStorageInterface: '@statistics.storage.node'
+
+  Drupal\statistics\Hook\StatisticsHooks:
+    class: Drupal\statistics\Hook\StatisticsHooks
+    autowire: true
+
+  Drupal\statistics\Hook\StatisticsTokensHooks:
+    class: Drupal\statistics\Hook\StatisticsTokensHooks
+    autowire: true
+
+  Drupal\statistics\Hook\StatisticsViewsHooks:
+    class: Drupal\statistics\Hook\StatisticsViewsHooks
+    autowire: true
diff --git a/statistics.tokens.inc b/statistics.tokens.inc
index 72f6500..3997255 100644
--- a/statistics.tokens.inc
+++ b/statistics.tokens.inc
@@ -5,66 +5,22 @@
  * Builds placeholder replacement tokens for node visitor statistics.
  */
 
+use Drupal\Core\Hook\Attribute\LegacyHook;
+use Drupal\statistics\Hook\StatisticsTokensHooks;
 use Drupal\Core\Render\BubbleableMetadata;
 
 /**
  * Implements hook_token_info().
  */
+#[LegacyHook]
 function statistics_token_info() {
-  $node['total-count'] = [
-    'name' => t("Number of views"),
-    'description' => t("The number of visitors who have read the node."),
-  ];
-  $node['day-count'] = [
-    'name' => t("Views today"),
-    'description' => t("The number of visitors who have read the node today."),
-  ];
-  $node['last-view'] = [
-    'name' => t("Last view"),
-    'description' => t("The date on which a visitor last read the node."),
-    'type' => 'date',
-  ];
-
-  return [
-    'tokens' => ['node' => $node],
-  ];
+  return \Drupal::service(StatisticsTokensHooks::class)->tokenInfo();
 }
 
 /**
  * Implements hook_tokens().
  */
+#[LegacyHook]
 function statistics_tokens($type, $tokens, array $data, array $options, BubbleableMetadata $bubbleable_metadata) {
-  $token_service = \Drupal::token();
-
-  $replacements = [];
-
-  if ($type == 'node' & !empty($data['node'])) {
-    $node = $data['node'];
-
-    /** @var \Drupal\statistics\StatisticsStorageInterface $stats_storage */
-    $stats_storage = \Drupal::service('statistics.storage.node');
-    $node_view = NULL;
-
-    foreach ($tokens as $name => $original) {
-      if ($name == 'total-count') {
-        $node_view = $node_view ?? $stats_storage->fetchView($node->id());
-        $replacements[$original] = $node_view ? $node_view->getTotalCount() : 0;
-      }
-      elseif ($name == 'day-count') {
-        $node_view = $node_view ?? $stats_storage->fetchView($node->id());
-        $replacements[$original] = $node_view ? $node_view->getDayCount() : 0;
-      }
-      elseif ($name == 'last-view') {
-        $node_view = $node_view ?? $stats_storage->fetchView($node->id());
-        $replacements[$original] = $node_view ? \Drupal::service('date.formatter')->format($node_view->getTimestamp()) : t('never');
-      }
-    }
-
-    if ($created_tokens = $token_service->findWithPrefix($tokens, 'last-view')) {
-      $node_view = $node_view ?? $stats_storage->fetchView($node->id());
-      $replacements += $token_service->generate('date', $created_tokens, ['date' => $node_view ? $node_view->getTimestamp() : 0], $options, $bubbleable_metadata);
-    }
-  }
-
-  return $replacements;
+  return \Drupal::service(StatisticsTokensHooks::class)->tokens($type, $tokens, $data, $options, $bubbleable_metadata);
 }
diff --git a/statistics.views.inc b/statistics.views.inc
index 01e6d1e..88f2773 100644
--- a/statistics.views.inc
+++ b/statistics.views.inc
@@ -1,5 +1,12 @@
 <?php
 
+/**
+ * @file
+ */
+
+use Drupal\Core\Hook\Attribute\LegacyHook;
+use Drupal\statistics\Hook\StatisticsViewsHooks;
+
 /**
  * @file
  * Provide views data for statistics.module.
@@ -8,69 +15,7 @@
 /**
  * Implements hook_views_data().
  */
+#[LegacyHook]
 function statistics_views_data() {
-  $data['node_counter']['table']['group'] = t('Content statistics');
-
-  $data['node_counter']['table']['join'] = [
-    'node_field_data' => [
-      'left_field' => 'nid',
-      'field' => 'nid',
-    ],
-  ];
-
-  $data['node_counter']['totalcount'] = [
-    'title' => t('Total views'),
-    'help' => t('The total number of times the node has been viewed.'),
-    'field' => [
-      'id' => 'statistics_numeric',
-      'click sortable' => TRUE,
-    ],
-    'filter' => [
-      'id' => 'numeric',
-    ],
-    'argument' => [
-      'id' => 'numeric',
-    ],
-    'sort' => [
-      'id' => 'standard',
-    ],
-  ];
-
-  $data['node_counter']['daycount'] = [
-    'title' => t('Views today'),
-    'help' => t('The total number of times the node has been viewed today.'),
-    'field' => [
-      'id' => 'statistics_numeric',
-      'click sortable' => TRUE,
-    ],
-    'filter' => [
-      'id' => 'numeric',
-    ],
-    'argument' => [
-      'id' => 'numeric',
-    ],
-    'sort' => [
-      'id' => 'standard',
-    ],
-  ];
-
-  $data['node_counter']['timestamp'] = [
-    'title' => t('Most recent view'),
-    'help' => t('The most recent time the node has been viewed.'),
-    'field' => [
-      'id' => 'node_counter_timestamp',
-      'click sortable' => TRUE,
-    ],
-    'filter' => [
-      'id' => 'date',
-    ],
-    'argument' => [
-      'id' => 'date',
-    ],
-    'sort' => [
-      'id' => 'standard',
-    ],
-  ];
-
-  return $data;
+  return \Drupal::service(StatisticsViewsHooks::class)->viewsData();
 }
diff --git a/tests/src/Functional/GenericTest.php b/tests/src/Functional/GenericTest.php
index 2f92e04..9b85af3 100644
--- a/tests/src/Functional/GenericTest.php
+++ b/tests/src/Functional/GenericTest.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\statistics\Functional;
 
+use PHPUnit\Framework\Attributes\Group;
+use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
 use Drupal\Tests\system\Functional\Module\GenericModuleTestBase;
 
 /**
@@ -11,4 +13,6 @@ use Drupal\Tests\system\Functional\Module\GenericModuleTestBase;
  *
  * @group statistics
  */
+#[Group('statistics')]
+#[RunTestsInSeparateProcesses]
 class GenericTest extends GenericModuleTestBase {}
diff --git a/tests/src/Functional/StatisticsAdminTest.php b/tests/src/Functional/StatisticsAdminTest.php
index b95173e..035c768 100644
--- a/tests/src/Functional/StatisticsAdminTest.php
+++ b/tests/src/Functional/StatisticsAdminTest.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\statistics\Functional;
 
+use PHPUnit\Framework\Attributes\Group;
+use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
 use Drupal\Core\Database\Database;
 use Drupal\Core\State\StateInterface;
 use Drupal\node\NodeInterface;
@@ -17,6 +19,8 @@ use GuzzleHttp\Client;
  *
  * @group statistics
  */
+#[Group('statistics')]
+#[RunTestsInSeparateProcesses]
 class StatisticsAdminTest extends BrowserTestBase {
 
   use CronRunTrait;
diff --git a/tests/src/Functional/StatisticsAttachedTest.php b/tests/src/Functional/StatisticsAttachedTest.php
index b070859..646dea9 100644
--- a/tests/src/Functional/StatisticsAttachedTest.php
+++ b/tests/src/Functional/StatisticsAttachedTest.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\statistics\Functional;
 
+use PHPUnit\Framework\Attributes\Group;
+use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
 use Drupal\node\Entity\Node;
 use Drupal\Tests\BrowserTestBase;
 
@@ -12,6 +14,8 @@ use Drupal\Tests\BrowserTestBase;
  *
  * @group statistics
  */
+#[Group('statistics')]
+#[RunTestsInSeparateProcesses]
 class StatisticsAttachedTest extends BrowserTestBase {
 
   /**
diff --git a/tests/src/Functional/StatisticsInvalidPostTest.php b/tests/src/Functional/StatisticsInvalidPostTest.php
index 1375d27..1ab91b4 100644
--- a/tests/src/Functional/StatisticsInvalidPostTest.php
+++ b/tests/src/Functional/StatisticsInvalidPostTest.php
@@ -4,6 +4,9 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\statistics\Functional;
 
+use PHPUnit\Framework\Attributes\Group;
+use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
+use PHPUnit\Framework\Attributes\DataProvider;
 use Drupal\Core\Database\Connection;
 use Drupal\Tests\statistics\Unit\StatisticsNidFilterTest;
 
@@ -12,6 +15,8 @@ use Drupal\Tests\statistics\Unit\StatisticsNidFilterTest;
  *
  * @group statistics
  */
+#[Group('statistics')]
+#[RunTestsInSeparateProcesses]
 class StatisticsInvalidPostTest extends StatisticsTestBase {
 
   /**
@@ -64,6 +69,7 @@ class StatisticsInvalidPostTest extends StatisticsTestBase {
    *
    * @throws \GuzzleHttp\Exception\GuzzleException
    */
+  #[DataProvider('providerTestNidFilter')]
   public function testInvalidPost(mixed $nid, mixed $_, mixed $expected): void {
     $this->post($nid);
 
diff --git a/tests/src/Functional/StatisticsLoggingTest.php b/tests/src/Functional/StatisticsLoggingTest.php
index eda6d22..a340ab4 100644
--- a/tests/src/Functional/StatisticsLoggingTest.php
+++ b/tests/src/Functional/StatisticsLoggingTest.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\statistics\Functional;
 
+use PHPUnit\Framework\Attributes\Group;
+use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
 use Drupal\node\Entity\Node;
 use Drupal\Tests\BrowserTestBase;
 
@@ -15,6 +17,8 @@ use Drupal\Tests\BrowserTestBase;
  *
  * @group statistics
  */
+#[Group('statistics')]
+#[RunTestsInSeparateProcesses]
 class StatisticsLoggingTest extends BrowserTestBase {
 
   /**
diff --git a/tests/src/Functional/StatisticsReportsTest.php b/tests/src/Functional/StatisticsReportsTest.php
index 52bea8d..90523e2 100644
--- a/tests/src/Functional/StatisticsReportsTest.php
+++ b/tests/src/Functional/StatisticsReportsTest.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\statistics\Functional;
 
+use PHPUnit\Framework\Attributes\Group;
+use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
 use Drupal\Core\Cache\Cache;
 use Drupal\Core\Link;
 use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait;
@@ -13,6 +15,8 @@ use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait;
  *
  * @group statistics
  */
+#[Group('statistics')]
+#[RunTestsInSeparateProcesses]
 class StatisticsReportsTest extends StatisticsTestBase {
 
   use AssertPageCacheContextsAndTagsTrait;
diff --git a/tests/src/Functional/StatisticsTokenReplaceTest.php b/tests/src/Functional/StatisticsTokenReplaceTest.php
index 962f663..8842a87 100644
--- a/tests/src/Functional/StatisticsTokenReplaceTest.php
+++ b/tests/src/Functional/StatisticsTokenReplaceTest.php
@@ -4,11 +4,16 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\statistics\Functional;
 
+use PHPUnit\Framework\Attributes\Group;
+use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
+
 /**
  * Tests statistics token replacement.
  *
  * @group statistics
  */
+#[Group('statistics')]
+#[RunTestsInSeparateProcesses]
 class StatisticsTokenReplaceTest extends StatisticsTestBase {
 
   /**
diff --git a/tests/src/Functional/Views/IntegrationTest.php b/tests/src/Functional/Views/IntegrationTest.php
index 26e8997..0276b00 100644
--- a/tests/src/Functional/Views/IntegrationTest.php
+++ b/tests/src/Functional/Views/IntegrationTest.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\statistics\Functional\Views;
 
+use PHPUnit\Framework\Attributes\Group;
+use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
 use Drupal\Tests\views\Functional\ViewTestBase;
 use Drupal\user\Entity\User;
 
@@ -13,6 +15,8 @@ use Drupal\user\Entity\User;
  * @group statistics
  * @see
  */
+#[Group('statistics')]
+#[RunTestsInSeparateProcesses]
 class IntegrationTest extends ViewTestBase {
 
 
diff --git a/tests/src/Functional/migrate_drupal/d6/NoMultilingualReviewPageTest.php b/tests/src/Functional/migrate_drupal/d6/NoMultilingualReviewPageTest.php
index 38f995f..24ab418 100644
--- a/tests/src/Functional/migrate_drupal/d6/NoMultilingualReviewPageTest.php
+++ b/tests/src/Functional/migrate_drupal/d6/NoMultilingualReviewPageTest.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\statistics\Functional\migrate_drupal\d6;
 
+use PHPUnit\Framework\Attributes\Group;
+use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
 use Drupal\Tests\migrate_drupal_ui\Functional\NoMultilingualReviewPageTestBase;
 
 /**
@@ -11,6 +13,8 @@ use Drupal\Tests\migrate_drupal_ui\Functional\NoMultilingualReviewPageTestBase;
  *
  * @group statistics
  */
+#[Group('statistics')]
+#[RunTestsInSeparateProcesses]
 class NoMultilingualReviewPageTest extends NoMultilingualReviewPageTestBase {
 
   /**
diff --git a/tests/src/Functional/migrate_drupal/d7/NoMultilingualReviewPageTest.php b/tests/src/Functional/migrate_drupal/d7/NoMultilingualReviewPageTest.php
index de3d6ac..6460f29 100644
--- a/tests/src/Functional/migrate_drupal/d7/NoMultilingualReviewPageTest.php
+++ b/tests/src/Functional/migrate_drupal/d7/NoMultilingualReviewPageTest.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\statistics\Functional\migrate_drupal\d7;
 
+use PHPUnit\Framework\Attributes\Group;
+use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
 use Drupal\Tests\migrate_drupal_ui\Functional\NoMultilingualReviewPageTestBase;
 
 /**
@@ -13,6 +15,8 @@ use Drupal\Tests\migrate_drupal_ui\Functional\NoMultilingualReviewPageTestBase;
  *
  * @group statistics
  */
+#[Group('statistics')]
+#[RunTestsInSeparateProcesses]
 class NoMultilingualReviewPageTest extends NoMultilingualReviewPageTestBase {
 
   /**
diff --git a/tests/src/Functional/search/SearchRankingTest.php b/tests/src/Functional/search/SearchRankingTest.php
index fc7a8bd..08d1e00 100644
--- a/tests/src/Functional/search/SearchRankingTest.php
+++ b/tests/src/Functional/search/SearchRankingTest.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\statistics\Functional\search;
 
+use PHPUnit\Framework\Attributes\Group;
+use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
 use Drupal\Core\Database\Database;
 use Drupal\search\Entity\SearchPage;
 use Drupal\Tests\BrowserTestBase;
@@ -14,6 +16,8 @@ use Drupal\Tests\Traits\Core\CronRunTrait;
  *
  * @group statistics
  */
+#[Group('statistics')]
+#[RunTestsInSeparateProcesses]
 class SearchRankingTest extends BrowserTestBase {
 
   use CronRunTrait;
diff --git a/tests/src/FunctionalJavascript/StatisticsLoggingTest.php b/tests/src/FunctionalJavascript/StatisticsLoggingTest.php
index 8b0e57b..1958766 100644
--- a/tests/src/FunctionalJavascript/StatisticsLoggingTest.php
+++ b/tests/src/FunctionalJavascript/StatisticsLoggingTest.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\statistics\FunctionalJavascript;
 
+use PHPUnit\Framework\Attributes\Group;
+use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
 use Drupal\Core\Session\AccountInterface;
 use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
 use Drupal\language\Entity\ConfigurableLanguage;
@@ -14,6 +16,8 @@ use Drupal\user\Entity\Role;
  *
  * @group system
  */
+#[Group('system')]
+#[RunTestsInSeparateProcesses]
 class StatisticsLoggingTest extends WebDriverTestBase {
 
   /**
diff --git a/tests/src/Kernel/Migrate/d6/MigrateNodeCounterTest.php b/tests/src/Kernel/Migrate/d6/MigrateNodeCounterTest.php
index d127858..7642ffd 100644
--- a/tests/src/Kernel/Migrate/d6/MigrateNodeCounterTest.php
+++ b/tests/src/Kernel/Migrate/d6/MigrateNodeCounterTest.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\statistics\Kernel\Migrate\d6;
 
+use PHPUnit\Framework\Attributes\Group;
+use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
 use Drupal\Tests\migrate_drupal\Kernel\d6\MigrateDrupal6TestBase;
 
 /**
@@ -11,6 +13,8 @@ use Drupal\Tests\migrate_drupal\Kernel\d6\MigrateDrupal6TestBase;
  *
  * @group statistics
  */
+#[Group('statistics')]
+#[RunTestsInSeparateProcesses]
 class MigrateNodeCounterTest extends MigrateDrupal6TestBase {
 
   /**
diff --git a/tests/src/Kernel/Migrate/d6/MigrateStatisticsConfigsTest.php b/tests/src/Kernel/Migrate/d6/MigrateStatisticsConfigsTest.php
index e272ba2..04d8930 100644
--- a/tests/src/Kernel/Migrate/d6/MigrateStatisticsConfigsTest.php
+++ b/tests/src/Kernel/Migrate/d6/MigrateStatisticsConfigsTest.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\statistics\Kernel\Migrate\d6;
 
+use PHPUnit\Framework\Attributes\Group;
+use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
 use Drupal\Tests\migrate_drupal\Kernel\d6\MigrateDrupal6TestBase;
 use Drupal\Tests\SchemaCheckTestTrait;
 
@@ -12,6 +14,8 @@ use Drupal\Tests\SchemaCheckTestTrait;
  *
  * @group migrate_drupal_6
  */
+#[Group('migrate_drupal_6')]
+#[RunTestsInSeparateProcesses]
 class MigrateStatisticsConfigsTest extends MigrateDrupal6TestBase {
 
   use SchemaCheckTestTrait;
diff --git a/tests/src/Kernel/Migrate/d7/MigrateNodeCounterTest.php b/tests/src/Kernel/Migrate/d7/MigrateNodeCounterTest.php
index 9d8d155..79b8c34 100644
--- a/tests/src/Kernel/Migrate/d7/MigrateNodeCounterTest.php
+++ b/tests/src/Kernel/Migrate/d7/MigrateNodeCounterTest.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\statistics\Kernel\Migrate\d7;
 
+use PHPUnit\Framework\Attributes\Group;
+use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
 use Drupal\Tests\migrate_drupal\Kernel\d7\MigrateDrupal7TestBase;
 
 /**
@@ -11,6 +13,8 @@ use Drupal\Tests\migrate_drupal\Kernel\d7\MigrateDrupal7TestBase;
  *
  * @group statistics
  */
+#[Group('statistics')]
+#[RunTestsInSeparateProcesses]
 class MigrateNodeCounterTest extends MigrateDrupal7TestBase {
 
   /**
diff --git a/tests/src/Kernel/Migrate/d7/MigrateStatisticsConfigsTest.php b/tests/src/Kernel/Migrate/d7/MigrateStatisticsConfigsTest.php
index a6b8f73..3ec8e27 100644
--- a/tests/src/Kernel/Migrate/d7/MigrateStatisticsConfigsTest.php
+++ b/tests/src/Kernel/Migrate/d7/MigrateStatisticsConfigsTest.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\statistics\Kernel\Migrate\d7;
 
+use PHPUnit\Framework\Attributes\Group;
+use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
 use Drupal\Tests\migrate_drupal\Kernel\d7\MigrateDrupal7TestBase;
 use Drupal\Tests\SchemaCheckTestTrait;
 
@@ -12,6 +14,8 @@ use Drupal\Tests\SchemaCheckTestTrait;
  *
  * @group migrate_drupal_7
  */
+#[Group('migrate_drupal_7')]
+#[RunTestsInSeparateProcesses]
 class MigrateStatisticsConfigsTest extends MigrateDrupal7TestBase {
 
   use SchemaCheckTestTrait;
diff --git a/tests/src/Kernel/Plugin/migrate/source/NodeCounterTest.php b/tests/src/Kernel/Plugin/migrate/source/NodeCounterTest.php
index 78f8dde..f8b0910 100644
--- a/tests/src/Kernel/Plugin/migrate/source/NodeCounterTest.php
+++ b/tests/src/Kernel/Plugin/migrate/source/NodeCounterTest.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\statistics\Kernel\Plugin\migrate\source;
 
+use PHPUnit\Framework\Attributes\Group;
+use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
 use Drupal\Tests\migrate\Kernel\MigrateSqlSourceTestBase;
 
 /**
@@ -13,6 +15,8 @@ use Drupal\Tests\migrate\Kernel\MigrateSqlSourceTestBase;
  *
  * @group statistics
  */
+#[Group('statistics')]
+#[RunTestsInSeparateProcesses]
 class NodeCounterTest extends MigrateSqlSourceTestBase {
 
   /**
diff --git a/tests/src/Unit/StatisticsNidFilterTest.php b/tests/src/Unit/StatisticsNidFilterTest.php
index 16c366e..d01e6ab 100644
--- a/tests/src/Unit/StatisticsNidFilterTest.php
+++ b/tests/src/Unit/StatisticsNidFilterTest.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\statistics\Unit;
 
+use PHPUnit\Framework\Attributes\Group;
+use PHPUnit\Framework\Attributes\DataProvider;
 use Drupal\Tests\UnitTestCase;
 
 /**
@@ -13,6 +15,7 @@ use Drupal\Tests\UnitTestCase;
  *
  * @group statistics
  */
+#[Group('statistics')]
 class StatisticsNidFilterTest extends UnitTestCase {
 
   const NORMAL = 25;
@@ -86,6 +89,7 @@ class StatisticsNidFilterTest extends UnitTestCase {
    *
    * @dataProvider providerTestNidFilter
    */
+  #[DataProvider('providerTestNidFilter')]
   public function testNidFilter(mixed $input, mixed $expected) {
     $actual = filter_var($input, FILTER_VALIDATE_INT,
       ['options' => ['min_range' => 1]]
diff --git a/tests/src/Unit/StatisticsViewsResultTest.php b/tests/src/Unit/StatisticsViewsResultTest.php
index 4024f48..1d2d7b2 100644
--- a/tests/src/Unit/StatisticsViewsResultTest.php
+++ b/tests/src/Unit/StatisticsViewsResultTest.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\statistics\Unit;
 
+use PHPUnit\Framework\Attributes\Group;
+use PHPUnit\Framework\Attributes\DataProvider;
 use Drupal\statistics\StatisticsViewsResult;
 use Drupal\Tests\UnitTestCase;
 
@@ -11,6 +13,7 @@ use Drupal\Tests\UnitTestCase;
  * @coversDefaultClass \Drupal\statistics\StatisticsViewsResult
  * @group statistics
  */
+#[Group('statistics')]
 class StatisticsViewsResultTest extends UnitTestCase {
 
   /**
@@ -20,6 +23,7 @@ class StatisticsViewsResultTest extends UnitTestCase {
    *
    * @dataProvider providerTestStatisticsCount
    */
+  #[DataProvider('providerTestStatisticsCount')]
   public function testStatisticsCount($total_count, $day_count, $timestamp) {
     $statistics = new StatisticsViewsResult($total_count, $day_count, $timestamp);
     $this->assertSame((int) $total_count, $statistics->getTotalCount());
