Skip to content

CharacterDataProvider plugins

How to add a module that contributes per-character data synced alongside each character refresh.

When to use this

  • Your module needs to fetch something from /profile/wow/character/{realm}/{name}/… and store it per-character.
  • You want that fetch to run automatically on every character sync, with no extra glue.
  • The data follows a catalog + junction shape (like mounts, pets, titles).

The CharacterDataProvider plugin with buildPageSections() is the only extension mechanism for the character canonical page.

The interface

Drupal\wow_character\CharacterDataProviderInterface

interface CharacterDataProviderInterface extends PluginInspectionInterface {
  public function getLabel(): string;
  public function syncCharacterData(Character $character, string $region): int;
  public function deleteCharacterData(Character $character): void;
  public function buildPageSections(Character $character): array;
}

Return an integer from syncCharacterData() — the row count the sync layer will log. deleteCharacterData() is invoked when the character entity is deleted (ToS compliance). buildPageSections() returns CharacterPageSection[] value objects for the character canonical page — the base class provides a no-op default.

The attribute

use Drupal\wow_character\Attribute\CharacterDataProvider;

#[CharacterDataProvider(
  id: 'my_thing',
  label: new TranslatableMarkup('My thing'),
)]
class MyThingData extends PluginBase implements
  CharacterDataProviderInterface,
  ContainerFactoryPluginInterface {
  // ...
}

Plugins live in modules/<module>/src/Plugin/CharacterDataProvider/.

Reference implementation

modules/wow_mount/src/Plugin/CharacterDataProvider/MountData.php is the canonical example. Shape of a typical implementation:

public function syncCharacterData(Character $character, string $region): int {
  $realmSlug = $character->get('realm_slug')->value;
  $name = strtolower($character->get('name')->value);

  $path = sprintf(
    '/profile/wow/character/%s/%s/collections/my-thing',
    $realmSlug,
    $name,
  );

  $data = $this->client->get(
    $region, $path, [], NULL, 'profile-' . $region,
  );
  if (!$data) {
    return 0;
  }

  $count = 0;
  foreach ($data['data']['items'] ?? [] as $entry) {
    $existing = $this->loadExisting($character->id(), $entry['id']);
    $entity = $existing ?? $this->storage->create([
      'character' => $character->id(),
      'my_thing'  => $this->resolveCatalogEntity($entry['id']),
    ]);
    // ... map fields from $entry ...
    $entity->set('last_fetched', \Drupal::time()->getRequestTime());
    $entity->save();
    $count++;
  }
  return $count;
}

public function deleteCharacterData(Character $character): void {
  $ids = $this->storage->getQuery()
    ->accessCheck(FALSE)
    ->condition('character', $character->id())
    ->execute();
  if ($ids) {
    $this->storage->delete($this->storage->loadMultiple($ids));
  }
}

Required dependencies

  • Inject wow.battlenet.client for API calls.
  • Inject entity_type.manager for your junction storage.
  • Inject datetime.time to stamp last_fetched.
  • Inject logger.channel.wow for warnings.

Don't use \Drupal:: inside plugin methods — the GlobalDrupalDependencyInjectionRule in our phpstan config will flag it.

Junction entity requirements

Your junction entity:

  • Must declare last_fetched as a base field (the TTL sweeper depends on it, the TtlSweepCoverageTest enforces it).
  • Must NOT declare the character reference — wow_character projects it in via hook_entity_base_field_info. Same for guild if your junction is also guild-ownable.
  • Must have wow_character listed in _wow_character_owned_entity_types() in modules/wow_character/wow_character.install.

See architecture/lifecycle.md for why this matters.

Testing expectations

Two kernel tests are table-stakes:

  1. Standalone entity test — create, save, round-trip your junction with mock data. See MountCollectionEntityTest.
  2. Full plugin test — mock BattleNetClient, swap it into the container, drive syncCharacterData() with canned API responses, assert rows land. See MountDataTest.

Tests must use #[RunTestsInSeparateProcesses] (Drupal 11.3+ requirement for kernel tests).

Building page sections

Override buildPageSections() to contribute render content to the character canonical page:

use Drupal\wow_character\CharacterPage\CharacterPageSection;
use Drupal\wow_character\CharacterPage\CharacterPageSlot;

public function buildPageSections(Character $character): array {
  $items = $this->loadItemsFor($character);
  if (empty($items)) {
    return [];
  }

  return [
    new CharacterPageSection(
      slotName: CharacterPageSlot::COLLECTIONS,
      content: [
        '#theme' => 'my_collection_summary',
        '#items' => $items,
      ],
      weight: 10,
    ),
  ];
}

Each CharacterPageSection targets a named slot. Use CharacterPageSlot constants for well-known positions (TITLE_RIBBON, COLLECTIONS, ACHIEVEMENTS, EQUIPMENT, GENERIC), or use a custom string — unknown slot names render in the generic fallback area.

The CharacterPageRenderer service wraps each section in the wow_character_section theme hook. Template suggestions are generated per plugin and slot:

  • wow_character_section__PLUGIN_ID
  • wow_character_section__SLOT_NAME
  • wow_character_section__SLOT_NAME__PLUGIN_ID

Plugin failures are isolated: one plugin throwing doesn't prevent other plugins from rendering their sections.

The base class CharacterDataProviderBase returns an empty array by default — you only need to override this method if your module has page content.

See also

  • Adding submodules — the full submodule scaffold; the character-integration checklist there lists the install-side wiring (_wow_character_owned_entity_types(), TtlSweeper::SWEEP_ENTITY_TYPES).
  • Dashboard providers — if you also want an admin row for your module.
  • API compliance — delete cascades and the 30-day TTL your junction must satisfy.