Problem/Motivation

We've got the AST for the base schema and the extensions cached. The other thing we need to serve GraphQL requests is the Resolver Registry, since it informs us what to call for each field.

90% of the Resolver Registry are static references to classes which can easily be serialized and cached. However, the existence of the Callback resolver means that the serialization of the Registry may be prevented and break.

Although using callbacks can be easy to prototype, there's nothing that they can do that a proper data producer class can not do and besides quick prototyping the maintenance is often worse.

If we can get the resolver registry cacheable then I believe we can boot up the GraphQL server without loading any plugins (we would only need the schema AST and the serialized registry).

Steps to reproduce

Proposed resolution

Deprecate the callback resolver (the class itself and ResolverBuilder::callback) for removal in GraphQL 6.

Remaining tasks

User interface changes

API changes

Data model changes

Issue fork graphql-3576071

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

kingdutch created an issue. See original summary.

kingdutch’s picture

Status: Active » Needs review

Code is ready for review. Leaving assigned to Klausi to get buy-in on the plan :)

As a follow-up we may also need to address ResolverBuilder::fromContext which currently requests a callable to pass to Context but that callable is never actually invoked, the Context class expects static default values.

klausi’s picture

Status: Needs review » Needs work

Thanks, I think I'm ok with this. At Jobiqo we use too many callback resolvers, so this will be a bit painful to upgrade for us.

But in general I agree, I also prefer to see dataproducer code in actual dataproducer classes.

Can you fix the phpstan errors? I think we need to update examples and docs by removing all callback stuff there.

kingdutch’s picture

Status: Needs work » Needs review
Issue tags: +DevDaysAthens2026
kingdutch’s picture

Assigned: klausi » Unassigned
Status: Needs review » Fixed

Updated the docs and fixed PHPStan. Relying on the review from #4 to be able to tag beta3.

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

  • kingdutch committed 5b776f12 on 5.x
    feat: #3576071 Deprecate Callback Resolvers
    
    Callback resolvers prevent...
klausi’s picture

One thing I noticed here is that all example dataproducers should be prefixed with the module name - that is a best practice to avoid name clashes.

Can be fixed in a follow-up.

  • kingdutch committed eeb7e47c on 5.x
    bug: #3576071 Prefix docs data producers with graphql_docs
    
    This ensures...

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

pfrenssen’s picture

I want to share an AI skill I have made to facilitate the conversion from callback to data producer. We had to migrate a TON of callbacks and I noticed during the work that there are several types of callbacks which can match to different patterns. I have distilled this into a detailed skill which takes away most of the repetitive work.

As always, AI skills are opinionated (e.g. the use of DDEV is assumed, and a preference for ExistingSite over KernelTest is mentioned), but it has been serving us well and I hope it will help out others.

# GraphQL: Replace Builder Callbacks with Data Producers

Best practices for migrating `$builder->callback(...)` resolvers in Drupal GraphQL
schema extensions to proper data producer plugins.

## Why

Drupal GraphQL 5 deprecated the closure-based `$builder->callback()` resolver.
Closures cannot be serialized in PHP, which prevents caching of the
`ResolverRegistry` and the assembled schema. Replacing callbacks with data
producer plugins unlocks that caching and is the upstream-supported path forward.

References:

- Change record: <https://www.drupal.org/node/3576383>
- Reference migration (graphql_webform): <https://git.drupalcode.org/project/graphql_webform/-/merge_requests/76.diff>

## Compatibility note

The PHP 8 attribute syntax shown below (`#[DataProducer(...)]`) requires
**`drupal/graphql ^5`**. On `drupal/graphql ^4`, the `Drupal\graphql\Attribute\DataProducer`
class does not exist; producers must be declared with the doctrine annotation
syntax (`@DataProducer(...)` in a docblock) instead.

Use attributes for any new producer written after the GraphQL 5 upgrade, and
convert annotated producers as part of that upgrade.

## How to identify candidates

Open any class extending `SdlSchemaExtensionPluginBase` and look at
`registerResolvers()`. Any call like the following needs to be migrated:

```php
$registry->addFieldResolver('SomeType', 'someField', $builder->callback(
  fn (SomeModel $model): string => $model->getSomeData()
));
```

Group the callbacks by the **parent GraphQL type** they resolve on. A type
with several callbacks is a high-value target — it can usually be collapsed
into a single data producer.

Also look for callbacks embedded inside `$builder->compose()` chains — these
need careful handling (see the Compound resolvers section below).

## Decision guide: which pattern to use

Before writing any code, classify each callback:

| Situation | Pattern |
|---|---|
| 3+ fields on the same GraphQL type, all delegating to methods on the same parent object | **A: Grouped-field producer** |
| 1–2 fields, or fields with different parent types, or non-trivial / unique logic | **B: Single-field dedicated producer** |
| Input is a PHP model class (not a Drupal entity — no storage, no cache tags) | **C: Value object input** |
| Output is a nullable referenced entity that may or may not exist | **D: Optional referenced entity** |
| Resolver needs a Drupal service (entity type manager, config factory, etc.) | **E: Service-dependent producer** |
| Callback is inside `$builder->compose()` alongside a `$builder->context()` call | **F: Compound resolver** |
| Callback is a service-free one-liner (property read, method delegation, null coercion), and several such callbacks exist that can be grouped | **G: Simple resolver** |

Patterns are composable. A single producer can be both C (value object input)
and E (service injection), for example.

The patterns also fall along a spectrum of boilerplate. Data producers
(A–F) are the flexible, fully-featured option, but they carry a lot of
ceremony — a plugin class, attribute metadata, context definitions, and a
`->produce()->map()` wiring per field. **Pattern G** trades that flexibility
for a much lighter footprint and is the better fit when the callbacks are
trivial. Reach for it before A or B when the logic is a one-liner with no
service dependency.

---

## Pattern A: Grouped-field producer

When several fields of the same GraphQL type all read from the same parent
object with simple method delegation, write **one** producer that accepts the
parent entity plus a `field: String!` argument and dispatches with `match`.

### Step 1: Create the data producer

Location: `src/Plugin/GraphQL/DataProducer/{ParentEntity}{GraphqlType}Field.php`

```php
<?php

declare(strict_types=1);

namespace Drupal\my_module\Plugin\GraphQL\DataProducer;

use Drupal\Core\Plugin\Context\ContextDefinition;
use Drupal\Core\Plugin\Context\EntityContextDefinition;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\graphql\Attribute\DataProducer;
use Drupal\graphql\GraphQL\Execution\FieldContext;
use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase;
use Drupal\my_module\Entity\SomeEntity;

/**
 * Resolves a given field of the SomeGraphqlType GraphQL type.
 */
#[DataProducer(
  id: 'some_entity_some_graphql_type_field',
  name: new TranslatableMarkup('Some entity some graphql type field'),
  description: new TranslatableMarkup('Resolves a single field of the SomeGraphqlType GraphQL type.'),
  produces: new ContextDefinition(
    data_type: 'any',
    label: new TranslatableMarkup('Field value'),
    required: FALSE,
  ),
  consumes: [
    'entity' => new EntityContextDefinition(
      data_type: 'entity:some_entity_type_id',
      label: new TranslatableMarkup('Some entity'),
    ),
    'field' => new ContextDefinition(
      data_type: 'string',
      label: new TranslatableMarkup('Field name'),
    ),
  ],
)]
final class SomeEntitySomeGraphqlTypeField extends DataProducerPluginBase {

  /**
   * Resolves a single field of the SomeGraphqlType GraphQL type.
   *
   * @param \Drupal\my_module\Entity\SomeEntity $entity
   *   The entity to resolve the field for.
   * @param string $field
   *   The name of the SomeGraphqlType field to resolve.
   * @param \Drupal\graphql\GraphQL\Execution\FieldContext $context
   *   The field context. Used to register the entity as a cacheable
   *   dependency so the GraphQL response is invalidated when the entity
   *   changes.
   *
   * @return mixed
   *   The resolved field value.
   */
  protected function resolve(SomeEntity $entity, string $field, FieldContext $context): mixed {
    $context->addCacheableDependency($entity);

    return match ($field) {
      'foo' => $entity->getFoo(),
      'bar' => $entity->isBar(),
      'baz' => $entity->getBaz(),
      default => throw new \InvalidArgumentException(sprintf('Unknown field: %s', $field)),
    };
  }

}
```

### Step 2: Update the schema extension

Replace the individual `addFieldResolver` calls with a loop:

```php
foreach (['foo', 'bar', 'baz'] as $field) {
  $registry->addFieldResolver('SomeGraphqlType', $field,
    $builder->produce('some_entity_some_graphql_type_field')
      ->map('entity', $builder->fromParent())
      ->map('field', $builder->fromValue($field))
  );
}
```

---

## Pattern B: Single-field dedicated producer

When a field has unique logic, non-trivial transformation, or there are only
1–2 fields of the same type, give each a dedicated producer class. This is the
most common pattern in practice — every field whose callback cannot share code
with another field gets its own class.

```php
/**
 * Resolves whether an entity is hidden.
 */
#[DataProducer(
  id: 'some_entity_is_hidden',
  name: new TranslatableMarkup('Some entity is hidden'),
  produces: new ContextDefinition(
    data_type: 'boolean',
    label: new TranslatableMarkup('Whether the entity is hidden'),
  ),
  consumes: [
    'entity' => new ContextDefinition(
      data_type: 'entity',
      label: new TranslatableMarkup('Entity'),
    ),
  ],
)]
final class SomeEntityIsHidden extends DataProducerPluginBase {

  /**
   * Resolves whether the entity is hidden.
   *
   * @param \Drupal\my_module\HideableInterface $entity
   *   The entity.
   * @param \Drupal\graphql\GraphQL\Execution\FieldContext $field
   *   The field context. Registers the entity as a cacheable dependency.
   *
   * @return bool
   *   TRUE if the entity is hidden, FALSE otherwise.
   */
  protected function resolve(HideableInterface $entity, FieldContext $field): bool {
    $field->addCacheableDependency($entity);
    return $entity->isHidden();
  }

}
```

In the schema extension:

```php
$registry->addFieldResolver('SomeEntity', 'isHidden',
  $builder->produce('some_entity_is_hidden')
    ->map('entity', $builder->fromParent())
);
```

Note the `data_type: 'entity'` (generic) when the input implements an interface
rather than a single concrete entity type. The PHP type hint on `resolve()` then
uses the interface class, which is more precise than what the context definition
can express.

---

## Pattern C: Value object inputs

When the resolved parent is a PHP model class — not a Drupal entity (no
`EntityInterface`, no storage, no cache tags) — declare the consumed context
with `data_type: 'any'`. Do **not** add the object as a cacheable dependency
since it has no cache metadata.

```php
#[DataProducer(
  id: 'my_result_field',
  produces: new ContextDefinition(
    data_type: 'any',
    label: new TranslatableMarkup('Field value'),
    required: FALSE,
  ),
  consumes: [
    'result' => new ContextDefinition(
      data_type: 'any',
      label: new TranslatableMarkup('Result object'),
    ),
    'field' => new ContextDefinition(
      data_type: 'string',
      label: new TranslatableMarkup('Field name'),
    ),
  ],
)]
final class MyResultField extends DataProducerPluginBase {

  /**
   * Resolves a field of the result object.
   *
   * @param \Drupal\my_module\Model\MyResult $result
   *   The result model object.
   * @param string $field
   *   The field to resolve.
   *
   * @return mixed
   *   The resolved value. No cacheable dependencies are registered because
   *   MyResult is an in-memory value object with no cache metadata.
   */
  protected function resolve(MyResult $result, string $field): mixed {
    return match ($field) {
      'success' => $result->isValid(),
      'message' => $result->getMessage(),
      'errors' => $result->getErrors(),
      default => throw new \InvalidArgumentException(sprintf('Unknown field: %s', $field)),
    };
  }

}
```

When a value object carries a **reference to an entity** whose content affects
the result, add that referenced entity as a cacheable dependency even though the
value object itself is not cacheable:

```php
protected function resolve(MyResult $result, string $field, FieldContext $context): mixed {
  $entity = $result->getRelatedEntity();
  if ($entity) {
    $context->addCacheableDependency($entity);
  }
  // ...
}
```

---

## Pattern D: Optional referenced entity

When the resolved value is an entity that may or may not be set, add the
cacheable dependency only when the entity exists:

```php
/**
 * Resolves the referenced image for the entity.
 *
 * @param \Drupal\my_module\ImageAwareInterface $entity
 *   The entity that may reference an image.
 * @param \Drupal\graphql\GraphQL\Execution\FieldContext $field
 *   The field context. Registers the image entity as a cacheable
 *   dependency when present, so stale references are not returned.
 *
 * @return \Drupal\my_module\Entity\Image|null
 *   The image entity, or NULL if none is referenced.
 */
protected function resolve(ImageAwareInterface $entity, FieldContext $field): ?Image {
  $field->addCacheableDependency($entity);
  $image = $entity->getImage();
  if ($image) {
    $field->addCacheableDependency($image);
  }
  return $image;
}
```

Always add the **source entity** as a dependency (so changes to the reference
field itself are reflected), plus the **referenced entity** if it exists.

---

## Pattern E: Service-dependent producer

When resolution needs a Drupal service, implement `ContainerFactoryPluginInterface`.
Pass services as constructor-injected `protected readonly` properties.

```php
final class SomeComplexProducer
  extends DataProducerPluginBase
  implements ContainerFactoryPluginInterface {

  public function __construct(
    array $configuration,
    string $pluginId,
    mixed $pluginDefinition,
    protected readonly EntityTypeManagerInterface $entityTypeManager,
    protected readonly ConfigFactoryInterface $configFactory,
  ) {
    parent::__construct($configuration, $pluginId, $pluginDefinition);
  }

  public static function create(
    ContainerInterface $container,
    array $configuration,
    $pluginId,
    $pluginDefinition,
  ): static {
    return new static(
      $configuration,
      $pluginId,
      $pluginDefinition,
      $container->get('entity_type.manager'),
      $container->get('config.factory'),
    );
  }

  protected function resolve(SomeEntity $entity, FieldContext $context): mixed {
    $config = $this->configFactory->get('my_module.settings');
    $context->addCacheableDependency($entity);
    $context->addCacheableDependency($config);
    // ...
  }

}
```

Add each injected service that influences the result as a cacheable dependency
inside `resolve()`.

---

## Pattern F: Compound resolvers (`$builder->compose()` with `$builder->context()`)

Sometimes a callback is wrapped in a `$builder->compose()` chain that also calls
`$builder->context()` to store a value in the resolver context for child
resolvers to read via `$builder->fromContext()`. **Do not remove the `context()`
call** — it serves a purpose beyond the callback. Only replace the callback part:

```php
// Before
$registry->addFieldResolver('Eventinstance', 'series', $builder->compose(
  $builder->context('event_instance', $builder->fromParent()),
  $builder->callback(fn (EventInstance $instance): EventSeries => $instance->getEventSeries()),
));

// After — compose and context() stay; only the callback becomes a producer
$registry->addFieldResolver('Eventinstance', 'series', $builder->compose(
  $builder->context('event_instance', $builder->fromParent()),
  $builder->produce('eventinstance_series')
    ->map('entity', $builder->fromParent()),
));
```

When a `compose()` chain contains **only** a callback with no `context()`, it
can be flattened to a direct producer call without `compose()`:

```php
// Before
$registry->addFieldResolver('MyType', 'myField', $builder->compose(
  $builder->callback(fn (MyEntity $e): string => $e->getValue()),
));

// After
$registry->addFieldResolver('MyType', 'myField',
  $builder->produce('my_entity_value')
    ->map('entity', $builder->fromParent())
);
```

---

## Pattern G: Simple resolver (`ResolverInterface`)

Not every callback justifies a data producer. A producer is the right tool when
you need flexibility — service injection, reusable mapped inputs, precise context
typing. But for a callback that is a **service-free one-liner** (read a property,
delegate to a method, coerce a null), the full producer machinery is pure
boilerplate.

For these, write a class implementing
`Drupal\graphql\GraphQL\Resolver\ResolverInterface` and group many such callbacks
into it as named static factory methods. This is dramatically less code per field
than a producer, and a project with dozens of trivial callbacks can collapse them
into one or two `Resolver` classes.

### When to choose G over A or B

- **vs. Pattern B (dedicated producer)**: B is for unique or non-trivial logic.
  G is for callbacks you would write as `fn ($x) => $x->getFoo()` and nothing
  more.
- **vs. Pattern A (grouped producer)**: A groups fields of the *same GraphQL
  type* behind a `field` argument and a `match`. G groups callbacks by the fact
  that they are *all simple and service-free*, regardless of which type or
  parent they resolve on.
- **vs. Pattern E (service-dependent)**: the moment a Drupal service is needed,
  it is no longer a Pattern G candidate — write a data producer. Keeping
  Pattern G strictly service-free is what keeps the resolver serializable (see
  below).

### The class

Location: `src/GraphQL/Resolver/{ClassName}.php` (note: **not** under
`Plugin/GraphQL/DataProducer/` — a `ResolverInterface` is not a plugin).

```php
<?php

declare(strict_types=1);

namespace Drupal\my_module\GraphQL\Resolver;

use Drupal\file\FileInterface;
use Drupal\graphql\GraphQL\Execution\FieldContext;
use Drupal\graphql\GraphQL\Execution\ResolveContext;
use Drupal\graphql\GraphQL\Resolver\ResolverInterface;
use Drupal\menu_link_content\Plugin\Menu\MenuLinkContent;
use GraphQL\Type\Definition\ResolveInfo;

/**
 * Groups small, service-free field resolvers into a single class.
 *
 * Only resolvers that need no injected services live here — they read the
 * parent value (and, where relevant, register cacheable dependencies via the
 * FieldContext). Resolvers that depend on Drupal services remain dedicated
 * data producer plugins so they keep proper dependency injection.
 *
 * The instance only stores an operation string and a scalar config array, so it
 * remains serializable.
 */
final readonly class FieldResolver implements ResolverInterface {

  private function __construct(
    private string $operation,
    private array $config = [],
  ) {}

  /**
   * Resolves the parent menu link's parent plugin ID.
   */
  public static function menuLinkParent(): self {
    return new self('menuLinkParent');
  }

  /**
   * Resolves the root-relative URL of the parent file entity.
   */
  public static function fileUrl(): self {
    return new self('fileUrl');
  }

  /**
   * Reads a single key from the parent array/object value.
   *
   * @param string $key
   *   The key (or property) to read.
   * @param mixed $default
   *   The value to return when the key is missing.
   */
  public static function mapValue(string $key, mixed $default = NULL): self {
    return new self('mapValue', ['key' => $key, 'default' => $default]);
  }

  /**
   * {@inheritdoc}
   */
  public function resolve($value, $args, ResolveContext $context, ResolveInfo $info, FieldContext $field): mixed {
    return match ($this->operation) {
      'menuLinkParent' => $value instanceof MenuLinkContent ? $value->getParent() : NULL,
      'fileUrl' => $this->resolveFileUrl($value, $field),
      'mapValue' => $this->resolveMapValue($value),
    };
  }

  /**
   * Resolves the root-relative file URL, registering the file as a dependency.
   */
  protected function resolveFileUrl(FileInterface $value, FieldContext $field): ?string {
    $field->addCacheableDependency($value);
    return $value->createFileUrl();
  }

  /**
   * Reads a single key from an array or object value.
   */
  protected function resolveMapValue(mixed $value): mixed {
    $key = $this->config['key'];
    $default = $this->config['default'] ?? NULL;
    if (is_array($value)) {
      return $value[$key] ?? $default;
    }
    if (is_object($value)) {
      return $value->{$key} ?? $default;
    }
    return $default;
  }

}
```

### Registration

Pass the factory result straight to `addFieldResolver()` — no `$builder` wiring:

```php
$registry->addFieldResolver('File', 'url', FieldResolver::fileUrl());
$registry->addFieldResolver('MenuLink', 'parent', FieldResolver::menuLinkParent());
$registry->addFieldResolver('SomeType', 'someKey', FieldResolver::mapValue('some_key'));
```

### Why a private constructor and named factories

The constructor is **`private`**: an instance can only be created through a named
static method like `FieldResolver::fileUrl()`. This is deliberate —

- It forces every operation to have a named, documented entry point. The set of
  public static methods *is* the list of supported operations, and the `match`
  in `resolve()` must stay in sync with it.
- It prevents callers from constructing an arbitrary `new FieldResolver('typo')`
  that would fall through the `match` at runtime.

(`private` over `protected`: the class is `final`, so there are no subclasses to
inherit a `protected` constructor — `private` simply states the intent more
precisely. Use whichever your project's conventions prefer.)

### Serializability — the hard constraint

The whole point of the GraphQL 5 migration is that resolvers must be
serializable so the schema can be cached (closures are not). A Pattern G
resolver is only safe if its instance state is serializable:

- Store **only scalars and scalar arrays** in `$operation` and `$config`.
- **Never** store a service, an entity, a closure, or any other object on the
  instance. If you find yourself wanting to, the callback is not a Pattern G
  candidate — promote it to a data producer (Pattern E).

### Cacheability still applies

`resolve()` receives the `FieldContext` as its last parameter, exactly like a
producer. The rules from the **Cacheability** section below are unchanged: every
input whose content affects the result must be registered via
`$field->addCacheableDependency(...)` (see `resolveFileUrl()` above).

### Cross-module utility resolvers

Static operations that are generally useful across modules — flattening an
array, reading a map key, formatting a URL — can be grouped into a shared
`Resolver` class rather than duplicated per module. If the project already has a
base module for shared GraphQL functionality (e.g. `myproject_graphql`), that is
the natural home for it.

Keep module-specific operations — those that type-hint an entity or bundle class
owned by one module — in that module alongside the type they resolve.

---

## Cacheability — register every dependency

This is **mandatory**, not optional. GraphQL caches resolver results. If the
result of `resolve()` depends on the content of any input (entity, config,
referenced entity, current user, request state, etc.), the producer **must**
register that dependency on the `FieldContext`. Forgetting this returns stale
responses to clients after the underlying data changes — a silent, hard-to-
diagnose class of bug.

How to wire it in:

1. Add `\Drupal\graphql\GraphQL\Execution\FieldContext $context` as the
   **last** parameter of `resolve()`. The GraphQL module only populates the
   field-context argument when it is in the final position.
2. At the top of the method, call `$context->addCacheableDependency(...)` for
   **every** input whose content affects the result.
3. For state that is not itself a `CacheableDependencyInterface` (e.g.
   varying by the current user or language), add cache contexts and tags
   explicitly: `$context->addCacheContexts(['user'])`,
   `$context->addCacheTags(['some_list'])`.

Checklist for each `resolve()` method:

- [ ] Every consumed entity argument is added via `addCacheableDependency()`.
- [ ] Any referenced entity that the result depends on is also added.
- [ ] Any config object read inside the method is also added.
- [ ] Any cache contexts (user, language, url, etc.) are explicitly added.
- [ ] Any cache tags (lists, derived data) are explicitly added.
- [ ] Value object inputs (Pattern C) are **not** added — they have no cache metadata.

---

## Docblock conventions (Drupal API standards)

Every `resolve()` method gets a full docblock:

- One-line short description ending with a period.
- A `@param` for **every** argument, in declaration order. Use the
  fully-qualified class name (with leading backslash) or the scalar type;
  describe the argument starting with a capital and ending with a period.
- A `@return` with type and description.
- Document the `FieldContext` argument — note in its description what
  cacheable dependencies / contexts / tags the method registers.

See Drupal core coding standards:
<https://www.drupal.org/docs/develop/standards/api-documentation-and-comment-standards>

---

## Context definitions: type precisely

The data type in `ContextDefinition` drives validation and communicates intent:

- **Drupal entities**: `EntityContextDefinition` with
  `data_type: 'entity:{entity_type_id}'` (e.g. `'entity:node'`,
  `'entity:eventinstance'`). When only the bundle class matters, use the
  bundle-specific entity type ID — or `'entity'` (generic) when the entity
  implements an interface and no narrower type exists.
- **Scalars**: `ContextDefinition` with `'string'`, `'integer'`, `'boolean'`, etc.
- **Collections**: add `multiple: TRUE` to the `ContextDefinition`.
- **Optional inputs**: add `required: FALSE`.
- **`'any'`**: the escape hatch for PHP value objects, arrays, union types,
  and other inputs that do not map to a Drupal typed data type. It is **not**
  the default — always prefer a precise type.

---

## Naming conventions

- **Plugin ID**: `{parent_entity_snake_case}_{graphql_type_snake_case}_field`
  (for grouped patterns) or `{module_prefix}_{descriptive_name}` (for single-field)
- **Class name**: `{ParentEntity}{GraphqlType}Field` or `{DescriptiveName}`
- **File**: `src/Plugin/GraphQL/DataProducer/{ClassName}.php`

Examples:

- GraphQL type `RegistrationState` on parent `DefaultEventInstance` →
  `event_instance_registration_state_field` /
  `EventInstanceRegistrationStateField`
- Single field `isHidden` on entity with `HideableInterface` →
  `bundle_class_hideable` / `Hideable`
- Single field `moderationState` with enum conversion →
  `ekw_jam_job_ad_moderation_state` / `JobAdModerationState`

---

## When not to use the grouped-field (Pattern A)

- **Different logic shapes per field**: if some fields need service injection,
  non-trivial transformation, or different caching logic, split them out.
- **Different parent types**: if fields are on different GraphQL types (even if
  the PHP class is similar), give each type its own producer.
- **Single one-off field**: a dedicated producer (Pattern B) is fine and often
  cleaner. Pattern A earns its keep starting at ~3 fields.
- **Trivial, service-free callbacks**: if the logic is a one-liner with no
  service dependency, a simple resolver (Pattern G) is lighter than any
  producer — prefer it.
- **Already a producer chain**: resolvers that already use
  `$builder->produce(...)->map(...)` are not callbacks and do not need migration.
- **`$builder->compose()` chains**: the non-callback parts of a compose chain
  are not candidates for the grouped pattern (see Pattern F).

---

## `getBaseDefinition()` return type (GraphQL 5 only)

GraphQL 5 changes the return type of `getBaseDefinition()` from `string` to
`?Source` (from `GraphQL\Language\Source`). When upgrading, update the signature
and wrap the SDL string:

```php
use GraphQL\Language\Source;

public function getBaseDefinition(): ?Source {
  $parent = parent::getBaseDefinition();
  $generatedSdl = '...';
  return new Source($parent->body . "\n\n" . $generatedSdl, $parent->name);
}
```

---

## Testing

Follow this workflow for every batch of callbacks you migrate. The goal is a
confirmed-passing test baseline **before** touching any resolver code, so that
a failing test after migration clearly points to a regression you introduced.

### Step 0 — Audit existing coverage

Before touching any code, identify the GraphQL fields being migrated and check
whether they are already exercised by an automated test:

```bash
grep -r "fieldNameInQuery\|SomeGraphqlType" tests/ --include="*.php" -l
```

List the field names from the callbacks under migration. If a field is not
queried in any existing test, it needs coverage before you proceed.

### Step 1 — Add missing test coverage

Write an ExistingSite test for each uncovered field. ExistingSite tests
(`weitzman/drupal-test-traits`) run against a stable SQL dump of an empty
installed site: no fixtures to maintain, and the schema stays in sync with
the codebase automatically.

**Use real GraphQL queries — not unit tests of the PHP method.** A query test
verifies the schema wiring, the resolver registration, the data producer logic,
and the cacheability in a single pass.

Look for existing tests in the same module as a template — match the base class
and creation traits already in use there.

```php
<?php

declare(strict_types=1);

namespace Drupal\Tests\my_module\ExistingSite;

use Drupal\Tests\graphql\Traits\QueryResultAssertionTrait;
use Drupal\Tests\my_module\Traits\MyEntityCreationTrait;
use PHPUnit\Framework\Attributes\Group;
// Replace with the project's GraphQL ExistingSite base class.
use Drupal\Tests\my_project\ExistingSite\MyProjectGraphQlTestBase;

/**
 * Tests the MyEntity type's custom GraphQL fields.
 */
#[Group('my_module')]
class MyEntityGraphQlTest extends MyProjectGraphQlTestBase {

  use MyEntityCreationTrait;
  use QueryResultAssertionTrait;

  /**
   * The entity used across test methods.
   */
  protected MyEntity $entity;

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();

    $this->entity = $this->createMyEntity([
      'title' => 'Test entity',
      'field_is_featured' => TRUE,
    ]);
  }

  /**
   * Tests that isFeatured and title are resolved correctly.
   */
  public function testMyEntityCustomFields(): void {
    $query = <<<GQL
      query myEntityById(\$id: ID!) {
        entityById(entityType: MY_ENTITY, id: \$id) {
          ... on MyEntity {
            title
            isFeatured
          }
        }
      }
    GQL;

    $variables = ['id' => $this->entity->id()];

    $expected = [
      'entityById' => [
        'title' => 'Test entity',
        'isFeatured' => TRUE,
      ],
    ];

    $metadata = $this->defaultCacheMetaData();
    $metadata->addCacheableDependency($this->entity);

    $this->assertResults($query, $variables, $expected, $metadata);
  }

}
```

`assertResults()` (from `QueryResultAssertionTrait`) runs the query **twice**
and asserts both the response data and the cache metadata. The second pass
catches uncacheable resolver output. The metadata assertion verifies that
`addCacheableDependency()` calls in the data producer are complete — a missing
dependency causes the assertion to fail even when the data looks correct.

**KernelTests** are a fallback when ExistingSite infrastructure is not available
in the project, or when the scenario requires controlled, isolated state that
cannot be set up against the shared SQL dump.

### Step 2 — Confirm tests pass and commit

Run the tests for the affected module:

```bash
ddev phpunit drupal/docroot/modules/custom/my_module/tests
```

All new and existing tests must pass. Commit the tests (or ask the developer
to commit) before proceeding. This creates a verified baseline: if anything
breaks after migration, the diff from this commit shows exactly what changed.

### Step 3 — Perform the migration

Apply the callback → data producer patterns from the sections above. Do not
change any tests during this step.

### Step 4 — Verify tests still pass

Run the same suite again:

```bash
ddev phpunit drupal/docroot/modules/custom/my_module/tests
```

If a test fails, the diff from the Step 2 commit isolates what the migration
broke. Fix and re-run before moving on to the next batch.

If a producer contains non-trivial branching logic that the query tests do not
fully exercise, you can add a `DataProducerExecutionTrait` test at this point —
the producer now exists. In practice, if the query tests from Step 1 cover the
relevant cases, dedicated producer tests rarely add value.