diff --git a/registration.tokens.inc b/registration.tokens.inc
new file mode 100644
index 0000000000000000000000000000000000000000..8c6940b6e25c2ebd34852d0b6b66b27e1e063125
--- /dev/null
+++ b/registration.tokens.inc
@@ -0,0 +1,221 @@
+<?php
+
+/**
+ * @file
+ * Builds placeholder replacement tokens for registration-related data.
+ */
+
+use Drupal\Core\Datetime\Entity\DateFormat;
+use Drupal\Core\Language\LanguageInterface;
+use Drupal\Core\Render\BubbleableMetadata;
+use Drupal\user\Entity\User;
+
+/**
+ * Implements hook_token_info().
+ */
+function registration_token_info() {
+  $types['registration'] = [
+    'name' => t('Registration'),
+    'description' => t('Tokens related to registrations.'),
+    'needs-data' => 'registration',
+  ];
+  $registration['id'] = [
+    'name' => t('ID'),
+    'description' => t('The unique identifier for the registration.'),
+  ];
+  $registration['count'] = [
+    'name' => t('Spaces'),
+    'description' => t('The number of spaces reserved by the registration.'),
+  ];
+  $registration['label'] = [
+    'name' => t('Label'),
+    'description' => t('The registration label.'),
+  ];
+  $registration['mail'] = [
+    'name' => t('Email'),
+    'description' => t('The email address for the registration.'),
+  ];
+  $registration['state'] = [
+    'name' => t('Status'),
+    'description' => t('The registration state.'),
+  ];
+  $registration['type'] = [
+    'name' => t('Type'),
+    'description' => t('The registration type ID.'),
+  ];
+  $registration['type-name'] = [
+    'name' => t('Type name'),
+    'description' => t('The registration type name.'),
+  ];
+
+  // Chained tokens for registrations.
+  $registration['author'] = [
+    'name' => t('Author'),
+    'type' => 'user',
+  ];
+  $node['created'] = [
+    'name' => t("Date created"),
+    'type' => 'date',
+  ];
+  $node['changed'] = [
+    'name' => t("Date changed"),
+    'description' => t("The date the registration was most recently updated."),
+    'type' => 'date',
+  ];
+  $node['completed'] = [
+    'name' => t("Date completed"),
+    'description' => t("The date the registration was completed."),
+    'type' => 'date',
+  ];
+  $registration['entity'] = [
+    'name' => t('Host entity'),
+    'description' => t('The host entity for the registration.'),
+  ];
+  $registration['user'] = [
+    'name' => t('User'),
+    'type' => 'user',
+  ];
+
+  return [
+    'types' => $types,
+    'tokens' => ['registration' => $registration],
+  ];
+}
+
+/**
+ * Implements hook_tokens().
+ */
+function registration_tokens($type, $tokens, array $data, array $options, BubbleableMetadata $bubbleable_metadata) {
+
+  $token_service = \Drupal::token();
+
+  if (isset($options['langcode'])) {
+    $url_options['language'] = \Drupal::languageManager()->getLanguage($options['langcode']);
+    $langcode = $options['langcode'];
+  }
+  else {
+    $langcode = LanguageInterface::LANGCODE_DEFAULT;
+  }
+
+  $replacements = [];
+
+  // The registration tokens.
+  if ($type == 'registration' && !empty($data['registration'])) {
+    /** @var \Drupal\registration\Entity\RegistrationInterface $registration */
+    $registration = $data['registration'];
+
+    // Simple tokens.
+    foreach ($tokens as $name => $original) {
+      switch ($name) {
+        case 'id':
+          $replacements[$original] = $registration->id();
+          break;
+
+        case 'count':
+          $replacements[$original] = $registration->getSpacesReserved();
+          break;
+
+        case 'label':
+          $replacements[$original] = $registration->label();
+          break;
+
+        case 'mail':
+          $replacements[$original] = $registration->getEmail();
+          break;
+
+        case 'state':
+          $replacements[$original] = $registration->getState()?->label();
+          break;
+
+        case 'type':
+          $replacements[$original] = $registration->getType()->id();
+          break;
+
+        case 'type-name':
+          $replacements[$original] = $registration->getType()->label();
+          break;
+
+        // Default values for the chained tokens handled below.
+        case 'author':
+          $account = $registration->getAuthor() ? $registration->getAuthor() : User::load(0);
+          $bubbleable_metadata->addCacheableDependency($account);
+          $replacements[$original] = $account->label();
+          break;
+
+        case 'created':
+          $date_format = DateFormat::load('medium');
+          $bubbleable_metadata->addCacheableDependency($date_format);
+          $replacements[$original] = \Drupal::service('date.formatter')->format($registration->getCreatedTime(), 'medium', '', NULL, $langcode);
+          break;
+
+        case 'changed':
+          $date_format = DateFormat::load('medium');
+          $bubbleable_metadata->addCacheableDependency($date_format);
+          $replacements[$original] = \Drupal::service('date.formatter')->format($registration->getChangedTime(), 'medium', '', NULL, $langcode);
+          break;
+
+        case 'completed':
+          if ($registration->isComplete()) {
+            $date_format = DateFormat::load('medium');
+            $bubbleable_metadata->addCacheableDependency($date_format);
+            $replacements[$original] = \Drupal::service('date.formatter')->format($registration->getCompletedTime(), 'medium', '', NULL, $langcode);
+          }
+          break;
+
+        case 'entity':
+          if ($entity = $registration->getHostEntity()?->getEntity()) {
+            $bubbleable_metadata->addCacheableDependency($entity);
+            $replacements[$original] = $entity->label();
+          }
+          break;
+
+        case 'user':
+          $account = $registration->getUser() ? $registration->getUser() : User::load(0);
+          $bubbleable_metadata->addCacheableDependency($account);
+          $replacements[$original] = $account->label();
+          break;
+      }
+    }
+
+    // Author tokens at [registration:author:*].
+    if ($author_tokens = $token_service->findWithPrefix($tokens, 'author')) {
+      $data = ['user' => $registration->getAuthor()];
+      $replacements += $token_service->generate('user', $author_tokens, $data, $options, $bubbleable_metadata);
+    }
+
+    // Date created tokens at [registration:created:*].
+    if ($created_tokens = $token_service->findWithPrefix($tokens, 'created')) {
+      $data = ['date' => $registration->getCreatedTime()];
+      $replacements += $token_service->generate('date', $created_tokens, $data, $options, $bubbleable_metadata);
+    }
+
+    // Date changed tokens at [registration:changed:*].
+    if ($changed_tokens = $token_service->findWithPrefix($tokens, 'changed')) {
+      $data = ['date' => $registration->getChangedTime()];
+      $replacements += $token_service->generate('date', $changed_tokens, $data, $options, $bubbleable_metadata);
+    }
+
+    // Date completed tokens at [registration:completed:*].
+    if ($completed_tokens = $token_service->findWithPrefix($tokens, 'completed')) {
+      $data = ['date' => $registration->getCompletedTime()];
+      $replacements += $token_service->generate('date', $completed_tokens, $data, $options, $bubbleable_metadata);
+    }
+
+    // Host entity tokens at [registration:entity:*].
+    if ($entity_tokens = $token_service->findWithPrefix($tokens, 'entity')) {
+      if ($entity = $registration->getHostEntity()?->getEntity()) {
+        $entity_type_id = $entity->getEntityTypeId();
+        $data = [$entity_type_id => $entity];
+        $replacements += $token_service->generate($entity_type_id, $entity_tokens, $data, $options, $bubbleable_metadata);
+      }
+    }
+
+    // User tokens at [registration:user:*].
+    if ($user_tokens = $token_service->findWithPrefix($tokens, 'user')) {
+      $data = ['user' => $registration->getUser()];
+      $replacements += $token_service->generate('user', $user_tokens, $data, $options, $bubbleable_metadata);
+    }
+  }
+
+  return $replacements;
+}
diff --git a/tests/src/Kernel/RegistrationTokensTest.php b/tests/src/Kernel/RegistrationTokensTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..9c7caba7032086862578be881da97d9f9ae9303d
--- /dev/null
+++ b/tests/src/Kernel/RegistrationTokensTest.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace Drupal\Tests\registration\Kernel;
+
+use Drupal\Core\Utility\Token;
+use Drupal\Tests\registration\Traits\NodeCreationTrait;
+use Drupal\Tests\registration\Traits\RegistrationCreationTrait;
+
+/**
+ * Tests registration tokens.
+ *
+ * @group registration
+ */
+class RegistrationTokensTest extends RegistrationKernelTestBase {
+
+  use NodeCreationTrait;
+  use RegistrationCreationTrait;
+
+  /**
+   * The token service.
+   */
+  protected Token $tokenService;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->tokenService = $this->container->get('token');
+  }
+
+  /**
+   * Tests token generation and chaining.
+   */
+  public function testRegistrationTokens() {
+    $node = $this->createAndSaveNode();
+    $registration = $this->createRegistration($node);
+    $registration->set('anon_mail', 'test@example.org');
+    $registration->save();
+
+    // Simple tokens.
+    $test_data = [
+      '[registration:id]' => '1',
+      '[registration:count]' => '1',
+      '[registration:label]' => 'Registration #1 for My event',
+      '[registration:mail]' => 'test@example.org',
+      '[registration:state]' => 'Pending',
+      '[registration:type]' => 'conference',
+      '[registration:type-name]' => 'Conference',
+    ];
+
+    $token_data = [
+      'registration' => $registration,
+    ];
+
+    foreach ($test_data as $token => $expected_value) {
+      $token_replaced = $this->tokenService->replace($token, $token_data);
+      $this->assertEquals($token_replaced, $expected_value);
+    }
+
+    // Chained host entity tokens.
+    $test_data = [
+      '[registration:entity]' => 'My event',
+      '[registration:entity:nid]' => '1',
+      '[registration:entity:title]' => 'My event',
+      '[registration:entity:type]' => 'event',
+      '[registration:entity:type-name]' => 'Event',
+    ];
+
+    foreach ($test_data as $token => $expected_value) {
+      $token_replaced = $this->tokenService->replace($token, $token_data);
+      $this->assertEquals($token_replaced, $expected_value);
+    }
+  }
+
+}