diff --git a/plausible.info.yml b/plausible.info.yml
index 9cfabb5..a941df4 100644
--- a/plausible.info.yml
+++ b/plausible.info.yml
@@ -2,6 +2,6 @@ name: Plausible
 type: module
 description: 'Allows your site to be tracked using Plausible, by adding a Javascript tracking snippet to every page.'
 package: Statistics
-core_version_requirement: ^9.1 || ^10 || ^11
+core_version_requirement: ^10.1 || ^11 || ^12
 php: 8.0
 configure: plausible.admin_settings_form
diff --git a/plausible.module b/plausible.module
index 1e6c308..7c74e1d 100644
--- a/plausible.module
+++ b/plausible.module
@@ -5,32 +5,17 @@
  * Drupal Module: Plausible.
  */
 
-use Drupal\Component\Utility\Html;
+use Drupal\Core\Hook\Attribute\LegacyHook;
+use Drupal\plausible\Hook\PlausibleHooks;
 use Drupal\Core\Routing\RouteMatchInterface;
 use Drupal\Core\Url;
-use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
 
 /**
  * Implements hook_help().
  */
+#[LegacyHook]
 function plausible_help($route_name, RouteMatchInterface $route_match) {
-  switch ($route_name) {
-    case 'help.page.plausible':
-      $text = file_get_contents(__DIR__ . '/README.md');
-      if (!\Drupal::moduleHandler()->moduleExists('markdown')) {
-        return '<pre>' . Html::escape($text) . '</pre>';
-      }
-
-      // Use the Markdown filter to render the README.
-      $filter_manager = \Drupal::service('plugin.manager.filter');
-      $settings = \Drupal::configFactory()->get('markdown.settings')->getRawData();
-      $config = ['settings' => $settings];
-      $filter = $filter_manager->createInstance('markdown', $config);
-
-      return $filter->process($text, 'en');
-  }
-
-  return NULL;
+  return \Drupal::service(PlausibleHooks::class)->help($route_name, $route_match);
 }
 
 /**
@@ -39,67 +24,9 @@ function plausible_help($route_name, RouteMatchInterface $route_match) {
  * Inserts the JavaScript snippet into to <head> of each page before they are
  * rendered.
  */
+#[LegacyHook]
 function plausible_page_attachments(array &$attachments) {
-  $account = \Drupal::currentUser();
-  $config = \Drupal::config('plausible.settings');
-
-  if (!$config->get('visibility.enable')) {
-    return;
-  }
-
-  $visibility_request_path_mode = (int) $config->get('visibility.request_path_mode');
-  if ($visibility_request_path_mode > 0) {
-    $attachments['#cache']['contexts'][] = 'url.path';
-  }
-
-  $visibility_user_role_mode = (int) $config->get('visibility.user_role_mode');
-  if ($visibility_user_role_mode > 0) {
-    $attachments['#cache']['contexts'][] = 'user.roles';
-  }
-
-  $visibility_admin_route_mode = (int) $config->get('visibility.admin_route_mode');
-  if ($visibility_admin_route_mode > 0) {
-    $attachments['#cache']['contexts'][] = 'route.is_admin';
-  }
-
-  $attachments['#cache']['tags'][] = 'config:plausible.settings';
-
-  if (!_plausible_visibility_pages() || !_plausible_visibility_roles($account) || !_plausible_visibility_admin_routes()) {
-    return;
-  }
-
-  $version = $config->get('script.version');
-  $src = $config->get('script.src');
-
-  if ($version === 'october-2025') {
-    _plausible_attach_new_snippet($attachments);
-  }
-  elseif ($src) {
-    _plausible_attach_old_snippet($attachments);
-  }
-  else {
-    return;
-  }
-
-  $exception = \Drupal::requestStack()->getCurrentRequest()->attributes->get('exception');
-  if ($exception instanceof HttpExceptionInterface && $exception->getStatusCode() === 403 && $config->get('events.403')) {
-    $attachments['#attached']['html_head'][] = [
-      [
-        '#tag' => 'script',
-        '#value' => 'plausible("403", { props: { path: document.location.pathname } });',
-      ],
-      'plausible_tracking_snippet_event_403',
-    ];
-  }
-  if ($exception instanceof HttpExceptionInterface && $exception->getStatusCode() === 404 && $config->get('events.404')) {
-    $attachments['#attached']['html_head'][] = [
-      [
-        '#tag' => 'script',
-        '#value' => 'plausible("404", { props: { path: document.location.pathname } });',
-      ],
-      'plausible_tracking_snippet_event_404',
-    ];
-  }
+  \Drupal::service(PlausibleHooks::class)->pageAttachments($attachments);
 }
 
 /**
diff --git a/plausible.services.yml b/plausible.services.yml
index 761c52d..abe3520 100644
--- a/plausible.services.yml
+++ b/plausible.services.yml
@@ -5,3 +5,7 @@ services:
       - '@router.admin_context'
     tags:
       - { name: cache.context }
+
+  Drupal\plausible\Hook\PlausibleHooks:
+    class: Drupal\plausible\Hook\PlausibleHooks
+    autowire: true
diff --git a/src/Hook/PlausibleHooks.php b/src/Hook/PlausibleHooks.php
new file mode 100644
index 0000000..86bbcd2
--- /dev/null
+++ b/src/Hook/PlausibleHooks.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace Drupal\plausible\Hook;
+
+use Drupal\Component\Utility\Html;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Url;
+use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
+use Drupal\Core\Hook\Attribute\Hook;
+/**
+ * Hook implementations for plausible.
+ */
+class PlausibleHooks
+{
+    /**
+     * Implements hook_help().
+     */
+    #[Hook('help')]
+    public static function help($route_name, \Drupal\Core\Routing\RouteMatchInterface $route_match)
+    {
+        switch ($route_name) {
+            case 'help.page.plausible':
+                $text = file_get_contents(__DIR__ . '/README.md');
+                if (!\Drupal::moduleHandler()->moduleExists('markdown')) {
+                    return '<pre>' . \Drupal\Component\Utility\Html::escape($text) . '</pre>';
+                }
+                // Use the Markdown filter to render the README.
+                $filter_manager = \Drupal::service('plugin.manager.filter');
+                $settings = \Drupal::configFactory()->get('markdown.settings')->getRawData();
+                $config = [
+                    'settings' => $settings,
+                ];
+                $filter = $filter_manager->createInstance('markdown', $config);
+                return $filter->process($text, 'en');
+        }
+        return NULL;
+    }
+    /**
+     * Implements hook_page_attachments().
+     *
+     * Inserts the JavaScript snippet into to <head> of each page before they are
+     * rendered.
+     */
+    #[Hook('page_attachments')]
+    public static function pageAttachments(array &$attachments)
+    {
+        $account = \Drupal::currentUser();
+        $config = \Drupal::config('plausible.settings');
+        if (!$config->get('visibility.enable')) {
+            return;
+        }
+        $visibility_request_path_mode = (int) $config->get('visibility.request_path_mode');
+        if ($visibility_request_path_mode > 0) {
+            $attachments['#cache']['contexts'][] = 'url.path';
+        }
+        $visibility_user_role_mode = (int) $config->get('visibility.user_role_mode');
+        if ($visibility_user_role_mode > 0) {
+            $attachments['#cache']['contexts'][] = 'user.roles';
+        }
+        $visibility_admin_route_mode = (int) $config->get('visibility.admin_route_mode');
+        if ($visibility_admin_route_mode > 0) {
+            $attachments['#cache']['contexts'][] = 'route.is_admin';
+        }
+        $attachments['#cache']['tags'][] = 'config:plausible.settings';
+        if (!_plausible_visibility_pages() || !_plausible_visibility_roles($account) || !_plausible_visibility_admin_routes()) {
+            return;
+        }
+        $version = $config->get('script.version');
+        $src = $config->get('script.src');
+        if ($version === 'october-2025') {
+            _plausible_attach_new_snippet($attachments);
+        } elseif ($src) {
+            _plausible_attach_old_snippet($attachments);
+        } else {
+            return;
+        }
+        $exception = \Drupal::requestStack()->getCurrentRequest()->attributes->get('exception');
+        if ($exception instanceof \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface && $exception->getStatusCode() === 403 && $config->get('events.403')) {
+            $attachments['#attached']['html_head'][] = [
+                [
+                    '#tag' => 'script',
+                    '#value' => 'plausible("403", { props: { path: document.location.pathname } });',
+                ],
+                'plausible_tracking_snippet_event_403',
+            ];
+        }
+        if ($exception instanceof \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface && $exception->getStatusCode() === 404 && $config->get('events.404')) {
+            $attachments['#attached']['html_head'][] = [
+                [
+                    '#tag' => 'script',
+                    '#value' => 'plausible("404", { props: { path: document.location.pathname } });',
+                ],
+                'plausible_tracking_snippet_event_404',
+            ];
+        }
+    }
+}
diff --git a/tests/src/Functional/PlausibleSnippetTest.php b/tests/src/Functional/PlausibleSnippetTest.php
index 1652aa9..b92e2f8 100644
--- a/tests/src/Functional/PlausibleSnippetTest.php
+++ b/tests/src/Functional/PlausibleSnippetTest.php
@@ -2,6 +2,8 @@
 
 namespace Drupal\Tests\plausible\Functional;
 
+use PHPUnit\Framework\Attributes\Group;
+use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
 use Drupal\Tests\BrowserTestBase;
 
 /**
@@ -9,6 +11,8 @@ use Drupal\Tests\BrowserTestBase;
  *
  * @group plausible
  */
+#[Group('plausible')]
+#[RunTestsInSeparateProcesses]
 final class PlausibleSnippetTest extends BrowserTestBase {
 
   const SCRIPT_SELECTOR = 'script[async][defer][src="https://plausible.io/js/pa-XXXX.js"]';
