Skip to content

Annotations — Developer Reference

Developer-focused reference. For module overview, entity capabilities, and the permission model see README.md.


Config management

Scope config (annotation_target, annotation_type) is managed via standard config sync. Annotation text lives in the database and is never touched by config management tools.


Drush commands

Inspection (root annotations module)

Commands for querying the live state of targets, types, and annotation content. No submodules required.

# List all annotation_target config entities (field count + live annotation count)
drush ann:targets
drush ann:targets node            # filter by entity type
drush ann:targets --format=json

# List all annotation_type config entities (sorted by weight)
drush ann:types
drush ann:types --format=json

# Show stored annotation content
drush ann:show                              # all annotations (default limit 50)
drush ann:show node__article               # single target
drush ann:show --entity-type=node          # all node targets
drush ann:show node__article --type=editorial
drush ann:show node__article --field=body  # specific field
drush ann:show node__article --field=      # bundle-level only
drush ann:show --limit=200 --format=json

# Coverage stats: annotation counts per target broken down by type
drush ann:stats
drush ann:stats --entity-type=node
drush ann:stats --format=yaml

All four commands return Drush's standard RowsOfFields structured output — --format accepts table (default), json, yaml, csv, and more; --fields narrows displayed columns on ann:targets/ann:types/ann:show (their column sets are static). ann:stats has no --fields support: its columns are the live set of annotation types, known only at runtime, so it can't declare a static #[CLI\FieldLabels] list.

Use drush list --filter=annotations to see all registered annotations commands. Use drush help ann:show for full option docs.

Audit scan (annotations_audit)

See modules/annotations_audit/README.md for ann:scan (--diff, --strict, --fields).

Export (annotations_export)

See modules/annotations_export/README.md for ann:ex (markdown and Obsidian vault export).


Developer API

AnnotationStorageService (annotations.annotation_storage)

Central service for all annotation CRUD. Inject annotations.annotation_storage.

use Drupal\annotations\AnnotationStorageService;

// Load all annotations for a target.
// Returns: array<field_name, array<type_id, value>>
// Bundle-level annotations use '' as field_name.
$all = $annotationStorage->getForTarget('node__article');

// Bundle-level overview annotation.
$editorial = $all['']['editorial'] ?? '';

// Field-level annotation.
$body_technical = $all['body']['technical'] ?? '';

// Save site-wide annotations.
$annotationStorage->saveSiteAnnotations(['site_purpose' => '...']);

// Check whether a target has any annotation data.
$hasData = $annotationStorage->hasAnnotationData('node__article');

// Delete all annotation data for a target.
$annotationStorage->deleteForTarget('node__article');

Target plugin manager (plugin.manager.annotations_target)

Standard Drupal plugin manager (TargetPluginManager) for Target plugins, discovered from src/Plugin/AnnotationsTarget of every enabled module via the #[AnnotationsTarget] attribute. GenericTarget uses a deriver to cover every fieldable entity type not claimed by a dedicated plugin. Inject plugin.manager.annotations_target (type-hint TargetPluginManagerInterface).

$plugins = $targetPluginManager->getPlugins();
// Returns: array<entity_type_id, TargetInterface>
// Dedicated plugins shadow generic derivatives for the same entity type.

foreach ($plugins as $entity_type_id => $plugin) {
  if (!$plugin->isAvailable()) continue;
  $label   = $plugin->getLabel();   // e.g. "Content types"
  $bundles = $plugin->getBundles(); // array<bundle_key, label>
  $hasFields = $plugin->hasFields();
}

Extending annotation types with custom behaviors

AnnotationType implements ThirdPartySettingsInterface. See annotations_type_ui/DEVELOPING.md for the full pattern.


Adding a custom Target plugin

The default TargetBase::discover() includes FieldConfig fields and the four common editorial base fields (title, body, name, description). If you need to surface additional base fields — for example, a contrib entity type that uses a different machine name for its main content field — override discover() in a custom plugin.

Place a class in src/Plugin/AnnotationsTarget of your module with the #[AnnotationsTarget] attribute and extend TargetBase (which implements TargetInterface and provides DI for the entity type manager, bundle info, and field manager):

// mymodule/src/Plugin/AnnotationsTarget/CustomTarget.php
namespace Drupal\mymodule\Plugin\AnnotationsTarget;

use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\annotations\Attribute\AnnotationsTarget;
use Drupal\annotations\Plugin\AnnotationsTarget\TargetBase;

#[AnnotationsTarget(
  id: 'my_entity', // Plugin ID doubles as the entity type ID.
  label: new TranslatableMarkup('My entities'),
)]
class CustomTarget extends TargetBase {
  // getEntityTypeId(), getLabel(), isAvailable(), getBundles(), hasFields(),
  // and discover() all have working defaults in TargetBase — override only
  // what needs special handling.
}

No service registration and no changes to the annotations module are needed — attribute discovery picks the plugin up automatically. A dedicated plugin for a fieldable entity type shadows the auto-derived generic:{entity_type_id} derivative for that type. Definitions can be altered via hook_annotations_target_info_alter().


Shipping default annotations

Use the drupal script to export annotation entities as files in a recipe's content/ directory. This is the mechanism for shipping a starter annotation set with a profile or recipe.

ddev php web/core/scripts/drupal content:export annotation --dir=recipes/myrecipe/content

This is one-time, opt-in provisioning — the right tool when a site chooses to apply a recipe. It has no update path: RecipeRunner::processContent() hardcodes Existing::Skip, so re-applying a recipe never touches content that already exists. If your annotation content needs to stay in sync on sites that already have your module installed — not a recipe — see AnnotationSyncService below instead.


Keeping a module's shipped annotations in sync (AnnotationSyncService)

For a module (as opposed to a recipe) that ships its own default annotation content and needs to keep it in sync across releases — new content, edited content, or content dropped entirely — inject annotations.annotation_sync (AnnotationSyncServiceInterface) and call its one method from your own module's lifecycle hooks:

interface AnnotationSyncServiceInterface {
  public function refresh(string $module_name): void;
}

refresh($module_name):

  1. Deletes every annotation entity currently tagged provider = $module_name — the whole set, unconditionally, not just the ones about to be reimported. This is what correctly retires content your module drops from a release, not just content it edits.
  2. Reimports from {module_path}/content/annotation/*.yml via core's Drupal\Core\DefaultContent\Importer. A PreEntityImportEvent subscriber stamps the non-revisionable provider base field on Annotation to $module_name on each imported entity.

Delete-then-reimport against zero existing rows is a no-op that produces the same correct result as a first import, so there is no separate "initial import" code path — hook_install() and every hook_post_update_NAME() call the exact same refresh().

Content directory convention: only content/annotation/ is scanned — not your module's whole content/ tree — matching where core's own Exporter writes to. Author with:

ddev php web/core/scripts/drupal content:export annotation --dir=mymodule/content

Precondition refresh() assumes, not arranges: the annotation_type/annotation_target config your content depends on must already exist in active config before you call it. This is true by construction at hook_install() time (core installs config/install/config/optional before a module's own hook_install() runs) — but it's your responsibility at update time if a later release changes that config. refresh() deliberately does not sync annotation_type/annotation_target itself: an earlier version did, unconditionally, on every call — which meant a content-only release silently overwrote any site-owner customization to those config entities. That's an ordinary Drupal config-management problem, not something a shared content-sync primitive should also take on. If your release changes type/target config, update it yourself first, in the same hook, before calling refresh()\Drupal\Core\Config\Entity\ConfigEntityUpdater is core's purpose-built helper for this, and matters concretely for annotation_type: only ConfigEntityBundleBase::postSave() clears the bundle-info cache, so a raw configFactory()->getEditable()->save() write leaves it stale.

Call-site pattern (worked example — the localgov_guides pilot, a soft dependency on annotations):

// mymodule.install
function mymodule_install(): void {
  if (\Drupal::moduleHandler()->moduleExists('annotations')) {
    \Drupal::service('annotations.annotation_sync')->refresh('mymodule');
  }
}

function mymodule_uninstall(): void {
  // Deletes everything provider = 'mymodule'. The annotation_type/target
  // config's own removal is handled separately by core's config-dependency
  // cascade — see "dependencies.enforced.module" below.
  \Drupal::entityTypeManager()->getStorage('annotation')
    ->delete(\Drupal::entityTypeManager()->getStorage('annotation')
      ->loadByProperties(['provider' => 'mymodule']));
}

// mymodule.module
function mymodule_modules_installed(array $modules): void {
  // Only needed for a soft dependency: catches 'annotations' becoming
  // available after mymodule was already installed (including both
  // installed in the same command with no guaranteed internal order).
  if (in_array('annotations', $modules, TRUE)) {
    \Drupal::service('annotations.annotation_sync')->refresh('mymodule');
  }
}

// mymodule.post_update.php — one new, uniquely-named function per
// content-changing release. This is the actual "keep already-installed
// sites in sync" job; hook_install()/hook_modules_installed() only cover
// mymodule's own install moment.
function mymodule_post_update_content_2026_09(): void {
  \Drupal::service('annotations.annotation_sync')->refresh('mymodule');
}

Do not call refresh() from hook_update_N() — core's own docs disqualify entity API/CRUD calls at that tier (definitions aren't guaranteed stable yet); hook_post_update_NAME() is the tier core documents as safe for exactly this ("Executes an update which is intended to update data, like entities... Drupal is already fully bootstrapped").

If your module hard-depends on annotations (dependencies: [annotations] in the .info.yml), hook_modules_installed() isn't needed — hook_install() alone covers your install moment, since annotations is guaranteed present by then.

Making refresh() always safe to call: mark your shipped type readonly (below) so a site owner can never hand-edit the content refresh() deletes and recreates — see "Locking a shipped annotation type" next.


Locking a shipped annotation type (readonly)

A readonly third-party setting on AnnotationType marks a type as accepting only machine-managed content — the write-side counterpart to AnnotationSyncService above. It's what makes refresh()'s delete-then-reimport safe: if site owners can never hand-edit annotations of that type, there is never a real edit for refresh() to silently clobber.

Set it in shipped config only. The type-edit form (annotations_type_ui) shows the current status as a disabled checkbox when the type is readonly — root's AnnotationsHooks::formAnnotationTypeFormAlter() injects it into the same "Behavior" fieldset submodules use for their own third-party settings — so a site builder can see a type is locked, but there is deliberately no way to check or uncheck it there. An ordinary, non-readonly type gets no checkbox at all:

# annotations.annotation_type.guides.yml
id: guides
label: 'Guides'
third_party_settings:
  annotations:
    readonly: true

Ship this in your module's config/install/ (hard dependency on annotations) or config/optional/ (soft dependency, mirroring the guides pattern above).

What it blocks: create/update/delete for everyone except administer annotations, enforced in exactly one place — AnnotationAccessControlHandler (root). Every other surface that can reach an annotation delegates to it rather than holding its own copy of the check:

  • annotations_ui.target.create's route uses _entity_create_access: 'annotation:{type_id}', which core's EntityCreateAccessCheck resolves straight to AnnotationAccessControlHandler::checkCreateAccess().
  • AnnotationsOverlayService (annotations_overlay) calls $entity->access('update', ...) for its Edit trigger and $entityTypeManager->getAccessControlHandler('annotation')->createAccess(...) for its Add links — the overlay UI never renders a link into a route that would then reject it, without maintaining a second copy of the permission logic to do it.
  • AnnotationController::loadAnnotationTypes() (annotations_ui, the add-page's list of types with an available slot) filters via the same createAccess() call, for the same reason.

A single enforcement point isn't incidental here: an earlier version of this had three independent copies of the same permission logic (the entity handler, a bespoke _custom_access route callback, and a private method in the overlay service) that had already drifted apart — the route callback never granted administer annotations its bypass, unlike the entity handler. Delegating everywhere to AnnotationAccessControlHandler closed that gap and removes the possibility of it reopening.

What it does not block: view/consume. A readonly type's content is exactly as visible as any other type's — consume {type} annotations still governs that, unchanged. Readonly is a write-gate only.

Not blocked, by design: AnnotationSyncService::refresh() itself. It deletes and reimports through entity storage directly (EntityStorageInterface::delete(), Importer::importContent()), and Drupal's entity storage does not gate delete()/save() through access control at all — only forms, routes, and REST do (EntityAccessControlHandler::access() is opt-in, never called by EntityStorageBase itself). The readonly flag protects a type from editors, not from the sync mechanism that is supposed to be able to overwrite it.

API: AnnotationTypeInterface::isReadonly(): bool. Backed by getThirdPartySetting('annotations', 'readonly', FALSE) — no dedicated interface for setting it beyond the standard ThirdPartySettingsInterface every config entity already implements.


Recipe authoring

The root module ships two config action plugins for wiring up annotation scope in a recipe without overwriting pre-existing config.

enableTargetType

Appends one or more entity types to annotations.target_types. Idempotent — types already in the list are skipped.

config:
  actions:
    annotations.target_types:
      enableTargetType: node
      # or a list:
      # enableTargetType:
      #   - node
      #   - taxonomy_term

enableTargetField

Appends fields to an annotation_target entity's fields list. Idempotent — fields already registered are skipped. The target config entity must already exist — export it from the site and ship it in the recipe's config/ directory before applying this action, or it throws a ConfigActionException.

config:
  actions:
    annotations.target.node__article:
      enableTargetField:
        - title
        - body
        - field_tags

Bolt-on recipe pattern (targeting existing content types)

install:
  - annotations
  - annotations_ui
  - annotations_type_ui

config:
  actions:
    annotations.target_types:
      enableTargetType: node
    annotations.target.node__article:
      enableTargetField:
        - title
        - body
        - field_tags

New content type recipe pattern

When a recipe creates its own content types, ship annotations.target.{entity_type}__{bundle}.yml directly in the recipe's config/ directory alongside the node type, field, and display configs. Config import runs before config actions, so the target entity is already in place by the time any action fires. enableTargetField is not needed — the target YAML already carries the full fields list.

See recipes/annotations_demo/ for a worked example.


Documentation site

Published at https://project.pages.drupalcode.org/annotations/ via GitLab Pages (MkDocs Material). Source files are the README.md and DEVELOPING.md files that live alongside the code in each module and recipe directory — no separate docs tree to maintain.

To preview locally, mirror the files into docs/ then serve:

pip install mkdocs-material
mkdir -p docs docs/recipes
cp README.md DEVELOPING.md docs/
for dir in modules/annotations_*/; do mkdir -p "docs/$dir"; [ -f "$dir/README.md" ] && cp "$dir/README.md" "docs/$dir/"; [ -f "$dir/DEVELOPING.md" ] && cp "$dir/DEVELOPING.md" "docs/$dir/"; done
cp recipes/README.md docs/recipes/ && for dir in recipes/annotations_*/; do mkdir -p "docs/$dir" && cp "$dir/README.md" "docs/$dir/"; done
mkdocs serve

docs/ is not committed — CI creates it from the source files on every build.


Notes

Views listed use a text variable instead of value in the filter criteria because that was a reserved name: admin/structure/views/view/annotations_target, admin/structure/views/view/annotations