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.clientfor API calls. - Inject
entity_type.managerfor your junction storage. - Inject
datetime.timeto stamplast_fetched. - Inject
logger.channel.wowfor 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_fetchedas a base field (the TTL sweeper depends on it, theTtlSweepCoverageTestenforces it). - Must NOT declare the
characterreference —wow_characterprojects it in viahook_entity_base_field_info. Same forguildif your junction is also guild-ownable. - Must have
wow_characterlisted in_wow_character_owned_entity_types()inmodules/wow_character/wow_character.install.
See architecture/lifecycle.md for why this matters.
Testing expectations¶
Two kernel tests are table-stakes:
- Standalone entity test — create, save, round-trip your junction with mock data. See
MountCollectionEntityTest. - Full plugin test — mock BattleNetClient, swap it into the container, drive
syncCharacterData()with canned API responses, assert rows land. SeeMountDataTest.
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_IDwow_character_section__SLOT_NAMEwow_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.