diff --git a/captcha.install b/captcha.install
index c68361a..099aa03 100755
--- a/captcha.install
+++ b/captcha.install
@@ -5,6 +5,8 @@
  * Install, update and uninstall functions for the CAPTCHA module.
  */
 
+use Drupal\Component\Utility\DeprecationHelper;
+use Drupal\Core\Extension\Requirement\RequirementSeverity;
 use Drupal\captcha\Constants\CaptchaConstants;
 
 /**
@@ -101,7 +103,7 @@ function captcha_requirements($phase) {
         'Already 1 blocked form submission',
         'Already @count blocked form submissions'
       ),
-      'severity' => REQUIREMENT_INFO,
+      'severity' => DeprecationHelper::backwardsCompatibleCall(\Drupal::VERSION, '11.2.0', fn() => RequirementSeverity::Info, fn() => REQUIREMENT_INFO),
     ];
   }
   return $requirements;
@@ -116,7 +118,7 @@ function captcha_install() {
     $form_ids = [];
     $label = [];
     // Add form_ids of all currently known node types too.
-    foreach (node_type_get_names() as $type => $name) {
+    foreach (DeprecationHelper::backwardsCompatibleCall(\Drupal::VERSION, '11.3.0', fn() => \Drupal::service('entity_type.bundle.info')->getBundleLabels('node'), fn() => node_type_get_names()) as $type => $name) {
       $form_ids[] = 'node_' . $type . '_form';
       $label[] = 'node_' . $type . '_form';
     }
@@ -225,7 +227,7 @@ function captcha_update_8905() {
   $captchaConfig = \Drupal::configFactory()->getEditable('captcha.settings');
   if ($captchaConfig->get('title') === NULL) {
     // Set the title in config, if it wasn't existing before:
-    $captchaConfig->set('title', 'CAPTCHA')->save(TRUE);
+    DeprecationHelper::backwardsCompatibleCall(\Drupal::VERSION, '11.4.0', fn() => $captchaConfig->set('title', 'CAPTCHA')->save(), fn() => $captchaConfig->set('title', 'CAPTCHA')->save(TRUE));
   }
 }
 
diff --git a/captcha.module b/captcha.module
index 0b84cf9..31771a3 100755
--- a/captcha.module
+++ b/captcha.module
@@ -9,13 +9,10 @@
  * to solve.
  */
 
-use Drupal\Core\Database\Database;
-use Drupal\Core\Form\BaseFormIdInterface;
+use Drupal\Core\Hook\Attribute\LegacyHook;
+use Drupal\captcha\Hook\CaptchaHooks;
 use Drupal\Core\Form\FormStateInterface;
-use Drupal\Core\Link;
 use Drupal\Core\Routing\RouteMatchInterface;
-use Drupal\Core\Site\Settings;
-use Drupal\Core\Url;
 use Drupal\captcha\Constants\CaptchaConstants;
 use Drupal\captcha\Element\Captcha;
 use Drupal\captcha\Entity\CaptchaPoint;
@@ -25,28 +22,9 @@ use Symfony\Component\HttpFoundation\Response;
 /**
  * Implements hook_help().
  */
+#[LegacyHook]
 function captcha_help($route_name, RouteMatchInterface $route_match) {
-  switch ($route_name) {
-    case 'help.page.captcha':
-      $output = '<h3>' . t('About') . '</h3>';
-      $output .= '<p>' . t('"CAPTCHA" is an acronym for "Completely Automated Public Turing test to tell Computers and Humans Apart". It is typically a challenge-response test to determine whether the user is human. The CAPTCHA module is a tool to fight automated submission by malicious users (spamming) of for example comments forms, user registration forms, guestbook forms, etc. You can extend the desired forms with an additional challenge, which should be easy for a human to solve correctly, but hard enough to keep automated scripts and spam bots out.') . '</p>';
-      $output .= '<p>' . t('Note that the CAPTCHA module interacts with page caching (see <a href=":performancesettings">performance settings</a>). Because the challenge should be unique for each generated form, the caching of the page it appears on is prevented. Make sure that these forms do not appear on too many pages or you will lose much caching efficiency. For example, if you put a CAPTCHA on the user login block, which typically appears on each page for anonymous visitors, caching will practically be disabled. The comment submission forms are another example. In this case you should set the <em>Location of comment submission form</em> to <em>Display on separate page</em> in the comment settings of the relevant <a href=":contenttypes">content types</a> for better caching efficiency.', [
-        ':performancesettings' => Url::fromRoute('system.performance_settings')->toString(),
-        ':contenttypes' => \Drupal::moduleHandler()->moduleExists('node') ? Url::fromRoute('entity.node_type.collection')->toString() : '#',
-      ]) . '</p>';
-      $output .= '<p>' . t('CAPTCHA is a trademark of Carnegie Mellon University.') . '</p>';
-      return ['#markup' => $output];
-
-    case 'captcha_settings':
-      $output = '<p>' . t('A CAPTCHA can be added to virtually any Drupal form, through adding an affiliated <em>CAPTCHA Point</em>. Some default <em>CAPTCHA Points</em> are already provided in the <em>CAPTCHA Point</em> section, but arbitrary forms can be easily added and managed.') . '</p>';
-      $output .= '<p>' . t('Users with the <em>Skip CAPTCHA</em> <a href=":perm">permission</a> won\'t be offered a challenge. Be sure to grant this permission to the trusted users (e.g. site administrators). If you want to test a protected form, be sure to do it as a user without the <em>Skip CAPTCHA</em> permission (e.g. as anonymous user).', [
-        ':perm' => Url::fromRoute('user.admin_permissions')->toString(),
-      ]) . '</p>';
-      $output .= '<p><b>' . t('Note that the CAPTCHA module disables <a href=":performancesettings">page caching</a> of pages that include a CAPTCHA challenge.', [
-        ':performancesettings' => Url::fromRoute('system.performance_settings')->toString(),
-      ]) . '</b></p>';
-      return ['#markup' => $output];
-  }
+  return \Drupal::service(CaptchaHooks::class)->help($route_name, $route_match);
 }
 
 /**
@@ -65,24 +43,17 @@ function captcha_point_load($id) {
 /**
  * Implements hook_theme().
  */
+#[LegacyHook]
 function captcha_theme() {
-  $path = \Drupal::service('extension.list.module')->getPath('captcha');
-  return [
-    'captcha' => [
-      'render element' => 'element',
-      'template' => 'captcha',
-      'path' => $path . '/templates',
-    ],
-  ];
+  return \Drupal::service(CaptchaHooks::class)->theme();
 }
 
 /**
  * Implements hook_theme_suggestions_HOOK().
  */
+#[LegacyHook]
 function captcha_theme_suggestions_captcha(array $variables) {
-  $suggestions = [];
-  $suggestions[] = 'captcha__' . strtolower($variables['element']['#captcha_type_challenge']);
-  return $suggestions;
+  return \Drupal::service(CaptchaHooks::class)->themeSuggestionsCaptcha($variables);
 }
 
 /**
@@ -90,15 +61,9 @@ function captcha_theme_suggestions_captcha(array $variables) {
  *
  * Remove old entries from captcha_sessions table.
  */
+#[LegacyHook]
 function captcha_cron() {
-  // Get request time.
-  $request_time = \Drupal::time()->getRequestTime();
-
-  // Remove challenges older than PHP's session.gc_maxlifetime value.
-  $connection = Database::getConnection();
-  $connection->delete('captcha_sessions')
-    ->condition('timestamp', $request_time - ini_get('session.gc_maxlifetime'), '<')
-    ->execute();
+  \Drupal::service(CaptchaHooks::class)->cron();
 }
 
 /**
@@ -129,182 +94,9 @@ function template_preprocess_captcha(&$variables, $hook, $info) {
  * if needed and adds. CAPTCHA administration links for site
  * administrators if this option is enabled.
  */
+#[LegacyHook]
 function captcha_form_alter(array &$form, FormStateInterface $form_state, $form_id) {
-  $account = \Drupal::currentUser();
-  $config = \Drupal::config('captcha.settings');
-  $captchaService = \Drupal::service('captcha.helper');
-  $adminContext = \Drupal::service('router.admin_context');
-  $captcha_point = NULL;
-
-  \Drupal::moduleHandler()->loadInclude('captcha', 'inc');
-
-  // If the user does not have the "skip CAPTCHA" permission render
-  // it "normally":
-  if (!$account->hasPermission('skip CAPTCHA')) {
-    $query = \Drupal::entityQuery('captcha_point');
-    $query->condition('label', $form_id);
-    $entity_ids = $query->execute();
-
-    // If empty, see if it is a form provided by default config.
-    if (empty($entity_ids)) {
-      $query = \Drupal::entityQuery('captcha_point');
-      $query->condition('formId', $form_id);
-      $entity_ids = $query->execute();
-    }
-
-    if (!empty($entity_ids) && is_array($entity_ids)) {
-      $captcha_point_id = array_pop($entity_ids);
-      /** @var \Drupal\captcha\CaptchaPointInterface $captcha_point */
-      $captcha_point = \Drupal::entityTypeManager()
-        ->getStorage('captcha_point')
-        ->load($captcha_point_id);
-    }
-
-    // If there is no CaptchaPoint for the form_id, try to use the base_form_id.
-    if (empty($captcha_point) || !$captcha_point->status()) {
-      $form_object = $form_state->getFormObject();
-      if ($form_object instanceof BaseFormIdInterface) {
-        $base_form_id = $form_object->getBaseFormId();
-        if (!empty($base_form_id) && $base_form_id != $form_id) {
-          /** @var \Drupal\captcha\CaptchaPointInterface $captcha_point */
-          $captcha_point = \Drupal::entityTypeManager()
-            ->getStorage('captcha_point')
-            ->load($base_form_id);
-        }
-      }
-    }
-
-    // If there is no CaptchaPoint at all, but we want to add a captcha globally
-    // for every form do that here:
-    if (empty($captcha_point) && !empty($config->get('enable_globally'))) {
-      // Only add this captcha on non admin routes or if
-      // "enable_globally_on_admin_routes" is enabled, also on admin routes:
-      if (
-      !$adminContext->isAdminRoute()
-      ||
-      ($adminContext->isAdminRoute() && !empty($config->get('enable_globally_on_admin_routes')))
-      ) {
-        // Create captcha point without saving.
-        /** @var \Drupal\captcha\CaptchaPointInterface $captcha_point */
-        $captcha_point = new CaptchaPoint([
-          'formId' => $form_id,
-          'captchaType' => $config->get('default_challenge'),
-        ], 'captcha_point');
-        $captcha_point->enable();
-      }
-    }
-    if (!empty($captcha_point) && $captcha_point->status()) {
-      // Checking if user's ip is whitelisted.
-      if (captcha_whitelist_ip_whitelisted()) {
-        // If form is setup to have captcha, but user's ip is whitelisted, then
-        // we still have to disable form caching to prevent showing cached form
-        // for users with not whitelisted ips.
-        $form['#cache'] = ['max-age' => 0];
-        \Drupal::service('page_cache_kill_switch')->trigger();
-      }
-      else {
-        // Build CAPTCHA form element.
-        $captcha_element = [
-          '#type' => 'captcha',
-          '#captcha_type' => $captcha_point->getCaptchaType(),
-        ];
-
-        // Get placement in form and insert in form.
-        $captcha_placement = _captcha_get_captcha_placement($form_id, $form);
-        $captchaService->insertCaptchaElement($form, $captcha_placement, $captcha_element);
-      }
-    }
-  }
-  // If the user has the "skip CAPTCHA" permission, check, if it should be
-  // rendered in "administration_mode", which adds administrative informations
-  // to the captcha:
-  elseif (!empty($config->get('administration_mode')) && $account->hasPermission('administer CAPTCHA settings')) {
-    // Add informations if it isn't an admin route OR if
-    // "administration_mode_on_admin_routes" is set and the current route is
-    // and admin route:
-    if (
-      !$adminContext->isAdminRoute()
-      ||
-      ($config->get('administration_mode_on_admin_routes') && $adminContext->isAdminRoute())
-      ) {
-      // Add CAPTCHA administration tools.
-      /** @var \Drupal\captcha\CaptchaPointInterface $captcha_point */
-      $captcha_point = CaptchaPoint::load($form_id);
-
-      // Add admin information to the captcha:
-      $captcha_element = [
-        '#type' => 'details',
-        '#title' => t('CAPTCHA'),
-        '#attributes' => [
-          'class' => ['captcha-admin-links'],
-        ],
-        '#open' => TRUE,
-      ];
-
-      if ($captcha_point !== NULL && $captcha_point->getCaptchaType()) {
-        $captcha_element['#title'] = $captcha_point->status() ? t('CAPTCHA: challenge "@type" enabled', ['@type' => $captcha_point->getCaptchaType()]) : t('CAPTCHA: challenge "@type" disabled', ['@type' => $captcha_point->getCaptchaType()]);
-        $captcha_point->status() ? $captchaElementDescription = t('Users without the "skip CAPTCHA" permission will see a CAPTCHA here.') : $captchaElementDescription = t("CAPTCHA disabled, Untrusted users won't see the captcha.");
-        $captcha_element['#description'] = $captchaElementDescription;
-        $captcha_element['settings_link'] = [
-          '#markup' => Link::fromTextAndUrl(
-            t('general CAPTCHA settings'),
-            Url::fromRoute('captcha_settings')
-          )->toString(),
-        ];
-        $captcha_element['challenge'] = [
-          '#type' => 'item',
-          '#title' => t('Enabled challenge'),
-          '#markup' => $captcha_point->toLink(t('change'), 'edit-form', [
-            'query' => \Drupal::destination()
-              ->getAsArray(),
-          ])->toString(),
-        ];
-      }
-      else {
-        $captcha_element['#title'] = t('CAPTCHA: no challenge enabled');
-        $captcha_element['add_captcha'] = [
-          '#markup' => Link::fromTextAndUrl(
-        t('Place a CAPTCHA here for untrusted users.'),
-        Url::fromRoute('captcha_point.add', [], [
-          'query' => \Drupal::destination()
-            ->getAsArray() + ['form_id' => $form_id],
-        ])
-          )->toString(),
-        ];
-      }
-
-      // Get placement in form and insert in form.
-      $captcha_placement = _captcha_get_captcha_placement($form_id, $form);
-      $captchaService->insertCaptchaElement($form, $captcha_placement, $captcha_element);
-    }
-    // If the user has the "skip Captcha" permission, but the
-    // administration_mode is not enabled, simply do nothing and therefore
-    // "skip" the CAPTCHA.
-  }
-
-  // Add a warning about caching on the Performance settings page.
-  if ($form_id == 'system_performance_settings') {
-    $form['caching']['captcha'] = [
-      '#type' => 'item',
-      '#title' => t('CAPTCHA'),
-      '#markup' => '<div class="messages messages--warning">' . t('Most CAPTCHA methods will disable the caching of pages that contain a CAPTCHA element. Check the different implementations to know more about how it affects caching.') . '</div>',
-    ];
-  }
-
-  // Disable captcha if override is set.
-  if (Settings::get('disable_captcha', FALSE) === TRUE) {
-    $override_notice = [
-      '#type' => 'html_tag',
-      '#tag' => 'strong',
-      '#value' => t('Captcha is currently disabled via settings.php.'),
-    ];
-    if (isset($form['elements']['captcha'])) {
-      $form['elements']['captcha'] = $override_notice;
-    }
-    if (isset($form['captcha'])) {
-      $form['captcha'] = $override_notice;
-    }
-  }
+  \Drupal::service(CaptchaHooks::class)->formAlter($form, $form_state, $form_id);
 }
 
 /**
diff --git a/captcha.services.yml b/captcha.services.yml
index 2eeae7b..21e631f 100644
--- a/captcha.services.yml
+++ b/captcha.services.yml
@@ -8,3 +8,7 @@ services:
   captcha.helper:
     class: Drupal\captcha\Service\CaptchaService
     arguments: ['@module_handler']
+
+  Drupal\captcha\Hook\CaptchaHooks:
+    class: Drupal\captcha\Hook\CaptchaHooks
+    autowire: true
diff --git a/modules/image_captcha/image_captcha.admin.inc b/modules/image_captcha/image_captcha.admin.inc
index a0de45e..ff91cf3 100644
--- a/modules/image_captcha/image_captcha.admin.inc
+++ b/modules/image_captcha/image_captcha.admin.inc
@@ -63,8 +63,6 @@ function image_captcha_font_preview($font_token) {
   $response->headers->set('Content-Type', 'image/png');
   // Dump image data to client.
   imagepng($image);
-  // Release image memory.
-  imagedestroy($image);
   unset($image);
 
   // Close connection.
diff --git a/modules/image_captcha/image_captcha.install b/modules/image_captcha/image_captcha.install
index 462c93d..45ac22e 100644
--- a/modules/image_captcha/image_captcha.install
+++ b/modules/image_captcha/image_captcha.install
@@ -1,5 +1,12 @@
 <?php
 
+/**
+ * @file
+ */
+
+use Drupal\Component\Utility\DeprecationHelper;
+use Drupal\Core\Extension\Requirement\RequirementSeverity;
+
 /**
  * @file
  * Installation/uninstallation related functions for the image_captcha module.
@@ -32,7 +39,7 @@ function image_captcha_requirements($phase) {
           ->translate('The Image CAPTCHA module can not be installed because your PHP setup does not provide the <a href="!gddoc">GD library</a>, which is required to generate images.',
               ['!gddoc' => 'http://www.php.net/manual/en/book.image.php']
         ),
-        'severity' => REQUIREMENT_ERROR,
+        'severity' => DeprecationHelper::backwardsCompatibleCall(\Drupal::VERSION, '11.2.0', fn() => RequirementSeverity::Error, fn() => REQUIREMENT_ERROR),
       ];
     }
   }
@@ -45,10 +52,13 @@ function image_captcha_requirements($phase) {
 function image_captcha_install() {
   $config = \Drupal::configFactory()->getEditable('image_captcha.settings');
   $module_path = \Drupal::service('extension.list.module')->getPath('image_captcha');
-  $config->set('image_captcha_fonts', [
+  DeprecationHelper::backwardsCompatibleCall(\Drupal::VERSION, '11.4.0', fn() => $config->set('image_captcha_fonts', [
     hash('sha256', $module_path . '/fonts/Tesox/tesox.ttf'),
     hash('sha256', $module_path . '/fonts/Tuffy/Tuffy.ttf'),
-  ])->save(TRUE);
+  ])->save(), fn() => $config->set('image_captcha_fonts', [
+    hash('sha256', $module_path . '/fonts/Tesox/tesox.ttf'),
+    hash('sha256', $module_path . '/fonts/Tuffy/Tuffy.ttf'),
+  ])->save(TRUE));
 }
 
 /**
@@ -62,7 +72,7 @@ function image_captcha_update_8001() {
       $config->set('image_captcha_fonts.' . $index, hash('sha256', $font));
     }
   }
-  $config->save(TRUE);
+  DeprecationHelper::backwardsCompatibleCall(\Drupal::VERSION, '11.4.0', fn() => $config->save(), fn() => $config->save(TRUE));
 }
 
 /**
@@ -72,10 +82,13 @@ function image_captcha_update_9001(&$sandbox) {
   // Reset the font path from the correct module directory location:
   $config = \Drupal::configFactory()->getEditable('image_captcha.settings');
   $module_path = \Drupal::service('extension.list.module')->getPath('image_captcha');
-  $config->set('image_captcha_fonts', [
+  DeprecationHelper::backwardsCompatibleCall(\Drupal::VERSION, '11.4.0', fn() => $config->set('image_captcha_fonts', [
+    hash('sha256', $module_path . '/fonts/Tesox/tesox.ttf'),
+    hash('sha256', $module_path . '/fonts/Tuffy/Tuffy.ttf'),
+  ])->save(), fn() => $config->set('image_captcha_fonts', [
     hash('sha256', $module_path . '/fonts/Tesox/tesox.ttf'),
     hash('sha256', $module_path . '/fonts/Tuffy/Tuffy.ttf'),
-  ])->save(TRUE);
+  ])->save(TRUE));
 }
 
 /**
@@ -83,7 +96,7 @@ function image_captcha_update_9001(&$sandbox) {
  */
 function image_captcha_update_9002(&$sandbox) {
   $config = \Drupal::configFactory()->getEditable('image_captcha.settings');
-  $config->set('title', 'CAPTCHA')->save(TRUE);
+  DeprecationHelper::backwardsCompatibleCall(\Drupal::VERSION, '11.4.0', fn() => $config->set('title', 'CAPTCHA')->save(), fn() => $config->set('title', 'CAPTCHA')->save(TRUE));
 }
 
 /**
@@ -91,5 +104,5 @@ function image_captcha_update_9002(&$sandbox) {
  */
 function image_captcha_update_9003(&$sandbox) {
   $imageCaptchaConfig = \Drupal::configFactory()->getEditable('image_captcha.settings');
-  $imageCaptchaConfig->clear('title')->save(TRUE);
+  DeprecationHelper::backwardsCompatibleCall(\Drupal::VERSION, '11.4.0', fn() => $imageCaptchaConfig->clear('title')->save(), fn() => $imageCaptchaConfig->clear('title')->save(TRUE));
 }
diff --git a/modules/image_captcha/image_captcha.module b/modules/image_captcha/image_captcha.module
index edad417..3fe8750 100644
--- a/modules/image_captcha/image_captcha.module
+++ b/modules/image_captcha/image_captcha.module
@@ -5,22 +5,18 @@
  * Implements image CAPTCHA for use with the CAPTCHA module.
  */
 
+use Drupal\Core\Hook\Attribute\LegacyHook;
+use Drupal\image_captcha\Hook\ImageCaptchaHooks;
 use Drupal\Core\DrupalKernel;
 use Drupal\Core\Routing\RouteMatchInterface;
-use Drupal\Core\Url;
-use Drupal\captcha\Constants\CaptchaConstants;
 use Drupal\image_captcha\Constants\ImageCaptchaConstants;
-use Drupal\image_captcha\Service\ImageCaptchaRenderService;
 
 /**
  * Implements hook_help().
  */
+#[LegacyHook]
 function image_captcha_help($route_name, RouteMatchInterface $route_match) {
-  switch ($route_name) {
-    case 'image_captcha.settings':
-      $output = '<p>' . t('The image CAPTCHA is a popular challenge where a random textual code is obfuscated in an image. The image is generated on the fly for each request, which is rather CPU intensive for the server. Be careful with the size and computation related settings.') . '</p>';
-      return $output;
-  }
+  return \Drupal::service(ImageCaptchaHooks::class)->help($route_name, $route_match);
 }
 
 /**
@@ -247,121 +243,23 @@ function _image_captcha_image_size($code) {
 /**
  * Implements hook_captcha().
  */
+#[LegacyHook]
 function image_captcha_captcha($op, $captcha_type = '', $captcha_sid = NULL) {
-  $config = \Drupal::config('image_captcha.settings');
-
-  switch ($op) {
-    case 'list':
-      // Only offer the image CAPTCHA if it is possible to generate an image
-      // on this setup.
-      if (!(_image_captcha_check_setup() & ImageCaptchaConstants::IMAGE_CAPTCHA_ERROR_NO_GDLIB)) {
-        return ['Image'];
-      }
-      else {
-        return [];
-      }
-      break;
-
-    case 'generate':
-      if ($captcha_type == 'Image') {
-        // In maintenance mode, the image CAPTCHA does not work because
-        // the request for the image itself won't succeed (only ?q=user
-        // is permitted for unauthenticated users). We fall back to the
-        // Math CAPTCHA in that case.
-        if (\Drupal::state()->get('system.maintenance_mode')
-        && \Drupal::currentUser()->isAnonymous()
-        ) {
-          return captcha_captcha('generate', 'Math');
-        }
-        // Generate a CAPTCHA code.
-        $allowed_chars = _image_captcha_utf8_split($config->get('image_captcha_image_allowed_chars'));
-        $code_length = (int) $config->get('image_captcha_code_length');
-        $code = '';
-
-        for ($i = 0; $i < $code_length; $i++) {
-          $code .= $allowed_chars[array_rand($allowed_chars)];
-        }
-
-        // Build the result to return.
-        $result = [];
-
-        $result['solution'] = $code;
-        // Add CAPTCHA image wrapper (holds the refresh button + the image
-        // itself)
-        $result['form']['captcha_image_wrapper'] = [
-          '#type' => 'container',
-          '#attributes' => ['class' => ['captcha__image-wrapper']],
-        ];
-        // Generate image source URL (add timestamp to avoid problems with
-        // client side caching: subsequent images of the same CAPTCHA session
-        // have the same URL, but should display a different code).
-        [$width, $height] = _image_captcha_image_size($code);
-        $result['form']['captcha_image_wrapper']['captcha_image'] = [
-          '#theme' => 'image',
-          '#uri' => Url::fromRoute('image_captcha.generator', [
-            'session_id' => $captcha_sid,
-            'timestamp' => \Drupal::time()->getRequestTime(),
-          ])->toString(),
-          '#width' => $width,
-          '#height' => $height,
-          '#alt' => t('Image CAPTCHA'),
-          '#title' => t('Image CAPTCHA'),
-          '#weight' => -2,
-        ];
-
-        $result['form']['captcha_response'] = [
-          '#type' => 'textfield',
-          '#title' => t('What code is in the image?'),
-          '#description' => t('Enter the characters shown in the image.'),
-          '#weight' => 0,
-          '#required' => TRUE,
-          '#size' => 15,
-          '#attributes' => ['autocomplete' => 'off'],
-          '#cache' => ['max-age' => 0],
-        ];
-
-        // Handle the case insensitive validation option combined with
-        // ignoring spaces.
-        switch (\Drupal::config('captcha.settings')
-          ->get('default_validation')) {
-          case CaptchaConstants::CAPTCHA_DEFAULT_VALIDATION_CASE_SENSITIVE:
-            $result['captcha_validate'] = 'captcha_validate_ignore_spaces';
-            break;
-
-          case CaptchaConstants::CAPTCHA_DEFAULT_VALIDATION_CASE_INSENSITIVE:
-            $result['captcha_validate'] = 'captcha_validate_case_insensitive_ignore_spaces';
-            break;
-        }
-        \Drupal::service('page_cache_kill_switch')->trigger();
-
-        return $result;
-      }
-      break;
-  }
+  return \Drupal::service(ImageCaptchaHooks::class)->captcha($op, $captcha_type, $captcha_sid);
 }
 
 /**
  * Implements hook_theme().
  */
+#[LegacyHook]
 function image_captcha_theme() {
-  return [
-    'image_captcha_refresh' => [
-      'variables' => ['captcha_refresh_link' => NULL],
-    ],
-  ];
+  return \Drupal::service(ImageCaptchaHooks::class)->theme();
 }
 
 /**
  * Implements hook_element_info_alter().
  */
+#[LegacyHook]
 function image_captcha_element_info_alter(array &$info) {
-  if (!empty($info['captcha'])) {
-    // Register the process callback. Sadly we can't determine here safely yet,
-    // if the processed captcha type is an image_captcha. That has to be done
-    // inside the #process callback.
-    $info['captcha']['#process'][] = [
-      ImageCaptchaRenderService::class,
-      'imageCaptchaAfterBuildProcess',
-    ];
-  }
+  \Drupal::service(ImageCaptchaHooks::class)->elementInfoAlter($info);
 }
diff --git a/modules/image_captcha/image_captcha.services.yml b/modules/image_captcha/image_captcha.services.yml
index 7581fa0..c6b7c83 100644
--- a/modules/image_captcha/image_captcha.services.yml
+++ b/modules/image_captcha/image_captcha.services.yml
@@ -2,3 +2,7 @@ services:
   image_captcha.render_service:
     class: Drupal\image_captcha\Service\ImageCaptchaRenderService
     arguments: [ '@config.factory', '@database', '@file_system']
+
+  Drupal\image_captcha\Hook\ImageCaptchaHooks:
+    class: Drupal\image_captcha\Hook\ImageCaptchaHooks
+    autowire: true
diff --git a/modules/image_captcha/src/Controller/CaptchaFontPreviewController.php b/modules/image_captcha/src/Controller/CaptchaFontPreviewController.php
index 82b99db..82c900b 100644
--- a/modules/image_captcha/src/Controller/CaptchaFontPreviewController.php
+++ b/modules/image_captcha/src/Controller/CaptchaFontPreviewController.php
@@ -96,8 +96,6 @@ class CaptchaFontPreviewController implements ContainerInjectionInterface {
       }
       // Dump image data to client.
       imagepng($image);
-        // Release image memory.
-      imagedestroy($image);
       unset($image);
     }, 200, ['Content-Type' => 'image/png']);
   }
diff --git a/modules/image_captcha/src/Controller/CaptchaImageGeneratorController.php b/modules/image_captcha/src/Controller/CaptchaImageGeneratorController.php
index 26b962a..fe7c5aa 100644
--- a/modules/image_captcha/src/Controller/CaptchaImageGeneratorController.php
+++ b/modules/image_captcha/src/Controller/CaptchaImageGeneratorController.php
@@ -140,8 +140,6 @@ class CaptchaImageGeneratorController implements ContainerInjectionInterface {
         imagepng($image);
 
       }
-        // Release image memory.
-      imagedestroy($image);
       unset($image);
       return $this;
 
diff --git a/modules/image_captcha/src/Hook/ImageCaptchaHooks.php b/modules/image_captcha/src/Hook/ImageCaptchaHooks.php
new file mode 100644
index 0000000..1d96516
--- /dev/null
+++ b/modules/image_captcha/src/Hook/ImageCaptchaHooks.php
@@ -0,0 +1,158 @@
+<?php
+
+namespace Drupal\image_captcha\Hook;
+
+use Drupal\image_captcha\Service\ImageCaptchaRenderService;
+use Drupal\captcha\Constants\CaptchaConstants;
+use Drupal\Core\Url;
+use Drupal\image_captcha\Constants\ImageCaptchaConstants;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * Hook implementations for image_captcha.
+ */
+class ImageCaptchaHooks {
+  use StringTranslationTrait;
+
+  /**
+   * Implements hook_help().
+   */
+  #[Hook('help')]
+  public function help($route_name, RouteMatchInterface $route_match) {
+    switch ($route_name) {
+      case 'image_captcha.settings':
+        $output = '<p>' . $this->t('The image CAPTCHA is a popular challenge where a random textual code is obfuscated in an image. The image is generated on the fly for each request, which is rather CPU intensive for the server. Be careful with the size and computation related settings.') . '</p>';
+        return $output;
+    }
+  }
+
+  /**
+   * Implements hook_captcha().
+   */
+  #[Hook('captcha')]
+  public function captcha($op, $captcha_type = '', $captcha_sid = NULL) {
+    $config = \Drupal::config('image_captcha.settings');
+    switch ($op) {
+      case 'list':
+        // Only offer the image CAPTCHA if it is possible to generate an image
+        // on this setup.
+        if (!(_image_captcha_check_setup() & ImageCaptchaConstants::IMAGE_CAPTCHA_ERROR_NO_GDLIB)) {
+          return [
+            'Image',
+          ];
+        }
+        else {
+          return [];
+        }
+        break;
+
+      case 'generate':
+        if ($captcha_type == 'Image') {
+          // In maintenance mode, the image CAPTCHA does not work because
+          // the request for the image itself won't succeed (only ?q=user
+          // is permitted for unauthenticated users). We fall back to the
+          // Math CAPTCHA in that case.
+          if (\Drupal::state()->get('system.maintenance_mode') && \Drupal::currentUser()->isAnonymous()) {
+            return captcha_captcha('generate', 'Math');
+          }
+          // Generate a CAPTCHA code.
+          $allowed_chars = _image_captcha_utf8_split($config->get('image_captcha_image_allowed_chars'));
+          $code_length = (int) $config->get('image_captcha_code_length');
+          $code = '';
+          for ($i = 0; $i < $code_length; $i++) {
+            $code .= $allowed_chars[array_rand($allowed_chars)];
+          }
+          // Build the result to return.
+          $result = [];
+          $result['solution'] = $code;
+          // Add CAPTCHA image wrapper (holds the refresh button + the image
+          // itself)
+          $result['form']['captcha_image_wrapper'] = [
+            '#type' => 'container',
+            '#attributes' => [
+              'class' => [
+                'captcha__image-wrapper',
+              ],
+            ],
+          ];
+          // Generate image source URL (add timestamp to avoid problems with
+          // client side caching: subsequent images of the same CAPTCHA session
+          // have the same URL, but should display a different code).
+          [$width, $height] = _image_captcha_image_size($code);
+          $result['form']['captcha_image_wrapper']['captcha_image'] = [
+            '#theme' => 'image',
+            '#uri' => Url::fromRoute('image_captcha.generator', [
+              'session_id' => $captcha_sid,
+              'timestamp' => \Drupal::time()->getRequestTime(),
+            ])->toString(),
+            '#width' => $width,
+            '#height' => $height,
+            '#alt' => $this->t('Image CAPTCHA'),
+            '#title' => $this->t('Image CAPTCHA'),
+            '#weight' => -2,
+          ];
+          $result['form']['captcha_response'] = [
+            '#type' => 'textfield',
+            '#title' => $this->t('What code is in the image?'),
+            '#description' => $this->t('Enter the characters shown in the image.'),
+            '#weight' => 0,
+            '#required' => TRUE,
+            '#size' => 15,
+            '#attributes' => [
+              'autocomplete' => 'off',
+            ],
+            '#cache' => [
+              'max-age' => 0,
+            ],
+          ];
+          // Handle the case insensitive validation option combined with
+          // ignoring spaces.
+          switch (\Drupal::config('captcha.settings')->get('default_validation')) {
+            case CaptchaConstants::CAPTCHA_DEFAULT_VALIDATION_CASE_SENSITIVE:
+              $result['captcha_validate'] = 'captcha_validate_ignore_spaces';
+              break;
+
+            case CaptchaConstants::CAPTCHA_DEFAULT_VALIDATION_CASE_INSENSITIVE:
+              $result['captcha_validate'] = 'captcha_validate_case_insensitive_ignore_spaces';
+              break;
+          }
+          \Drupal::service('page_cache_kill_switch')->trigger();
+          return $result;
+        }
+        break;
+    }
+  }
+
+  /**
+   * Implements hook_theme().
+   */
+  #[Hook('theme')]
+  public static function theme() {
+    return [
+      'image_captcha_refresh' => [
+        'variables' => [
+          'captcha_refresh_link' => NULL,
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Implements hook_element_info_alter().
+   */
+  #[Hook('element_info_alter')]
+  public static function elementInfoAlter(array &$info) {
+    if (!empty($info['captcha'])) {
+      // Register the process callback. Sadly we can't determine here safely yet,
+      // if the processed captcha type is an image_captcha. That has to be done
+      // inside the #process callback.
+      $info['captcha']['#process'][] = [
+        ImageCaptchaRenderService::class,
+        'imageCaptchaAfterBuildProcess',
+      ];
+    }
+  }
+
+}
diff --git a/src/Hook/CaptchaHooks.php b/src/Hook/CaptchaHooks.php
new file mode 100644
index 0000000..1aa32a8
--- /dev/null
+++ b/src/Hook/CaptchaHooks.php
@@ -0,0 +1,254 @@
+<?php
+
+namespace Drupal\captcha\Hook;
+
+use Drupal\Core\Site\Settings;
+use Drupal\Core\Link;
+use Drupal\captcha\Entity\CaptchaPoint;
+use Drupal\Core\Form\BaseFormIdInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Database\Database;
+use Drupal\Core\Url;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * Hook implementations for captcha.
+ */
+class CaptchaHooks {
+  use StringTranslationTrait;
+
+  /**
+   * Implements hook_help().
+   */
+  #[Hook('help')]
+  public function help($route_name, RouteMatchInterface $route_match) {
+    switch ($route_name) {
+      case 'help.page.captcha':
+        $output = '<h3>' . $this->t('About') . '</h3>';
+        $output .= '<p>' . $this->t('"CAPTCHA" is an acronym for "Completely Automated Public Turing test to tell Computers and Humans Apart". It is typically a challenge-response test to determine whether the user is human. The CAPTCHA module is a tool to fight automated submission by malicious users (spamming) of for example comments forms, user registration forms, guestbook forms, etc. You can extend the desired forms with an additional challenge, which should be easy for a human to solve correctly, but hard enough to keep automated scripts and spam bots out.') . '</p>';
+        $output .= '<p>' . $this->t('Note that the CAPTCHA module interacts with page caching (see <a href=":performancesettings">performance settings</a>). Because the challenge should be unique for each generated form, the caching of the page it appears on is prevented. Make sure that these forms do not appear on too many pages or you will lose much caching efficiency. For example, if you put a CAPTCHA on the user login block, which typically appears on each page for anonymous visitors, caching will practically be disabled. The comment submission forms are another example. In this case you should set the <em>Location of comment submission form</em> to <em>Display on separate page</em> in the comment settings of the relevant <a href=":contenttypes">content types</a> for better caching efficiency.', [
+          ':performancesettings' => Url::fromRoute('system.performance_settings')->toString(),
+          ':contenttypes' => \Drupal::moduleHandler()->moduleExists('node') ? Url::fromRoute('entity.node_type.collection')->toString() : '#',
+        ]) . '</p>';
+        $output .= '<p>' . $this->t('CAPTCHA is a trademark of Carnegie Mellon University.') . '</p>';
+        return [
+          '#markup' => $output,
+        ];
+
+      case 'captcha_settings':
+        $output = '<p>' . $this->t('A CAPTCHA can be added to virtually any Drupal form, through adding an affiliated <em>CAPTCHA Point</em>. Some default <em>CAPTCHA Points</em> are already provided in the <em>CAPTCHA Point</em> section, but arbitrary forms can be easily added and managed.') . '</p>';
+        $output .= '<p>' . $this->t('Users with the <em>Skip CAPTCHA</em> <a href=":perm">permission</a> won\'t be offered a challenge. Be sure to grant this permission to the trusted users (e.g. site administrators). If you want to test a protected form, be sure to do it as a user without the <em>Skip CAPTCHA</em> permission (e.g. as anonymous user).', [
+          ':perm' => Url::fromRoute('user.admin_permissions')->toString(),
+        ]) . '</p>';
+        $output .= '<p><b>' . $this->t('Note that the CAPTCHA module disables <a href=":performancesettings">page caching</a> of pages that include a CAPTCHA challenge.', [
+          ':performancesettings' => Url::fromRoute('system.performance_settings')->toString(),
+        ]) . '</b></p>';
+        return [
+          '#markup' => $output,
+        ];
+    }
+  }
+
+  /**
+   * Implements hook_theme().
+   */
+  #[Hook('theme')]
+  public static function theme() {
+    $path = \Drupal::service('extension.list.module')->getPath('captcha');
+    return [
+      'captcha' => [
+        'render element' => 'element',
+        'template' => 'captcha',
+        'path' => $path . '/templates',
+      ],
+    ];
+  }
+
+  /**
+   * Implements hook_theme_suggestions_HOOK().
+   */
+  #[Hook('theme_suggestions_captcha')]
+  public static function themeSuggestionsCaptcha(array $variables) {
+    $suggestions = [];
+    $suggestions[] = 'captcha__' . strtolower($variables['element']['#captcha_type_challenge']);
+    return $suggestions;
+  }
+
+  /**
+   * Implements hook_cron().
+   *
+   * Remove old entries from captcha_sessions table.
+   */
+  #[Hook('cron')]
+  public static function cron() {
+    // Get request time.
+    $request_time = \Drupal::time()->getRequestTime();
+    // Remove challenges older than PHP's session.gc_maxlifetime value.
+    $connection = Database::getConnection();
+    $connection->delete('captcha_sessions')->condition('timestamp', $request_time - ini_get('session.gc_maxlifetime'), '<')->execute();
+  }
+
+  /**
+   * Implements hook_form_alter().
+   *
+   * This function adds a CAPTCHA to forms for untrusted users
+   * if needed and adds. CAPTCHA administration links for site
+   * administrators if this option is enabled.
+   */
+  #[Hook('form_alter')]
+  public function formAlter(array &$form, FormStateInterface $form_state, $form_id) {
+    $account = \Drupal::currentUser();
+    $config = \Drupal::config('captcha.settings');
+    $captchaService = \Drupal::service('captcha.helper');
+    $adminContext = \Drupal::service('router.admin_context');
+    $captcha_point = NULL;
+    \Drupal::moduleHandler()->loadInclude('captcha', 'inc');
+    // If the user does not have the "skip CAPTCHA" permission render
+    // it "normally":
+    if (!$account->hasPermission('skip CAPTCHA')) {
+      $query = \Drupal::entityQuery('captcha_point');
+      $query->condition('label', $form_id);
+      $entity_ids = $query->execute();
+      // If empty, see if it is a form provided by default config.
+      if (empty($entity_ids)) {
+        $query = \Drupal::entityQuery('captcha_point');
+        $query->condition('formId', $form_id);
+        $entity_ids = $query->execute();
+      }
+      if (!empty($entity_ids) && is_array($entity_ids)) {
+        $captcha_point_id = array_pop($entity_ids);
+        /** @var \Drupal\captcha\CaptchaPointInterface $captcha_point */
+        $captcha_point = \Drupal::entityTypeManager()->getStorage('captcha_point')->load($captcha_point_id);
+      }
+      // If there is no CaptchaPoint for the form_id, try to use the base_form_id.
+      if (empty($captcha_point) || !$captcha_point->status()) {
+        $form_object = $form_state->getFormObject();
+        if ($form_object instanceof BaseFormIdInterface) {
+          $base_form_id = $form_object->getBaseFormId();
+          if (!empty($base_form_id) && $base_form_id != $form_id) {
+            /** @var \Drupal\captcha\CaptchaPointInterface $captcha_point */
+            $captcha_point = \Drupal::entityTypeManager()->getStorage('captcha_point')->load($base_form_id);
+          }
+        }
+      }
+      // If there is no CaptchaPoint at all, but we want to add a captcha globally
+      // for every form do that here:
+      if (empty($captcha_point) && !empty($config->get('enable_globally'))) {
+        // Only add this captcha on non admin routes or if
+        // "enable_globally_on_admin_routes" is enabled, also on admin routes:
+        if (!$adminContext->isAdminRoute() || $adminContext->isAdminRoute() && !empty($config->get('enable_globally_on_admin_routes'))) {
+          // Create captcha point without saving.
+          /** @var \Drupal\captcha\CaptchaPointInterface $captcha_point */
+          $captcha_point = new CaptchaPoint([
+            'formId' => $form_id,
+            'captchaType' => $config->get('default_challenge'),
+          ], 'captcha_point');
+          $captcha_point->enable();
+        }
+      }
+      if (!empty($captcha_point) && $captcha_point->status()) {
+        // Checking if user's ip is whitelisted.
+        if (captcha_whitelist_ip_whitelisted()) {
+          // If form is setup to have captcha, but user's ip is whitelisted, then
+          // we still have to disable form caching to prevent showing cached form
+          // for users with not whitelisted ips.
+          $form['#cache'] = [
+            'max-age' => 0,
+          ];
+          \Drupal::service('page_cache_kill_switch')->trigger();
+        }
+        else {
+          // Build CAPTCHA form element.
+          $captcha_element = [
+            '#type' => 'captcha',
+            '#captcha_type' => $captcha_point->getCaptchaType(),
+          ];
+          // Get placement in form and insert in form.
+          $captcha_placement = _captcha_get_captcha_placement($form_id, $form);
+          $captchaService->insertCaptchaElement($form, $captcha_placement, $captcha_element);
+        }
+      }
+    }
+    elseif (!empty($config->get('administration_mode')) && $account->hasPermission('administer CAPTCHA settings')) {
+      // Add informations if it isn't an admin route OR if
+      // "administration_mode_on_admin_routes" is set and the current route is
+      // and admin route:
+      if (!$adminContext->isAdminRoute() || $config->get('administration_mode_on_admin_routes') && $adminContext->isAdminRoute()) {
+        // Add CAPTCHA administration tools.
+        /** @var \Drupal\captcha\CaptchaPointInterface $captcha_point */
+        $captcha_point = CaptchaPoint::load($form_id);
+        // Add admin information to the captcha:
+        $captcha_element = [
+          '#type' => 'details',
+          '#title' => $this->t('CAPTCHA'),
+          '#attributes' => [
+            'class' => [
+              'captcha-admin-links',
+            ],
+          ],
+          '#open' => TRUE,
+        ];
+        if ($captcha_point !== NULL && $captcha_point->getCaptchaType()) {
+          $captcha_element['#title'] = $captcha_point->status() ? $this->t('CAPTCHA: challenge "@type" enabled', [
+            '@type' => $captcha_point->getCaptchaType(),
+          ]) : $this->t('CAPTCHA: challenge "@type" disabled', [
+            '@type' => $captcha_point->getCaptchaType(),
+          ]);
+          $captcha_point->status() ? $captchaElementDescription = $this->t('Users without the "skip CAPTCHA" permission will see a CAPTCHA here.') : $captchaElementDescription = $this->t("CAPTCHA disabled, Untrusted users won't see the captcha.");
+          $captcha_element['#description'] = $captchaElementDescription;
+          $captcha_element['settings_link'] = [
+            '#markup' => Link::fromTextAndUrl($this->t('general CAPTCHA settings'), Url::fromRoute('captcha_settings'))->toString(),
+          ];
+          $captcha_element['challenge'] = [
+            '#type' => 'item',
+            '#title' => $this->t('Enabled challenge'),
+            '#markup' => $captcha_point->toLink($this->t('change'), 'edit-form', [
+              'query' => \Drupal::destination()->getAsArray(),
+            ])->toString(),
+          ];
+        }
+        else {
+          $captcha_element['#title'] = $this->t('CAPTCHA: no challenge enabled');
+          $captcha_element['add_captcha'] = [
+            '#markup' => Link::fromTextAndUrl($this->t('Place a CAPTCHA here for untrusted users.'), Url::fromRoute('captcha_point.add', [], [
+              'query' => \Drupal::destination()->getAsArray() + [
+                'form_id' => $form_id,
+              ],
+            ]))->toString(),
+          ];
+        }
+        // Get placement in form and insert in form.
+        $captcha_placement = _captcha_get_captcha_placement($form_id, $form);
+        $captchaService->insertCaptchaElement($form, $captcha_placement, $captcha_element);
+      }
+      // If the user has the "skip Captcha" permission, but the
+      // administration_mode is not enabled, simply do nothing and therefore
+      // "skip" the CAPTCHA.
+    }
+    // Add a warning about caching on the Performance settings page.
+    if ($form_id == 'system_performance_settings') {
+      $form['caching']['captcha'] = [
+        '#type' => 'item',
+        '#title' => $this->t('CAPTCHA'),
+        '#markup' => '<div class="messages messages--warning">' . $this->t('Most CAPTCHA methods will disable the caching of pages that contain a CAPTCHA element. Check the different implementations to know more about how it affects caching.') . '</div>',
+      ];
+    }
+    // Disable captcha if override is set.
+    if (Settings::get('disable_captcha', FALSE) === TRUE) {
+      $override_notice = [
+        '#type' => 'html_tag',
+        '#tag' => 'strong',
+        '#value' => $this->t('Captcha is currently disabled via settings.php.'),
+      ];
+      if (isset($form['elements']['captcha'])) {
+        $form['elements']['captcha'] = $override_notice;
+      }
+      if (isset($form['captcha'])) {
+        $form['captcha'] = $override_notice;
+      }
+    }
+  }
+
+}
diff --git a/tests/modules/captcha_test/captcha_test.module b/tests/modules/captcha_test/captcha_test.module
index 97d9768..077ca2d 100644
--- a/tests/modules/captcha_test/captcha_test.module
+++ b/tests/modules/captcha_test/captcha_test.module
@@ -1,5 +1,12 @@
 <?php
 
+/**
+ * @file
+ */
+
+use Drupal\Core\Hook\Attribute\LegacyHook;
+use Drupal\captcha_test\Hook\CaptchaTestHooks;
+
 /**
  * @file
  * Contains hook implementations for the Captcha Test module.
@@ -8,33 +15,9 @@
 /**
  * Implements hook_captcha().
  */
+#[LegacyHook]
 function captcha_test_captcha($op, $captcha_type = '') {
-  switch ($op) {
-    case 'list':
-      return [];
-
-    case 'generate':
-      if ($captcha_type === 'TestCacheable') {
-        // A cacheable Captcha type.
-        $result = [
-          'cacheable' => TRUE,
-          // Cacheable captcha types need to provide a custom validation
-          // callback that doesn't care about the solution, because a form can
-          // be shown containing a cached CSID that has since been deleted
-          // from the {captcha_sessions} table.
-          'captcha_validate' => 'captcha_test_captcha_captcha_validation',
-          'solution' => 'Test 123',
-          'form' => [],
-        ];
-        $result['form']['captcha_response'] = [
-          '#type' => 'textfield',
-          '#title' => t('Test one two three'),
-          '#required' => TRUE,
-        ];
-
-        return $result;
-      }
-  }
+  return \Drupal::service(CaptchaTestHooks::class)->captcha($op, $captcha_type);
 }
 
 /**
diff --git a/tests/modules/captcha_test/captcha_test.services.yml b/tests/modules/captcha_test/captcha_test.services.yml
new file mode 100644
index 0000000..56a4e95
--- /dev/null
+++ b/tests/modules/captcha_test/captcha_test.services.yml
@@ -0,0 +1,5 @@
+
+services:
+  Drupal\captcha_test\Hook\CaptchaTestHooks:
+    class: Drupal\captcha_test\Hook\CaptchaTestHooks
+    autowire: true
diff --git a/tests/modules/captcha_test/src/Hook/CaptchaTestHooks.php b/tests/modules/captcha_test/src/Hook/CaptchaTestHooks.php
new file mode 100644
index 0000000..227665e
--- /dev/null
+++ b/tests/modules/captcha_test/src/Hook/CaptchaTestHooks.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Drupal\captcha_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * Hook implementations for captcha_test.
+ */
+class CaptchaTestHooks {
+  use StringTranslationTrait;
+  /**
+   * @file
+   * Contains hook implementations for the Captcha Test module.
+   */
+
+  /**
+   * Implements hook_captcha().
+   */
+  #[Hook('captcha')]
+  public function captcha($op, $captcha_type = '') {
+    switch ($op) {
+      case 'list':
+        return [];
+
+      case 'generate':
+        if ($captcha_type === 'TestCacheable') {
+          // A cacheable Captcha type.
+          $result = [
+            'cacheable' => TRUE,
+                // Cacheable captcha types need to provide a custom validation
+                // callback that doesn't care about the solution, because a form can
+                // be shown containing a cached CSID that has since been deleted
+                // from the {captcha_sessions} table.
+            'captcha_validate' => 'captcha_test_captcha_captcha_validation',
+            'solution' => 'Test 123',
+            'form' => [],
+          ];
+          $result['form']['captcha_response'] = [
+            '#type' => 'textfield',
+            '#title' => $this->t('Test one two three'),
+            '#required' => TRUE,
+          ];
+          return $result;
+        }
+    }
+  }
+
+}
diff --git a/tests/src/Functional/CaptchaWebTestBase.php b/tests/src/Functional/CaptchaWebTestBase.php
index b65d533..b051825 100755
--- a/tests/src/Functional/CaptchaWebTestBase.php
+++ b/tests/src/Functional/CaptchaWebTestBase.php
@@ -2,6 +2,8 @@
 
 namespace Drupal\Tests\captcha\Functional;
 
+use Drupal\Component\Utility\DeprecationHelper;
+use Drupal\comment\FormLocation;
 use Drupal\Core\Session\AccountInterface;
 use Drupal\Tests\BrowserTestBase;
 use Drupal\comment\Plugin\Field\FieldType\CommentItemInterface;
@@ -114,7 +116,7 @@ abstract class CaptchaWebTestBase extends BrowserTestBase {
 
     // Put comments on page nodes on a separate page.
     $comment_field = FieldConfig::loadByName($entity_type, $entity_bundle, 'comment');
-    $comment_field->setSetting('form_location', CommentItemInterface::FORM_SEPARATE_PAGE);
+    $comment_field->setSetting('form_location', DeprecationHelper::backwardsCompatibleCall(\Drupal::VERSION, '11.4.0', fn() => FormLocation::SeparatePage, fn() => CommentItemInterface::FORM_SEPARATE_PAGE));
     $comment_field->save();
   }
 
