Skip to content

Adding New Submodules

This guide walks through creating a new taxonomy-backed submodule. Follow the pattern established by wow_playable_class (the simplest reference).

File checklist

modules/wow_<entity>/
├── wow_<entity>.info.yml          # Module metadata + dependencies
├── wow_<entity>.install           # Uninstall hook (state cleanup)
├── wow_<entity>.services.yml      # API + Sync services
├── drush.services.yml             # Drush command service
├── README.md                      # Admin-facing quick reference
├── config/
│   ├── install/
│   │   └── taxonomy.vocabulary.wow_<entity>.yml
│   └── optional/
│       ├── field.field.taxonomy_term.wow_<entity>.wow_blizzard_id.yml
│       ├── field.field.taxonomy_term.wow_<entity>.wow_last_fetched.yml
│       └── field.field.taxonomy_term.wow_<entity>.wow_<custom>.yml
├── src/
│   ├── Api/<Entity>Api.php
│   ├── Sync/<Entity>Sync.php
│   ├── Commands/<Entity>Commands.php
│   └── Plugin/DashboardSectionProvider/<Entity>Status.php
└── tests/
    └── src/
        ├── Unit/
        │   ├── Api/<Entity>ApiTest.php
        │   └── Sync/<Entity>SyncTest.php
        └── Kernel/
            └── Plugin/DashboardSectionProvider/<Entity>StatusTest.php

Base classes

The core module provides base classes that eliminate most boilerplate:

Your class Extends What you implement
<Entity>Api GameDataApi getIndex(), get<Entity>() — just the endpoint paths
<Entity>Sync TaxonomySyncBase $vocabularyId, $stateKeyPrefix, mapFields()
<Entity>Commands TaxonomySyncCommandBase stateKeyPrefix(), entityLabel(), the #[CLI\Command] attribute
<Entity>Status TaxonomyDashboardProviderBase 5 metadata methods: vocabulary ID, state key, title, label, service ID

Shared field storages

wow_blizzard_id and wow_last_fetched field storages are shared across all taxonomy submodules. Do NOT create new field.storage.taxonomy_term.wow_blizzard_id.yml files — only create field.field.*.yml instances for your vocabulary.

If you need a new custom field (e.g., wow_role), create both the storage and instance YAMLs in config/optional/.

Install hook

Use the core helper to clean up state keys on uninstall:

function wow_<entity>_uninstall(): void {
  wow_uninstall_state_keys('wow.last_modified.<entity>_index');
}

Adding character integration

If your data type has per-character tracking (like mounts, titles, reputation):

  1. Create a junction entity in src/Entity/ using the #[ContentEntityType] attribute. Declare NO owner field — wow_character projects character in at runtime. Do declare last_fetched as a base field; the TTL sweeper and TtlSweepCoverageTest depend on it.
  2. Create a CharacterDataProvider plugin in src/Plugin/CharacterDataProvider/ that fetches the profile sub-endpoint and upserts junction rows. Implement deleteCharacterData() too — it runs on character delete for ToS-cascade compliance.
  3. Register the new entity type ID in two places that must stay in sync:
  4. the match expression in wow_character.module's hook_entity_base_field_info() (runtime projection), and
  5. _wow_character_owned_entity_types() in modules/wow_character/wow_character.install (install/uninstall lifecycle).
  6. Register the entity type ID in TtlSweeper::SWEEP_ENTITY_TYPES.
  7. If the same junction is guild-ownable, add it to wow_guild.module and _wow_guild_owned_entity_types() the same way.
  8. Add tests: standalone entity round-trip, owner-injection ordering (paths A and B), full CharacterDataProvider sync/delete, and a uninstall-lifecycle test.

See wow_title as the reference implementation for the single-owner case; wow_achievement for a junction that is both character- and guild-ownable.

All character page contributions go through CharacterDataProvider plugins via buildPageSections(). See character data providers.

Testing conventions

  • PHPUnit attributes (#[CoversClass], #[Group]) — no docblock annotations.
  • #[RunTestsInSeparateProcesses] on all kernel tests.
  • // cspell:disable-next-line before WoW proper nouns in test fixtures.
  • US English spelling only.

See also