diff --git a/src/Hook/ViewsNodeAccessFilterAccessRecordsHooks.php b/src/Hook/ViewsNodeAccessFilterAccessRecordsHooks.php
new file mode 100644
index 0000000..233267b
--- /dev/null
+++ b/src/Hook/ViewsNodeAccessFilterAccessRecordsHooks.php
@@ -0,0 +1,120 @@
+<?php
+
+namespace Drupal\views_node_access_filter\Hook;
+
+use Drupal\node\NodeAccessRebuild;
+use Drupal\Component\Utility\DeprecationHelper;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\node\NodeInterface;
+use Drupal\node\Entity\NodeType;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Hook\Attribute\Hook;
+
+/**
+ * Hook implementations for views_node_access_filter.
+ */
+class ViewsNodeAccessFilterAccessRecordsHooks {
+
+  /**
+   * Implements hook_node_grants().
+   *
+   * @see node_query_node_access_alter()
+   * @see node_node_access()
+   */
+  #[Hook('node_grants')]
+  public static function nodeGrants(AccountInterface $account, $op) {
+    $grants = [];
+    if ($op == 'update') {
+      foreach (array_keys(NodeType::loadMultiple()) as $type) {
+        if ($account->hasPermission('edit any ' . $type . ' content')) {
+          $grants['edit any ' . $type . ' content'] = [
+            0,
+          ];
+        }
+        elseif ($account->id() && $account->hasPermission('edit own ' . $type . ' content')) {
+          $grants['edit own ' . $type . ' content'] = [
+            $account->id(),
+          ];
+        }
+      }
+    }
+    elseif ($op == 'view') {
+      if ($account->hasPermission('view own unpublished content')) {
+        $grants['view own unpublished content'] = [
+          $account->id(),
+        ];
+      }
+    }
+    return $grants;
+  }
+
+  /**
+   * Implements hook_node_access_records().
+   *
+   * @see views_node_access_filter_node_grants()
+   */
+  #[Hook('node_access_records')]
+  public static function nodeAccessRecords(NodeInterface $node) {
+    $type = $node->bundle();
+    $grants = [];
+    $grants[] = [
+      'realm' => 'edit any ' . $type . ' content',
+      'gid' => 0,
+      'grant_view' => 0,
+      'grant_update' => 1,
+      'grant_delete' => 0,
+    ];
+    if ($owner_id = $node->getOwnerId()) {
+      $grants[] = [
+        'realm' => 'edit own ' . $type . ' content',
+        'gid' => $owner_id,
+        'grant_view' => 0,
+        'grant_update' => 1,
+        'grant_delete' => 0,
+      ];
+    }
+    return $grants;
+  }
+
+  /**
+   * Implements hook_node_access_records_alter().
+   */
+  #[Hook('node_access_records_alter')]
+  public static function nodeAccessRecordsAlter(&$grants, NodeInterface $node) {
+    // As soon as there is at least one record for a node, Drupal core does not
+    // handle default view access on its side.
+    // @see NodeAccessControlHandler::acquireGrants()
+    // But our module doesn't want to alter default view access, so, if no other
+    // module defines records, we mimic the default core behavior.
+    if (!_views_node_access_filter_external_grants_are_defined($grants, $node)) {
+      if ($node->isPublished()) {
+        $grants[] = [
+          'realm' => 'all',
+          'gid' => 0,
+          'grant_view' => 1,
+          'grant_update' => 0,
+          'grant_delete' => 0,
+        ];
+      }
+      elseif ($owner_id = $node->getOwnerId()) {
+        $grants[] = [
+          'realm' => 'view own unpublished content',
+          'gid' => $owner_id,
+          'grant_view' => 1,
+          'grant_update' => 0,
+          'grant_delete' => 0,
+        ];
+      }
+    }
+  }
+
+  /**
+   * Implements hook_ENTITY_TYPE_update().
+   */
+  #[Hook('user_role_update')]
+  public static function userRoleUpdate(EntityInterface $entity) {
+    // Rebuild node grants when permissions change.
+    DeprecationHelper::backwardsCompatibleCall(\Drupal::VERSION, '11.4.0', fn() => \Drupal::service(NodeAccessRebuild::class)->setNeedsRebuild(TRUE), fn() => node_access_needs_rebuild(TRUE));
+  }
+
+}
diff --git a/src/Hook/ViewsNodeAccessFilterHooks.php b/src/Hook/ViewsNodeAccessFilterHooks.php
new file mode 100644
index 0000000..6e7d03c
--- /dev/null
+++ b/src/Hook/ViewsNodeAccessFilterHooks.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Drupal\views_node_access_filter\Hook;
+
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Database\Query\AlterableInterface;
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * Hook implementations for views_node_access_filter.
+ */
+class ViewsNodeAccessFilterHooks {
+  use StringTranslationTrait;
+
+  /**
+   * Implements hook_views_data_alter().
+   */
+  #[Hook('views_data_alter')]
+  public function viewsDataAlter(array &$data) {
+    $data['node_field_data']['editable'] = [
+      'title' => $this->t('Editable'),
+      'help' => $this->t('Only keep nodes for which the current user has edit access.'),
+      'filter' => [
+        'id' => 'views_node_access_filter_editable',
+        'field' => 'nid',
+        'label' => $this->t('Editable'),
+      ],
+    ];
+    $data['node_field_revision']['editable'] = [
+      'title' => $this->t('Editable'),
+      'help' => $this->t('Only keep revisions for which the current user has edit access.'),
+      'filter' => [
+        'id' => 'views_node_access_filter_editable',
+        'field' => 'vid',
+        'label' => $this->t('Editable'),
+      ],
+    ];
+  }
+
+  /**
+   * Implements hook_query_TAG_alter().
+   *
+   * @see node_query_node_access_alter()
+   */
+  #[Hook('query_views_node_access_filter_editable_alter')]
+  public static function queryViewsNodeAccessFilterEditableAlter(AlterableInterface $query) {
+    $query->addMetaData('op', 'update');
+    $query->addTag('node_access');
+  }
+
+  /**
+   * Implements hook_help().
+   */
+  #[Hook('help')]
+  public function help($route_name, RouteMatchInterface $route_match) {
+    switch ($route_name) {
+      case 'help.page.views_node_access_filter':
+        $output = '';
+        $output .= '<h3>' . $this->t('About') . '</h3>';
+        $output .= '<p>' . $this->t('This module provides a Views filter to only show the nodes that the current user is allowed to edit. This is typically useful as an UX improvement for editors on the /admin/content page.') . '</p>';
+        $output .= '<p>' . $this->t('Note: because of the way core deals with node access (for performance reasons), this module may not take into account some node access modules which change the update access without updating the node access registry.') . '</p>';
+        // Add a link to the Drupal.org project.
+        $output .= '<p>';
+        $output .= $this->t('Visit the <a href=":project_link">Views Node Access Filter project pages</a> on Drupal.org for more information.', [
+          ':project_link' => 'https://www.drupal.org/project/views_node_access_filter',
+        ]);
+        $output .= '</p>';
+        return $output;
+
+      default:
+    }
+  }
+
+}
diff --git a/tests/src/Unit/EditableTest.php b/tests/src/Unit/EditableTest.php
index 9a78de0..47bd742 100644
--- a/tests/src/Unit/EditableTest.php
+++ b/tests/src/Unit/EditableTest.php
@@ -2,6 +2,7 @@
 
 namespace Drupal\Tests\views_node_access_filter\Unit;
 
+use PHPUnit\Framework\Attributes\Group;
 use Drupal\Tests\UnitTestCase;
 use Drupal\views_node_access_filter\Plugin\views\filter\Editable;
 
@@ -10,6 +11,7 @@ use Drupal\views_node_access_filter\Plugin\views\filter\Editable;
  *
  * @group views_node_access_filter
  */
+#[Group('views_node_access_filter')]
 class EditableTest extends UnitTestCase {
 
   /**
diff --git a/views_node_access_filter.access_records.inc b/views_node_access_filter.access_records.inc
index 2df95b7..5c50dd0 100644
--- a/views_node_access_filter.access_records.inc
+++ b/views_node_access_filter.access_records.inc
@@ -27,9 +27,10 @@
  * when a module is enabled or disabled.
  */
 
+use Drupal\Core\Hook\Attribute\LegacyHook;
+use Drupal\views_node_access_filter\Hook\ViewsNodeAccessFilterAccessRecordsHooks;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\node\NodeInterface;
-use Drupal\node\Entity\NodeType;
 use Drupal\Core\Session\AccountInterface;
 
 /**
@@ -38,25 +39,9 @@ use Drupal\Core\Session\AccountInterface;
  * @see node_query_node_access_alter()
  * @see node_node_access()
  */
+#[LegacyHook]
 function views_node_access_filter_node_grants(AccountInterface $account, $op) {
-
-  $grants = [];
-  if ($op == 'update') {
-    foreach (array_keys(NodeType::loadMultiple()) as $type) {
-      if ($account->hasPermission('edit any ' . $type . ' content')) {
-        $grants['edit any ' . $type . ' content'] = [0];
-      }
-      elseif ($account->id() && $account->hasPermission('edit own ' . $type . ' content')) {
-        $grants['edit own ' . $type . ' content'] = [$account->id()];
-      }
-    }
-  }
-  elseif ($op == 'view') {
-    if ($account->hasPermission('view own unpublished content')) {
-      $grants['view own unpublished content'] = [$account->id()];
-    }
-  }
-  return $grants;
+  return \Drupal::service(ViewsNodeAccessFilterAccessRecordsHooks::class)->nodeGrants($account, $op);
 }
 
 /**
@@ -64,61 +49,17 @@ function views_node_access_filter_node_grants(AccountInterface $account, $op) {
  *
  * @see views_node_access_filter_node_grants()
  */
+#[LegacyHook]
 function views_node_access_filter_node_access_records(NodeInterface $node) {
-
-  $type = $node->bundle();
-  $grants = [];
-
-  $grants[] = [
-    'realm' => 'edit any ' . $type . ' content',
-    'gid' => 0,
-    'grant_view' => 0,
-    'grant_update' => 1,
-    'grant_delete' => 0,
-  ];
-
-  if ($owner_id = $node->getOwnerId()) {
-    $grants[] = [
-      'realm' => 'edit own ' . $type . ' content',
-      'gid' => $owner_id,
-      'grant_view' => 0,
-      'grant_update' => 1,
-      'grant_delete' => 0,
-    ];
-  }
-
-  return $grants;
+  return \Drupal::service(ViewsNodeAccessFilterAccessRecordsHooks::class)->nodeAccessRecords($node);
 }
 
 /**
  * Implements hook_node_access_records_alter().
  */
+#[LegacyHook]
 function views_node_access_filter_node_access_records_alter(&$grants, NodeInterface $node) {
-  // As soon as there is at least one record for a node, Drupal core does not
-  // handle default view access on its side.
-  // @see NodeAccessControlHandler::acquireGrants()
-  // But our module doesn't want to alter default view access, so, if no other
-  // module defines records, we mimic the default core behavior.
-  if (!_views_node_access_filter_external_grants_are_defined($grants, $node)) {
-    if ($node->isPublished()) {
-      $grants[] = [
-        'realm' => 'all',
-        'gid' => 0,
-        'grant_view' => 1,
-        'grant_update' => 0,
-        'grant_delete' => 0,
-      ];
-    }
-    elseif ($owner_id = $node->getOwnerId()) {
-      $grants[] = [
-        'realm' => 'view own unpublished content',
-        'gid' => $owner_id,
-        'grant_view' => 1,
-        'grant_update' => 0,
-        'grant_delete' => 0,
-      ];
-    }
-  }
+  \Drupal::service(ViewsNodeAccessFilterAccessRecordsHooks::class)->nodeAccessRecordsAlter($grants, $node);
 }
 
 /**
@@ -132,7 +73,7 @@ function _views_node_access_filter_external_grants_are_defined($grants, $node) {
 /**
  * Implements hook_ENTITY_TYPE_update().
  */
+#[LegacyHook]
 function views_node_access_filter_user_role_update(EntityInterface $entity) {
-  // Rebuild node grants when permissions change.
-  node_access_needs_rebuild(TRUE);
+  \Drupal::service(ViewsNodeAccessFilterAccessRecordsHooks::class)->userRoleUpdate($entity);
 }
diff --git a/views_node_access_filter.info.yml b/views_node_access_filter.info.yml
index 43e5bd3..2f5bc19 100644
--- a/views_node_access_filter.info.yml
+++ b/views_node_access_filter.info.yml
@@ -2,7 +2,7 @@ name: Views Node Access Filter
 type: module
 description: Provides a Views filter to list only editable nodes.
 package: Views
-core_version_requirement: ^8 || ^9 || ^10 || ^11
+core_version_requirement: ^10.1 || ^11 || ^12
 
 dependencies:
   - node
diff --git a/views_node_access_filter.install b/views_node_access_filter.install
index 98f0082..accf474 100644
--- a/views_node_access_filter.install
+++ b/views_node_access_filter.install
@@ -1,5 +1,12 @@
 <?php
 
+/**
+ * @file
+ */
+
+use Drupal\Component\Utility\DeprecationHelper;
+use Drupal\node\NodeAccessRebuild;
+
 /**
  * @file
  * Install file.
@@ -9,5 +16,5 @@
  * Mark node access grants as needing a rebuilding.
  */
 function views_node_access_filter_update_8002() {
-  node_access_needs_rebuild(TRUE);
+  DeprecationHelper::backwardsCompatibleCall(\Drupal::VERSION, '11.4.0', fn() => \Drupal::service(NodeAccessRebuild::class)->setNeedsRebuild(TRUE), fn() => node_access_needs_rebuild(TRUE));
 }
diff --git a/views_node_access_filter.module b/views_node_access_filter.module
index a78d25d..8491a62 100644
--- a/views_node_access_filter.module
+++ b/views_node_access_filter.module
@@ -1,5 +1,12 @@
 <?php
 
+/**
+ * @file
+ */
+
+use Drupal\Core\Hook\Attribute\LegacyHook;
+use Drupal\views_node_access_filter\Hook\ViewsNodeAccessFilterHooks;
+
 /**
  * @file
  * Module file.
@@ -13,25 +20,9 @@ use Drupal\Core\Routing\RouteMatchInterface;
 /**
  * Implements hook_views_data_alter().
  */
+#[LegacyHook]
 function views_node_access_filter_views_data_alter(array &$data) {
-  $data['node_field_data']['editable'] = [
-    'title' => t('Editable'),
-    'help' => t('Only keep nodes for which the current user has edit access.'),
-    'filter' => [
-      'id' => 'views_node_access_filter_editable',
-      'field' => 'nid',
-      'label' => t('Editable'),
-    ],
-  ];
-  $data['node_field_revision']['editable'] = [
-    'title' => t('Editable'),
-    'help' => t('Only keep revisions for which the current user has edit access.'),
-    'filter' => [
-      'id' => 'views_node_access_filter_editable',
-      'field' => 'vid',
-      'label' => t('Editable'),
-    ],
-  ];
+  \Drupal::service(ViewsNodeAccessFilterHooks::class)->viewsDataAlter($data);
 }
 
 /**
@@ -39,9 +30,9 @@ function views_node_access_filter_views_data_alter(array &$data) {
  *
  * @see node_query_node_access_alter()
  */
+#[LegacyHook]
 function views_node_access_filter_query_views_node_access_filter_editable_alter(AlterableInterface $query) {
-  $query->addMetaData('op', 'update');
-  $query->addTag('node_access');
+  \Drupal::service(ViewsNodeAccessFilterHooks::class)->queryViewsNodeAccessFilterEditableAlter($query);
 }
 
 /**
@@ -70,24 +61,7 @@ function views_node_access_filter_module_implements_alter(&$implementations, $ho
 /**
  * Implements hook_help().
  */
+#[LegacyHook]
 function views_node_access_filter_help($route_name, RouteMatchInterface $route_match) {
-  switch ($route_name) {
-
-    case 'help.page.views_node_access_filter':
-      $output = '';
-      $output .= '<h3>' . t('About') . '</h3>';
-      $output .= '<p>' . t('This module provides a Views filter to only show the nodes that the current user is allowed to edit. This is typically useful as an UX improvement for editors on the /admin/content page.') . '</p>';
-      $output .= '<p>' . t('Note: because of the way core deals with node access (for performance reasons), this module may not take into account some node access modules which change the update access without updating the node access registry.') . '</p>';
-
-      // Add a link to the Drupal.org project.
-      $output .= '<p>';
-      $output .= t('Visit the <a href=":project_link">Views Node Access Filter project pages</a> on Drupal.org for more information.', [
-        ':project_link' => 'https://www.drupal.org/project/views_node_access_filter',
-      ]);
-      $output .= '</p>';
-
-      return $output;
-
-    default:
-  }
+  return \Drupal::service(ViewsNodeAccessFilterHooks::class)->help($route_name, $route_match);
 }
diff --git a/views_node_access_filter.services.yml b/views_node_access_filter.services.yml
new file mode 100644
index 0000000..9677dab
--- /dev/null
+++ b/views_node_access_filter.services.yml
@@ -0,0 +1,9 @@
+
+services:
+  Drupal\views_node_access_filter\Hook\ViewsNodeAccessFilterAccessRecordsHooks:
+    class: Drupal\views_node_access_filter\Hook\ViewsNodeAccessFilterAccessRecordsHooks
+    autowire: true
+
+  Drupal\views_node_access_filter\Hook\ViewsNodeAccessFilterHooks:
+    class: Drupal\views_node_access_filter\Hook\ViewsNodeAccessFilterHooks
+    autowire: true
